diff --git a/.aiassistant/rules/rules.md b/.aiassistant/rules/rules.md index 1ca5bc7..fbb1ace 100644 --- a/.aiassistant/rules/rules.md +++ b/.aiassistant/rules/rules.md @@ -104,4 +104,6 @@ always use tanstack/router with web apps always use react query with web apps -always use bun for running scripts and building things \ No newline at end of file +always use bun for running scripts and building things + +always use port 4000 for server listener addresses so they don't conflict with the one running on default 3000 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2943840..b68bc2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@nostr-dev-kit/ndk": "^2.8.2", "@tanstack/react-query": "^5.56.2", "@tanstack/react-router": "^1.58.3", + "emoji-picker-react": "^4.13.3", + "nostr-tools": "^2.7.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -22,13 +24,29 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.11", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", "typescript": "^5.6.2", "vite": "^5.4.6" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1085,6 +1103,53 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1140,7 +1205,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", "license": "MIT", - "peer": true, "funding": { "url": "https://paulmillr.com/funding/" } @@ -1241,6 +1305,17 @@ "nostr-tools": "^2" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1570,7 +1645,6 @@ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", @@ -1585,7 +1659,6 @@ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", "license": "MIT", - "peer": true, "dependencies": { "@noble/hashes": "1.3.1" }, @@ -1598,7 +1671,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 16" }, @@ -1611,7 +1683,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 16" }, @@ -1624,7 +1695,6 @@ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", "license": "MIT", - "peer": true, "funding": { "url": "https://paulmillr.com/funding/" } @@ -1634,7 +1704,6 @@ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", "license": "MIT", - "peer": true, "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" @@ -1648,7 +1717,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 16" }, @@ -1661,7 +1729,6 @@ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", "license": "MIT", - "peer": true, "funding": { "url": "https://paulmillr.com/funding/" } @@ -2383,6 +2450,13 @@ "node": ">=14" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2397,6 +2471,13 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2427,6 +2508,44 @@ "node": ">=4" } }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.10.tgz", @@ -2537,6 +2656,16 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001743", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", @@ -2630,6 +2759,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2665,6 +2804,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2696,6 +2848,13 @@ "dev": true, "license": "MIT" }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/diff": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", @@ -2719,6 +2878,13 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2732,6 +2898,13 @@ "node": ">=6.0.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.222", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", @@ -2739,6 +2912,28 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-picker-react": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.13.3.tgz", + "integrity": "sha512-aZaxCI72oUQfvZtYuQ9RaYLEwmH3GVgAr5SEeB97Y7gWL06zJ4VTuSl8rAMVY7GNmd0tf/EQ1W2SDuXTl0q9AA==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -3120,6 +3315,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -3142,6 +3343,37 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3164,6 +3396,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3310,6 +3552,19 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3379,6 +3634,22 @@ "node": ">=8" } }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3389,6 +3660,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3438,6 +3719,32 @@ "dev": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3549,6 +3856,26 @@ ], "license": "MIT" }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3634,12 +3961,34 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3683,12 +4032,21 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nostr-tools": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.17.0.tgz", "integrity": "sha512-lrvHM7cSaGhz7F0YuBvgHMoU2s8/KuThihDoOYk8w5gpVHTy0DeUCAgCN8uLGeuSl5MAWekJr9Dkfo5HClqO9w==", "license": "Unlicense", - "peer": true, "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", @@ -3712,7 +4070,6 @@ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", "license": "MIT", - "peer": true, "dependencies": { "@noble/hashes": "1.3.2" }, @@ -3725,7 +4082,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 16" }, @@ -3738,7 +4094,6 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 16" }, @@ -3756,15 +4111,33 @@ "url": "https://paulmillr.com/funding/" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/nostr-wasm": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } }, "node_modules/once": { "version": "1.4.0", @@ -3826,6 +4199,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3869,6 +4249,37 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -3899,6 +4310,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3928,6 +4359,133 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4020,6 +4578,16 @@ "node": ">=0.10.0" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4060,6 +4628,27 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4237,6 +4826,19 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4279,6 +4881,76 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "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==", + "dev": true, + "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/string-width-cjs/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4292,6 +4964,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4305,6 +4991,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4318,6 +5048,70 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4325,6 +5119,29 @@ "dev": true, "license": "MIT" }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -4363,6 +5180,13 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tseep": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.3.1.tgz", @@ -4521,6 +5345,13 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.20", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", @@ -5044,6 +5875,107 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/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==", + "dev": true, + "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/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -5058,6 +5990,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 8cf929f..e300ccf 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@nostr-dev-kit/ndk": "^2.8.2", "@tanstack/react-query": "^5.56.2", "@tanstack/react-router": "^1.58.3", + "emoji-picker-react": "^4.13.3", "nostr-tools": "^2.7.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -25,13 +26,13 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.11", - "typescript": "^5.6.2", - "vite": "^5.4.6", - "tailwindcss": "^3.4.13", "postcss": "^8.4.47", - "autoprefixer": "^10.4.20" + "tailwindcss": "^3.4.13", + "typescript": "^5.6.2", + "vite": "^5.4.6" } -} \ No newline at end of file +} diff --git a/src/lib/event.ts b/src/lib/event.ts new file mode 100644 index 0000000..22c5d59 --- /dev/null +++ b/src/lib/event.ts @@ -0,0 +1,81 @@ +import { NDKEvent } from '@nostr-dev-kit/ndk' +import { nip19 } from 'nostr-tools' + +export function getParentETag(event?: NDKEvent) { + if (!event) return undefined + + // For kind 1 (short text notes), look for reply marker first + if (event.kind === 1) { + let tag = event.tags.find(([tagName, , , marker]: string[]) => { + return tagName === 'e' && marker === 'reply' + }) + if (!tag) { + // Fallback to last e-tag that's not a mention + const eTags = event.tags.filter( + ([tagName, tagValue, , marker]: string[]) => + tagName === 'e' && + !!tagValue && + marker !== 'mention' + ) + tag = eTags[eTags.length - 1] + } + return tag + } + + return undefined +} + +export function getParentEventHexId(event?: NDKEvent) { + const tag = getParentETag(event) + return tag?.[1] +} + +export function getParentBech32Id(event?: NDKEvent) { + const eTag = getParentETag(event) + if (!eTag) return undefined + + try { + const eventId = eTag[1] + if (!eventId) return undefined + return nip19.noteEncode(eventId) + } catch { + return undefined + } +} + +export function getRootETag(event?: NDKEvent) { + if (!event) return undefined + + if (event.kind === 1) { + let tag = event.tags.find(([tagName, , , marker]: string[]) => { + return tagName === 'e' && marker === 'root' + }) + if (!tag) { + // Fallback to first e-tag + tag = event.tags.find( + ([tagName, tagValue]: string[]) => tagName === 'e' && !!tagValue + ) + } + return tag + } + + return undefined +} + +export function getRootEventHexId(event?: NDKEvent) { + const tag = getRootETag(event) + return tag?.[1] +} + +export function getRootBech32Id(event?: NDKEvent) { + const eTag = getRootETag(event) + if (!eTag) return undefined + + try { + const eventId = eTag[1] + if (!eventId) return undefined + return nip19.noteEncode(eventId) + } catch { + return undefined + } +} \ No newline at end of file diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 743174b..eae38b9 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,19 +1,23 @@ import { createFileRoute } from '@tanstack/react-router' import { useInfiniteQuery, useQueryClient, useQuery } from '@tanstack/react-query' import { ndk, withTimeout, type LoggedInUser } from '@/lib/ndk' -import { type NDKEvent, type NDKFilter } from '@nostr-dev-kit/ndk' +import { NDKEvent, type NDKFilter } from '@nostr-dev-kit/ndk' import { useEffect, useMemo, useRef, useState } from 'react' import { nip19 } from 'nostr-tools' import { initializeNDK, getConnectionStatus } from '@/lib/ndk' +import { getRootEventHexId } from '@/lib/event' +import EmojiPicker, { type EmojiClickData, Theme } from 'emoji-picker-react' export const Route = createFileRoute('/')({ component: Home, }) -type FeedMode = 'global' | 'user' | 'follows' | 'profile' | 'note' | 'hashtag' +type FeedMode = 'global' | 'user' | 'follows' | 'profile' | 'note' | 'hashtag' | 'notifications' // Event kinds to include in feeds (global and user) -const FEED_KINDS: number[] = [1, 1111, 6, 30023, 9802, 1068, 1222, 1244, 20, 21, 22] +const FEED_KINDS: number[] = [1, 1111, 6, 7, 30023, 9802, 1068, 1222, 1244, 20, 21, 22] +// Event kinds for follows and hashtag feeds (excludes reactions) +const FEED_KINDS_NO_REACTIONS: number[] = [1, 1111, 6, 30023, 9802, 1068, 1222, 1244, 20, 21, 22] type MediaType = 'image' | 'video' type MediaItem = { url: string; type: MediaType } @@ -21,8 +25,9 @@ type MediaItem = { url: string; type: MediaType } type MediaGallery = { items: MediaItem[]; index: number } const URL_REGEX = /(https?:\/\/[^\s]+)/g -const NOSTR_REF_REGEX = /(nostr:(npub1[0-9a-z]+|nprofile1[0-9a-z]+|nevent1[0-9a-z]+|note1[0-9a-z]+))/gi -const HASHTAG_REGEX = /#[a-z0-9_]{1,64}/gi +const NOSTR_REF_REGEX = /(nostr:(npub1[0-9a-z]+|nprofile1[0-9a-z]+|nevent1[0-9a-z]+|note1[0-9a-z]+|naddr1[0-9a-z]+))/gi +// Using this regex inline where needed +// const HASHTAG_REGEX = /#[a-z0-9_]{1,64}/gi const MEDIA_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|bmp|svg|mp4|webm|mov|m4v|avi|mkv)(?:\?.*)?$/i function classifyMedia(url: string): MediaItem | null { if (!MEDIA_EXT_REGEX.test(url)) return null @@ -97,7 +102,7 @@ function renderMarkdownInline(text: string, keyPrefix: string) { return nodes } -function renderContent(text: string, openMedia: (g: MediaGallery) => void, openProfile?: (bech: string) => void, openHashtag?: (tag: string) => void, allowedTags?: string[], isAuthorFollowed?: boolean, onOpenNote?: (id: string) => void, onReply?: (e: NDKEvent) => void, onRepost?: (e: NDKEvent) => void, onQuote?: (e: NDKEvent) => void, onOpenThread?: (e: NDKEvent) => void, scopeId?: string, actionMessages?: Record, replyOpen?: Record, replyBuffers?: Record, onChangeReplyText?: (id: string, v: string) => void, onCloseReply?: (id: string) => void, onSendReply?: (targetId: string) => void, userFollows?: string[]) { +function renderContent(text: string, openMedia: (g: MediaGallery) => void, openProfile?: (bech: string) => void, openHashtag?: (tag: string) => void, allowedTags?: string[], isAuthorFollowed?: boolean, onOpenNote?: (id: string) => void, onReply?: (e: NDKEvent) => void, onRepost?: (e: NDKEvent) => void, onQuote?: (e: NDKEvent) => void, onOpenThread?: (e: NDKEvent) => void, scopeId?: string, actionMessages?: Record, replyOpen?: Record, replyBuffers?: Record, onChangeReplyText?: (id: string, v: string) => void, onCloseReply?: (id: string) => void, onSendReply?: (targetId: string) => void, userFollows?: string[]) { if (!text) return null // Pre-extract all media items in the content to build a gallery for navigation const urls = (text.match(URL_REGEX) || []) as string[] @@ -175,7 +180,7 @@ function renderContent(text: string, openMedia: (g: MediaGallery) => void, openP for (let i = 0; i < subparts.length; i++) { const seg = subparts[i] if (!seg) continue - const m = seg.match(/^nostr:(npub1[0-9a-z]+|nprofile1[0-9a-z]+|nevent1[0-9a-z]+|note1[0-9a-z]+)/i) + const m = seg.match(/^nostr:(npub1[0-9a-z]+|nprofile1[0-9a-z]+|nevent1[0-9a-z]+|note1[0-9a-z]+|naddr1[0-9a-z]+)/i) if (m) { const bech = m[1] if (/^(npub1|nprofile1)/i.test(bech) && openProfile) { @@ -212,6 +217,33 @@ function renderContent(text: string, openMedia: (g: MediaGallery) => void, openP // Skip the next captured subgroup to avoid rendering raw text i += 1 continue + } else if (/^naddr1/i.test(bech)) { + nodes.push( +
+ +
+ ) + // Skip the next captured subgroup to avoid rendering raw text + i += 1 + continue } } // regular text segment -> markdown inline + clickable hashtags (only for tags present in allowedTags if provided) @@ -268,7 +300,7 @@ function InlineNeventNote({ bech, openMedia, openProfile, onOpenNote, openHashta onQuote?: (e: NDKEvent) => void; onOpenThread?: (e: NDKEvent) => void; scopeId?: string; - actionMessages?: Record; + actionMessages?: Record; replyOpen?: Record; replyBuffers?: Record; onChangeReplyText?: (id: string, v: string) => void; @@ -373,6 +405,165 @@ function InlineNeventNote({ bech, openMedia, openProfile, onOpenNote, openHashta onChange={(v) => onChangeReplyText?.(`${scopeId}|${evQuery.data.id!}`, v)} onClose={() => onCloseReply?.(`${scopeId}|${evQuery.data.id!}`)} onSend={() => onSendReply?.(`${scopeId}|${evQuery.data.id!}`)} + replyKey={`${scopeId}|${evQuery.data.id}`} + /> + )} + + {onReply && onRepost && onQuote && evQuery.data && ( +
+ {onOpenThread && ( + + )} + + + +
+ )} + + )} + + + ) +} + +// Inline component to render a referenced naddr (addressable event) as its own note row +function InlineNaddrNote({ bech, openMedia, openProfile, onOpenNote, openHashtag, onReply, onRepost, onQuote, onOpenThread, scopeId, actionMessages, replyOpen, replyBuffers, onChangeReplyText, onCloseReply, onSendReply, userFollows }: { + bech: string; + openMedia: (g: MediaGallery) => void; + openProfile?: (bech: string) => void; + onOpenNote?: (id: string) => void; + openHashtag?: (tag: string) => void; + onReply?: (e: NDKEvent) => void; + onRepost?: (e: NDKEvent) => void; + onQuote?: (e: NDKEvent) => void; + onOpenThread?: (e: NDKEvent) => void; + scopeId?: string; + actionMessages?: Record; + replyOpen?: Record; + replyBuffers?: Record; + onChangeReplyText?: (id: string, v: string) => void; + onCloseReply?: (id: string) => void; + onSendReply?: (targetId: string) => void; + userFollows?: string[]; +}) { + const decoded = useMemo(() => { + try { + const bare = bech.startsWith('nostr:') ? bech.slice(6) : bech + const val = nip19.decode(bare) + if (val.type === 'naddr' && val.data) { + const data = val.data as any + return { + pubkey: data.pubkey, + kind: data.kind, + identifier: data.identifier || '' + } + } + } catch {} + return null + }, [bech]) + + const evQuery = useQuery({ + queryKey: ['naddr-inline', decoded?.pubkey, decoded?.kind, decoded?.identifier], + enabled: !!decoded, + staleTime: 1000 * 60 * 5, + queryFn: async () => { + if (!decoded) return null + try { + const filter: NDKFilter = { + authors: [decoded.pubkey], + kinds: [decoded.kind], + '#d': [decoded.identifier], + limit: 1 + } + const set = await withTimeout(ndk.fetchEvents(filter as any), 7000, 'fetch naddr') + const list = Array.from(set) + return list[0] || null + } catch { + return null + } + }, + }) + + return ( +
+
+ {!decoded ? ( +
Invalid addressable event reference.
+ ) : evQuery.isLoading ? ( +
Loading referenced event…
+ ) : !evQuery.data ? ( +
Referenced event not found.
+ ) : ( +
+
+
+ + · + +
+ {evQuery.data.kind === 6 ? ( + onOpenNote(ev.id || '') : undefined} + scopeId={scopeId} + actionMessages={actionMessages} + replyOpen={replyOpen} + replyBuffers={replyBuffers} + onChangeReplyText={onChangeReplyText} + onCloseReply={onCloseReply} + onSendReply={onSendReply} + userFollows={userFollows} + /> + ) : ( +
+ {renderContent(evQuery.data.content, openMedia, openProfile, openHashtag, extractHashtagTags((evQuery.data as any)?.tags), userFollows?.includes(evQuery.data.pubkey), onOpenNote, onReply, onRepost, onQuote, onOpenThread, scopeId, actionMessages, replyOpen, replyBuffers, onChangeReplyText, onCloseReply, onSendReply, userFollows)} + + {/* Hashtag list for 't' tag hashtags */} + {extractHashtagTags((evQuery.data as any)?.tags).length > 0 && ( +
+ {extractHashtagTags((evQuery.data as any)?.tags).map((tag, idx) => ( + + ))} +
+ )} +
+ )} + {actionMessages?.[evQuery.data.id || ''] && ( +
+ {actionMessages[evQuery.data.id || '']} +
+ )} + {(evQuery.data.id && scopeId && replyOpen?.[`${scopeId}|${evQuery.data.id}`]) && ( + onChangeReplyText?.(`${scopeId}|${evQuery.data.id!}`, v)} + onClose={() => onCloseReply?.(`${scopeId}|${evQuery.data.id!}`)} + onSend={() => onSendReply?.(`${scopeId}|${evQuery.data.id!}`)} + replyKey={`${scopeId}|${evQuery.data.id}`} /> )}
@@ -458,12 +649,15 @@ function SingleNoteView({ id, scopeId, openMedia, openProfileByBech, openProfile onQuote={onQuote} onOpenNote={onOpenNote} scopeId={scopeId} - actionMessages={actionMessages} + actionMessages={{[ev.id || '']: actionMessage}} replyOpen={replyOpen} replyBuffers={replyBuffers} onChangeReplyText={onChangeReplyText} onCloseReply={onCloseReply} onSendReply={onSendReply} + userFollows={undefined} + repostMode={repostMode} + onCancelRepost={onCancelRepost} /> ) : (
{renderContent(ev.content, openMedia, openProfileByBech, openHashtag, extractHashtagTags((ev as any)?.tags), false, (id: string) => onOpenNote({id} as NDKEvent), onReply, onRepost, onQuote, undefined, scopeId, {[ev.id || '']: actionMessage}, replyOpen, replyBuffers, onChangeReplyText, onCloseReply, onSendReply, undefined)}
@@ -495,6 +689,7 @@ function SingleNoteView({ id, scopeId, openMedia, openProfileByBech, openProfile onChange={(v) => onChangeReplyText?.(`${scopeId}|${ev.id!}`, v)} onClose={() => onCloseReply?.(`${scopeId}|${ev.id!}`)} onSend={() => onSendReply?.(`${scopeId}|${ev.id!}`)} + replyKey={`${scopeId}|${ev.id}`} /> )}
@@ -517,7 +712,7 @@ function SingleNoteView({ id, scopeId, openMedia, openProfileByBech, openProfile function Home() { // Thread modal state (narrow) and side panel state (wide) const [threadRootId, setThreadRootId] = useState(null) - const [threadOpenSeed, setThreadOpenSeed] = useState(null) // store clicked event id for context + const [threadOpenSeed, _setThreadOpenSeed] = useState(null) // store clicked event id for context // Multiple opened threads state for thread stack view const [openedThreads, setOpenedThreads] = useState([]) const [threadTriggerNotes, setThreadTriggerNotes] = useState>({}) // Map thread root ID to triggering note ID @@ -543,7 +738,7 @@ function Home() { // Feed mode and user info (from localStorage saved by Root) const [mode, setMode] = useState('global') const [user, setUser] = useState(null) - const [isWide, setIsWide] = useState(false) + const [_isWide, _setIsWide] = useState(false) // Unused but keeping for potential future use useEffect(() => { try { const saved = localStorage.getItem('nostrUser') @@ -557,8 +752,8 @@ function Home() { // Dynamic layout measurement: determine if main view and thread panel can fit side-by-side const layoutRef = useRef(null) const mainColRef = useRef(null) - const sidebarRef = useRef(null) - const [sidebarWidthPx, setSidebarWidthPx] = useState(0) + const _sidebarRef = useRef(null) // Unused but kept for future implementation + const [_sidebarWidthPx, _setSidebarWidthPx] = useState(0) // Unused but kept for future implementation const [canFitBoth, setCanFitBoth] = useState(false) // Sidebar fit detection - check if sidebar should be in drawer mode const [canFitSidebar, setCanFitSidebar] = useState(true) @@ -706,8 +901,9 @@ function Home() { }, [threadTriggerNotes]) // Action message state: per-event label to show in-note - const [actionMessages, setActionMessages] = useState>({}) - const showActionMessage = (ev: NDKEvent, label: string) => { + const [actionMessages, setActionMessages] = useState>({}) + // Unused but keeping for potential future use + const _showActionMessage = (ev: NDKEvent, label: string) => { const id = ev.id || '' if (!id) return setActionMessages(prev => ({ ...prev, [id]: label })) @@ -721,6 +917,19 @@ function Home() { }, 3000) } + // Repost mode state: per-event toggle for repost confirmation + const [repostMode, setRepostMode] = useState>({}) + const toggleRepostMode = (ev: NDKEvent) => { + const id = ev.id || '' + if (!id) return + setRepostMode(prev => ({ ...prev, [id]: !prev[id] })) + } + const cancelRepost = (ev: NDKEvent) => { + const id = ev.id || '' + if (!id) return + setRepostMode(prev => ({ ...prev, [id]: false })) + } + // Reply composers: independent per-note open state and persistent buffers const [replyOpen, setReplyOpen] = useState>({}) const [replyBuffers, setReplyBuffers] = useState>({}) @@ -737,6 +946,22 @@ function Home() { try { localStorage.setItem('replyBuffers', JSON.stringify(replyBuffers)) } catch {} }, [replyBuffers]) + // Quote composers: independent per-note open state and persistent buffers + const [quoteOpen, setQuoteOpen] = useState>({}) + const [quoteBuffers, setQuoteBuffers] = useState>({}) + + // Hydrate quote buffers + useEffect(() => { + try { + const saved = localStorage.getItem('quoteBuffers') + if (saved) setQuoteBuffers(JSON.parse(saved)) + } catch {} + }, []) + // Persist quote buffers + useEffect(() => { + try { localStorage.setItem('quoteBuffers', JSON.stringify(quoteBuffers)) } catch {} + }, [quoteBuffers]) + // Thread view single-active editor bridge (for ThreadModal/ThreadPanel legacy props) const [threadActiveReplyTargetKey, setThreadActiveReplyTargetKey] = useState(null) const changeThreadReplyText = (v: string) => { @@ -757,17 +982,124 @@ function Home() { const id = ev.id || '' if (!id) return const key = `${scopeId}|${id}` + const wasOpen = replyOpen[key] || false + + // Close any open quote panels for this event when opening reply + setQuoteOpen(prev => { + const updated = { ...prev } + const quoteKey = `quote|${id}` + if (updated[quoteKey]) { + updated[quoteKey] = false + } + return updated + }) + setReplyOpen(prev => ({ ...prev, [key]: !prev[key] })) // Manage thread inline editor target when in a thread view (use composite key) if (scopeId.startsWith('thread-modal:') || scopeId.startsWith('thread-panel:')) { setThreadActiveReplyTargetKey(prev => (prev === key ? null : key)) } + + // If reply is being opened (was closed, now opening), scroll to bring it into view + if (!wasOpen) { + setTimeout(() => { + try { + // Find all ReplyComposer elements and look for the one that was just opened + const replyElements = document.querySelectorAll('[data-reply-key]') + let targetElement: Element | null = null + let targetTextarea: HTMLTextAreaElement | null = null + + // If no data-reply-key attributes found, fallback to finding by textarea placeholder + if (replyElements.length === 0) { + const textareas = document.querySelectorAll('textarea[placeholder="Write a reply..."]') + // Get the last textarea (most recently opened) + if (textareas.length > 0) { + targetTextarea = textareas[textareas.length - 1] as HTMLTextAreaElement + targetElement = targetTextarea.closest('.mt-3') + } + } else { + // Find the specific reply element by key + targetElement = document.querySelector(`[data-reply-key="${key}"]`) + if (targetElement) { + targetTextarea = targetElement.querySelector('textarea[placeholder="Write a reply..."]') as HTMLTextAreaElement + } + } + + if (targetElement) { + // Scroll so that the bottom of the reply input is at the bottom of the viewport + const rect = targetElement.getBoundingClientRect() + const viewportHeight = window.innerHeight + const scrollTop = window.pageYOffset || document.documentElement.scrollTop + const elementBottom = rect.bottom + scrollTop + const targetScrollTop = elementBottom - viewportHeight + + window.scrollTo({ + top: Math.max(0, targetScrollTop), + behavior: 'smooth' + }) + + // Focus the textarea after scroll completes (smooth scroll takes ~300-500ms) + if (targetTextarea) { + setTimeout(() => { + try { + targetTextarea.focus() + } catch (error) { + console.warn('Error focusing reply textarea:', error) + } + }, 500) // Wait for smooth scroll to complete + } + } + } catch (error) { + console.warn('Error scrolling to reply input:', error) + } + }, 100) // Allow time for DOM update + } } const onRepost = (ev: NDKEvent) => { - showActionMessage(ev, 'Repost') + toggleRepostMode(ev) } + // Helper function to generate nevent encoding for an event + const generateNeventForEvent = (ev: NDKEvent): string => { + if (!ev.id) return '' + try { + return nip19.neventEncode({ + id: ev.id, + author: ev.pubkey, + kind: ev.kind + }) + } catch { + return '' + } + } + const onQuote = (ev: NDKEvent) => { - showActionMessage(ev, 'Quote') + const id = ev.id || '' + if (!id) return + + // Generate nevent encoding for the quoted event + const nevent = generateNeventForEvent(ev) + if (!nevent) return + + const key = `quote|${id}` + const quoteContent = `\n\nnostr:${nevent}` + + // Close any open reply panels for this event when opening quote + setReplyOpen(prev => { + const updated = { ...prev } + // Close reply panels for all scopes of this event + Object.keys(updated).forEach(replyKey => { + if (replyKey.endsWith(`|${id}`)) { + updated[replyKey] = false + } + }) + return updated + }) + + // Set the quote buffer with pre-filled content + setQuoteBuffers(prev => ({ ...prev, [key]: quoteContent })) + + // Toggle the quote panel + setQuoteOpen(prev => ({ ...prev, [key]: !prev[key] })) } const closeReply = (id: string) => { setReplyOpen(prev => ({ ...prev, [id]: false })) @@ -775,12 +1107,31 @@ function Home() { const changeReplyText = (id: string, v: string) => { setReplyBuffers(prev => ({ ...prev, [id]: v })) } + const closeQuote = (id: string) => { + setQuoteOpen(prev => ({ ...prev, [id]: false })) + } + const changeQuoteText = (id: string, v: string) => { + setQuoteBuffers(prev => ({ ...prev, [id]: v })) + } + const sendQuote = (targetId: string) => { + // For now, just close the composer and clear its buffer; sending is not yet implemented + setQuoteOpen(prev => ({ ...prev, [targetId]: false })) + setQuoteBuffers(prev => ({ ...prev, [targetId]: '' })) + setActionMessages(prev => ({ ...prev, [targetId]: 'Quote posted' })) + setTimeout(() => { + setActionMessages(prev => { + const copy = { ...prev } + if (copy[targetId] === 'Quote posted') delete copy[targetId] + return copy + }) + }, 2000) + } const sendReply = (targetId: string) => { // For now, just close the composer and clear its buffer; sending is not yet implemented setReplyOpen(prev => ({ ...prev, [targetId]: false })) setReplyBuffers(prev => ({ ...prev, [targetId]: '' })) // If this was the active thread editor, clear the bridge state - setThreadActiveReplyTargetId(prev => (prev === targetId ? null : prev)) + setThreadActiveReplyTargetKey(prev => (prev === targetId ? null : prev)) setActionMessages(prev => ({ ...prev, [targetId]: 'Reply sent' })) setTimeout(() => { setActionMessages(prev => { @@ -806,11 +1157,7 @@ function Home() { const openNoteForEvent = (ev: NDKEvent) => openNoteById(ev.id) const getThreadRootId = (ev: NDKEvent): string => { - const eTags = (ev.tags || []).filter(t => t[0] === 'e') - const root = eTags.find(t => (t[3] === 'root'))?.[1] as string | undefined - const reply = eTags.find(t => (t[3] === 'reply'))?.[1] as string | undefined - const any = eTags[0]?.[1] as string | undefined - return (root || reply || any || ev.id || '') + return getRootEventHexId(ev) || ev.id || '' } const openThreadFor = (ev: NDKEvent) => { const root = getThreadRootId(ev) @@ -1121,6 +1468,7 @@ function Home() { let label = '' if (mode === 'global') label = 'Global' else if (mode === 'follows') label = 'Follows' + else if (mode === 'notifications') label = 'Notifications' else if (mode === 'user') label = 'Me' else if (mode === 'profile') { if (profilePubkey && user?.pubkey && profilePubkey === user.pubkey) label = 'Me' @@ -1141,14 +1489,16 @@ function Home() { ? ['profile-feed', profilePubkey ?? 'none'] : mode === 'hashtag' ? ['hashtag-feed', currentHashtag || 'none'] + : mode === 'notifications' + ? ['notifications-feed', user?.pubkey ?? 'anon'] : ['follows-feed', user?.pubkey ?? 'anon', (followsQuery.data || []).length], retry: (failureCount: number) => mode === 'hashtag' ? false : failureCount < 2, initialPageParam: null as number | null, // until cursor (unix seconds) queryFn: async ({ pageParam }) => { - // Global, user, profile and hashtag modes use a single filter - if (mode === 'global' || mode === 'user' || mode === 'profile' || mode === 'hashtag') { + // Global, user, profile, hashtag and notifications modes use a single filter + if (mode === 'global' || mode === 'user' || mode === 'profile' || mode === 'hashtag' || mode === 'notifications') { const filter: NDKFilter = { - kinds: FEED_KINDS, + kinds: (mode === 'hashtag' || mode === 'global') ? FEED_KINDS_NO_REACTIONS : FEED_KINDS, limit: PAGE_SIZE, } if (mode === 'user' && user?.pubkey) { @@ -1160,6 +1510,9 @@ function Home() { if (mode === 'hashtag' && currentHashtag) { ;(filter as any)['#t'] = [currentHashtag] } + if (mode === 'notifications' && user?.pubkey) { + ;(filter as any)['#p'] = [user.pubkey] + } if (pageParam) { ;(filter as any).until = pageParam } @@ -1180,7 +1533,7 @@ function Home() { if (!follows.length) return [] const filters: NDKFilter[] = chunk(follows, 20).map(group => { - const f: NDKFilter = { kinds: FEED_KINDS, authors: group as any, limit: PAGE_SIZE } + const f: NDKFilter = { kinds: FEED_KINDS_NO_REACTIONS, authors: group as any, limit: PAGE_SIZE } if (pageParam) (f as any).until = pageParam return f }) @@ -1195,7 +1548,7 @@ function Home() { return ts > 0 ? ts : null }, refetchOnWindowFocus: false, - enabled: mode === 'global' || (mode === 'profile' ? !!profilePubkey : mode === 'hashtag' ? !!currentHashtag : !!user?.pubkey), + enabled: mode === 'global' || (mode === 'profile' ? !!profilePubkey : mode === 'hashtag' ? !!currentHashtag : mode === 'notifications' ? !!user?.pubkey : !!user?.pubkey), }) // IntersectionObservers to trigger loading more (bottom) and fetching newer (top) @@ -1257,9 +1610,9 @@ function Home() { lastTopFetchRef.current = now setIsFetchingNewer(true) try { - if (mode === 'global' || mode === 'user' || mode === 'profile' || mode === 'hashtag') { + if (mode === 'global' || mode === 'user' || mode === 'profile' || mode === 'hashtag' || mode === 'notifications') { const filter: NDKFilter = { - kinds: FEED_KINDS, + kinds: (mode === 'hashtag' || mode === 'global') ? FEED_KINDS_NO_REACTIONS : FEED_KINDS, limit: PAGE_SIZE, } if (mode === 'user' && user?.pubkey) { @@ -1271,6 +1624,9 @@ function Home() { if (mode === 'hashtag' && currentHashtag) { ;(filter as any)['#t'] = [currentHashtag] } + if (mode === 'notifications' && user?.pubkey) { + ;(filter as any)['#p'] = [user.pubkey] + } if (newestTs > 0) { ;(filter as any).since = newestTs + 1 } @@ -1283,6 +1639,8 @@ function Home() { ? ['profile-feed', profilePubkey ?? 'none'] : mode === 'hashtag' ? ['hashtag-feed', currentHashtag ?? ''] + : mode === 'notifications' + ? ['notifications-feed', user?.pubkey ?? 'anon'] : ['user-feed', user?.pubkey ?? 'anon'] queryClient.setQueryData(key, (oldData: any) => { if (!oldData) return { pages: [fresh], pageParams: [null] } @@ -1301,7 +1659,7 @@ function Home() { const follows = (followsQuery.data || []) as string[] if (!user?.pubkey || !follows.length) return const filters: NDKFilter[] = chunk(follows, 20).map(group => { - const f: NDKFilter = { kinds: FEED_KINDS, authors: group as any, limit: PAGE_SIZE } + const f: NDKFilter = { kinds: FEED_KINDS_NO_REACTIONS, authors: group as any, limit: PAGE_SIZE } if (newestTs > 0) (f as any).since = newestTs + 1 return f }) @@ -1339,7 +1697,7 @@ function Home() { }, { rootMargin: '0px' }) io.observe(el) return () => io.disconnect() - }, [newestTs]) + }, []) // Remove newestTs dependency to prevent observer recreation // Window-level touch handlers for pull-to-refresh (top) and pull-to-load (bottom) useEffect(() => { @@ -1464,17 +1822,46 @@ function Home() { }, 1200) } + // Get user's mute list for filtering + const muteList = useMemo(() => { + if (!user?.pubkey) return new Set() + try { + const ndkUser = ndk.getUser({ pubkey: user.pubkey }) + // Access mutelist as a custom property with type assertion + const mutelist = (ndkUser as any).mutelist + if (mutelist && Array.isArray(mutelist)) { + return new Set(mutelist.map((item: any) => typeof item === 'string' ? item : item.pubkey).filter(Boolean)) + } + } catch (error) { + console.warn('Failed to get mute list:', error) + } + return new Set() + }, [user?.pubkey]) + // Flatten and de-duplicate by event id const events: NDKEvent[] = useMemo(() => { const map = new Map() for (const page of feedQuery.data?.pages || []) { for (const ev of page) { - if (ev.id && !map.has(ev.id)) map.set(ev.id, ev) + if (ev.id && !map.has(ev.id)) { + // Filter out events from muted users for follows and notifications feeds + if ((mode === 'follows' || mode === 'notifications') && ev.pubkey && muteList.has(ev.pubkey)) { + continue + } + // Filter out events that mention muted users for follows and notifications feeds + if ((mode === 'follows' || mode === 'notifications') && ev.tags) { + const mentionsMutedUser = ev.tags.some(tag => + tag[0] === 'p' && tag[1] && muteList.has(tag[1]) + ) + if (mentionsMutedUser) continue + } + map.set(ev.id, ev) + } } } // Return in newest-first order return Array.from(map.values()).sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) - }, [feedQuery.data]) + }, [feedQuery.data, mode, muteList]) // Auto-refresh: periodically check connection and refresh feed content useEffect(() => { @@ -1672,16 +2059,6 @@ function Home() {
))} -
- -
{user && (
)} + {user && ( +
+ +
+ )} +
+ +
)}
- {(mode === 'user' || mode === 'follows') && !user ? ( + {(mode === 'user' || mode === 'follows' || mode === 'notifications') && !user ? (

Please use the Login button in the top bar to view this feed.

@@ -1819,30 +2219,77 @@ function Home() { ) : events.length === 0 && feedQuery.isLoading ? (
Loading feed…
) : ( - events.map((ev) => ( - - )) + events.map((ev) => { + // For notifications mode, use compact display for reactions + if (mode === 'notifications' && ev.kind === 7) { + return ( + + ) + } + + // For user's feed, use special display for reactions (no interaction buttons) + if (mode === 'user' && ev.kind === 7) { + return ( + + ) + } + + // For profile feeds, use special display for reactions that shows the embedded note + if (mode === 'profile' && ev.kind === 7) { + return ( + + ) + } + + // Regular note card for all other events + return ( + + ) + }) )} {mode !== 'note' && !feedQuery.hasNextPage && events.length > 0 && (
No more results.
@@ -1890,9 +2337,16 @@ function Home() { onSendReply={(id) => sendReply(`thread-modal:${threadRootId}|${id}`)} activeReplyTargetId={threadActiveReplyTargetKey ? threadActiveReplyTargetKey.split('|')[1] : null} replyText={threadActiveReplyTargetKey ? (replyBuffers[threadActiveReplyTargetKey] || '') : ''} - onChangeReplyText={changeThreadReplyText} - onCloseReply={closeThreadReply} + onChangeThreadReplyText={changeThreadReplyText} + onCloseThreadReply={closeThreadReply} openHashtag={openHashtag} + repostMode={repostMode} + onCancelRepost={cancelRepost} + quoteOpen={quoteOpen} + quoteBuffers={quoteBuffers} + onChangeQuoteText={changeQuoteText} + onCloseQuote={closeQuote} + onSendQuote={sendQuote} /> )}
@@ -1943,6 +2397,13 @@ function Home() { }} onCloseThread={closeThreadFromStack} userFollows={followsQuery.data || []} + repostMode={repostMode} + onCancelRepost={cancelRepost} + quoteOpen={quoteOpen} + quoteBuffers={quoteBuffers} + onChangeQuoteText={changeQuoteText} + onCloseQuote={closeQuote} + onSendQuote={sendQuote} />
@@ -2055,6 +2516,13 @@ function Home() { }} onCloseThread={closeThreadFromStack} userFollows={followsQuery.data || []} + repostMode={repostMode} + onCancelRepost={cancelRepost} + quoteOpen={quoteOpen} + quoteBuffers={quoteBuffers} + onChangeQuoteText={changeQuoteText} + onCloseQuote={closeQuote} + onSendQuote={sendQuote} /> @@ -2213,6 +2681,17 @@ function Home() { Follows )} + {user && ( + + )} - + {repostMode?.[ev.id || ''] ? ( +
+ + +
+ ) : ( + + )} @@ -2486,7 +2977,27 @@ function ThreadModal({ rootId, seedId: _seedId, onClose, openMedia, openProfileB ) } -function NoteCard({ ev, scopeId, onReply, onRepost, onQuote, onOpenThread, onOpenNote, openMedia, openProfileByBech, openProfileByPubkey, activeThreadRootId, actionMessages, replyOpen, replyBuffers, onChangeReplyText, onCloseReply, onSendReply, openHashtag, userFollows, hideThread }: { ev: NDKEvent; scopeId: string; onReply: (e: NDKEvent) => void; onRepost: (e: NDKEvent) => void; onQuote: (e: NDKEvent) => void; onOpenThread: (e: NDKEvent) => void; onOpenNote: (e: NDKEvent) => void; openMedia: (g: MediaGallery) => void; openProfileByBech: (bech: string) => void; openProfileByPubkey: (pubkey: string) => void; activeThreadRootId?: string | null; actionMessages?: Record; replyOpen?: Record; replyBuffers?: Record; onChangeReplyText?: (id: string, v: string) => void; onCloseReply?: (id: string) => void; onSendReply?: (targetId: string) => void; openHashtag?: (tag: string) => void; userFollows?: string[]; hideThread?: boolean }) { +export function NoteCard({ ev, scopeId, onReply, onRepost, onQuote, onOpenThread, onOpenNote, openMedia, openProfileByBech, openProfileByPubkey, activeThreadRootId, actionMessages, replyOpen, replyBuffers, onChangeReplyText, onCloseReply, onSendReply, openHashtag, userFollows, hideThread, userPubkey, showActionMessage, repostMode, onCancelRepost, quoteOpen, quoteBuffers, onChangeQuoteText, onCloseQuote, onSendQuote }: { ev: NDKEvent; scopeId: string; onReply: (e: NDKEvent) => void; onRepost: (e: NDKEvent) => void; onQuote: (e: NDKEvent) => void; onOpenThread: (e: NDKEvent) => void; onOpenNote: (e: NDKEvent) => void; openMedia: (g: MediaGallery) => void; openProfileByBech: (bech: string) => void; openProfileByPubkey: (pubkey: string) => void; activeThreadRootId?: string | null; actionMessages?: Record; replyOpen?: Record; replyBuffers?: Record; onChangeReplyText?: (id: string, v: string) => void; onCloseReply?: (id: string) => void; onSendReply?: (targetId: string) => void; openHashtag?: (tag: string) => void; userFollows?: string[]; hideThread?: boolean; userPubkey?: string; showActionMessage?: (e: NDKEvent, msg: string) => void; repostMode?: Record; onCancelRepost?: (e: NDKEvent) => void; quoteOpen?: Record; quoteBuffers?: Record; onChangeQuoteText?: (id: string, v: string) => void; onCloseQuote?: (id: string) => void; onSendQuote?: (targetId: string) => void }) { + + // Handle reaction creation + const handleReaction = async (targetEvent: NDKEvent, emoji: string) => { + if (!targetEvent.id || !userPubkey) return + try { + const reactionEvent = new NDKEvent(ndk) + reactionEvent.kind = 7 // reaction event + reactionEvent.content = emoji + reactionEvent.tags = [ + ['e', targetEvent.id], + ['p', targetEvent.pubkey] + ] + await reactionEvent.publish() + // Show success message + showActionMessage?.(targetEvent, `Reacted with ${emoji}`) + } catch (error) { + console.error('Failed to publish reaction:', error) + showActionMessage?.(targetEvent, 'Failed to react') + } + } const getThreadRootIdLocal = (ev: NDKEvent): string => { const eTags = (ev.tags || []).filter(t => t[0] === 'e') const root = eTags.find(t => (t[3] === 'root'))?.[1] as string | undefined @@ -2496,6 +3007,9 @@ function NoteCard({ ev, scopeId, onReply, onRepost, onQuote, onOpenThread, onOpe } const thisRootId = getThreadRootIdLocal(ev) const isActiveThread = !!activeThreadRootId && activeThreadRootId === thisRootId + + // Check if this event mentions the logged-in user + const mentionsUser = userPubkey && (ev.tags || []).some(t => t[0] === 'p' && t[1] === userPubkey) const [expanded, setExpanded] = useState(false) const [isOverflowing, setIsOverflowing] = useState(false) @@ -2503,6 +3017,7 @@ function NoteCard({ ev, scopeId, onReply, onRepost, onQuote, onOpenThread, onOpe const wrapperRef = useRef(null) const innerRef = useRef(null) const cardRef = useRef(null) + const buttonRowRef = useRef(null) const [isVisible, setIsVisible] = useState(false) // Observe when the card enters the viewport to trigger thread search lazily @@ -2529,8 +3044,8 @@ function NoteCard({ ev, scopeId, onReply, onRepost, onQuote, onOpenThread, onOpe staleTime: 1000 * 60 * 5, queryFn: async () => { try { - // Perform a lightweight crawl: 1 depth is enough to verify thread availability - const res = await fetchThreadEventsRecursive(thisRootId, undefined as any, 1, 50) + // Check for direct replies to verify thread availability + const res = await fetchThreadEvents(thisRootId) return (res || []).length } catch { return 0 @@ -2539,6 +3054,80 @@ function NoteCard({ ev, scopeId, onReply, onRepost, onQuote, onOpenThread, onOpe }) const showThreadButton = threadProbe.isSuccess + // Scroll button row to bottom of viewport + const scrollButtonRowToBottom = () => { + if (buttonRowRef.current) { + const rect = buttonRowRef.current.getBoundingClientRect() + const scrollAmount = rect.bottom - window.innerHeight + if (scrollAmount > 0) { + window.scrollBy({ + top: scrollAmount, + behavior: 'smooth' + }) + } + } + } + + // Function to scroll QuoteComposer to center of viewport and focus textarea + const scrollQuoteComposerToCenter = (ev: NDKEvent) => { + // Wait for the QuoteComposer to be rendered after state update + setTimeout(() => { + const quoteKey = `quote|${ev.id}` + const quoteComposer = document.querySelector(`[data-quote-key="${quoteKey}"]`) + if (quoteComposer) { + const rect = quoteComposer.getBoundingClientRect() + const viewportCenter = window.innerHeight / 2 + const elementCenter = rect.top + rect.height / 2 + const scrollAmount = elementCenter - viewportCenter + + window.scrollBy({ + top: scrollAmount, + behavior: 'smooth' + }) + + // Focus the textarea within the quote composer + const textarea = quoteComposer.querySelector('textarea') + if (textarea) { + textarea.focus() + // Move cursor to the beginning of the text input + textarea.setSelectionRange(0, 0) + } + } + }, 100) // Small delay to ensure the component is rendered + } + + // Enhanced quote handler + const handleQuote = (ev: NDKEvent) => { + onQuote(ev) + scrollQuoteComposerToCenter(ev) + } + + // Function to scroll ReplyComposer to center of viewport + const scrollReplyComposerToCenter = (ev: NDKEvent) => { + // Wait for the ReplyComposer to be rendered after state update + setTimeout(() => { + const replyKey = `${scopeId}|${ev.id}` + const replyComposer = document.querySelector(`[data-reply-key="${replyKey}"]`) + if (replyComposer) { + const rect = replyComposer.getBoundingClientRect() + const viewportCenter = window.innerHeight / 2 + const elementCenter = rect.top + rect.height / 2 + const scrollAmount = elementCenter - viewportCenter + + window.scrollBy({ + top: scrollAmount, + behavior: 'smooth' + }) + } + }, 100) // Small delay to ensure the component is rendered + } + + // Enhanced reply handler + const handleReply = (ev: NDKEvent) => { + onReply(ev) + scrollReplyComposerToCenter(ev) + } + useEffect(() => { const calc = () => { const inner = innerRef.current @@ -2560,122 +3149,186 @@ function NoteCard({ ev, scopeId, onReply, onRepost, onQuote, onOpenThread, onOpe return (
-
-
-
- openProfileByPubkey(pk)} /> - · - - -
- - {/* JSON Viewer */} - {jsonViewerOpen && ( -
-
- Event JSON -
-
-
-                  {JSON.stringify({
-                    id: ev.id,
-                    pubkey: ev.pubkey,
-                    created_at: ev.created_at,
-                    kind: ev.kind,
-                    tags: ev.tags,
-                    content: ev.content,
-                    sig: ev.sig
-                  }, null, 2)}
-                
-
-
- )} - - {/* Collapsible content wrapper capped at 50vh when not expanded */} -
-
- {ev.kind === 6 ? ( - - ) : ( -
{renderContent(ev.content, openMedia, openProfileByBech, openHashtag, extractHashtagTags((ev as any)?.tags), userFollows ? userFollows.includes(ev.pubkey || '') : false, (id: string) => onOpenNote({id} as NDKEvent), onReply, onRepost, onQuote, onOpenThread, scopeId, actionMessages, replyOpen, replyBuffers, onChangeReplyText, onCloseReply, onSendReply, userFollows)}
+
+
+
+
+ openProfileByPubkey(pk)} /> + · + + {mentionsUser && ( + <> + · + {ev.kind === 6 ? 'reposted' : 'reply'} + )} - - {/* Hashtag list for 't' tag hashtags */} - {ev.kind !== 6 && extractHashtagTags((ev as any)?.tags).length > 0 && ( -
- {extractHashtagTags((ev as any)?.tags).map((tag, idx) => ( - - ))} -
- )} -
- {!expanded && isOverflowing && ( - - - {/* Revealer button */} - {!expanded && isOverflowing && ( -
-
- )} + - {/* Action message box at bottom of note content */} - {actionMessages?.[ev.id || ''] && ( -
- {actionMessages[ev.id || '']} + {/* JSON Viewer */} + {jsonViewerOpen && ( +
+
+ Event JSON +
+
+
+                    {JSON.stringify({
+                      id: ev.id,
+                      pubkey: ev.pubkey,
+                      created_at: ev.created_at,
+                      kind: ev.kind,
+                      tags: ev.tags,
+                      content: ev.content,
+                      sig: ev.sig
+                    }, null, 2)}
+                  
+
+
+ )} + + {/* Collapsible content wrapper capped at 50vh when not expanded */} +
+
+ {ev.kind === 6 ? ( + + ) : ( +
{renderContent(ev.content, openMedia, openProfileByBech, openHashtag, extractHashtagTags((ev as any)?.tags), userFollows ? userFollows.includes(ev.pubkey || '') : false, (id: string) => onOpenNote({id} as NDKEvent), onReply, onRepost, onQuote, onOpenThread, scopeId, actionMessages, replyOpen, replyBuffers, onChangeReplyText, onCloseReply, onSendReply, userFollows)}
+ )} +
+ {!expanded && isOverflowing && ( + - )} - {(ev.id && replyOpen?.[`${scopeId}|${ev.id}`]) && ( - onChangeReplyText?.(`${scopeId}|${ev.id!}`, v)} - onClose={() => onCloseReply?.(`${scopeId}|${ev.id!}`)} - onSend={() => onSendReply?.(`${scopeId}|${ev.id!}`)} + + {/* Hashtag list for 't' tag hashtags - always visible at the bottom */} + {ev.kind !== 6 && extractHashtagTags((ev as any)?.tags).length > 0 && ( +
+ {extractHashtagTags((ev as any)?.tags).map((tag, idx) => ( + + ))} +
+ )} + + {/* Revealer button */} + {!expanded && isOverflowing && ( +
+ +
+ )} + + {/* Action message box at bottom of note content */} + {actionMessages?.[ev.id || ''] && ( +
+ {actionMessages[ev.id || '']} +
+ )} + + {/* Reaction buttons row at bottom left */} + handleReaction(ev, emoji)} /> - )} + + {(ev.id && replyOpen?.[`${scopeId}|${ev.id}`]) && ( + onChangeReplyText?.(`${scopeId}|${ev.id!}`, v)} + onClose={() => onCloseReply?.(`${scopeId}|${ev.id!}`)} + onSend={() => onSendReply?.(`${scopeId}|${ev.id!}`)} + replyKey={`${scopeId}|${ev.id}`} + /> + )} + + {(ev.id && quoteOpen?.[`quote|${ev.id}`]) && ( + onChangeQuoteText?.(`quote|${ev.id!}`, v)} + onClose={() => onCloseQuote?.(`quote|${ev.id!}`)} + onSend={() => onSendQuote?.(`quote|${ev.id!}`)} + quoteKey={`quote|${ev.id}`} + /> + )} +
+
+ {showThreadButton ? ( + + ) : null} +
-
- {showThreadButton ? ( - - ) : null} - - - + +
+ ) : ( + + )} + +
@@ -2684,6 +3337,183 @@ function NoteCard({ ev, scopeId, onReply, onRepost, onQuote, onOpenThread, onOpe ) } +function ReactionButtonRow({ eventId, onReact }: { eventId: string; onReact: (emoji: string) => void }) { + const [isEmojiModalOpen, setIsEmojiModalOpen] = useState(false) + const [modalPosition, setModalPosition] = useState({ x: 0, y: 0 }) + const reactButtonRef = useRef(null) + + // Query for existing reactions to this event + const { data: reactions } = useQuery({ + queryKey: ['reactions', eventId], + enabled: !!eventId, + staleTime: 1000 * 60 * 2, + queryFn: async () => { + if (!eventId) return [] + try { + const filter: NDKFilter = { kinds: [7], '#e': [eventId], limit: 100 } + const set = await withTimeout(ndk.fetchEvents(filter as any), 6000, 'fetch reactions') + return Array.from(set) + } catch { + return [] + } + }, + }) + + // Group reactions by emoji and count them + const reactionCounts = useMemo(() => { + if (!reactions) return {} + const counts: Record = {} + for (const reaction of reactions) { + const emoji = reaction.content || '❤️' + if (!counts[emoji]) counts[emoji] = { count: 0, users: [] } + counts[emoji].count++ + if (reaction.pubkey) counts[emoji].users.push(reaction.pubkey) + } + return counts + }, [reactions]) + + const handleReactClick = () => { + if (reactButtonRef.current) { + const rect = reactButtonRef.current.getBoundingClientRect() + const viewportHeight = window.innerHeight + const isButtonBelowHalfway = rect.top > viewportHeight / 2 + + // Position above the button if it's below halfway down the viewport, otherwise below + const yPosition = isButtonBelowHalfway ? rect.top - 400 : rect.bottom // 400 is modal height + setModalPosition({ x: rect.left, y: yPosition }) + } + setIsEmojiModalOpen(true) + } + + const handleEmojiSelect = (emoji: string) => { + onReact(emoji) + setIsEmojiModalOpen(false) + } + + if (!eventId) return null + + return ( +
+ {/* Display existing reactions */} + {Object.entries(reactionCounts).map(([emoji, data]) => ( + + ))} + + {/* React button (opens emoji selector) */} + + + {/* Emoji selector modal */} + {isEmojiModalOpen && ( + setIsEmojiModalOpen(false)} + /> + )} +
+ ) +} + +function EmojiSelectorModal({ position, onSelect, onClose }: { + position: { x: number; y: number }; + onSelect: (emoji: string) => void; + onClose: () => void +}) { + const modalRef = useRef(null) + + // Block scrolling when modal is open + useEffect(() => { + // Store the original overflow style + const originalOverflow = document.body.style.overflow + // Prevent scrolling + document.body.style.overflow = 'hidden' + + return () => { + // Restore original overflow when modal closes + document.body.style.overflow = originalOverflow + } + }, []) + + // Click outside to close + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + onClose() + } + } + + // Add event listener to the document + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [onClose]) + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + return ( + <> + {/* Darkened background */} + @@ -2882,9 +3715,20 @@ function RepostNote({ ev, openMedia, openProfile, openProfileByPubkey, openHasht - + {repostMode?.[targetEvent.id || ''] ? ( +
+ + +
+ ) : ( + + )} @@ -2927,7 +3771,7 @@ function InlineProfile({ bech, onOpen }: { bech: string; onOpen: (bech: string) title={bech} > {pic ? avatar : } - {label} + {label} ) } @@ -3130,9 +3974,141 @@ function SendIcon({ className = '' }: { className?: string }) { ) } -function ReplyComposer({ value, onChange, onClose, onSend }: { value: string; onChange: (v: string) => void; onClose: () => void; onSend: () => void }) { +function BellIcon({ className = '' }: { className?: string }) { return ( -
+ + ) +} + +function CompactReactionNote({ ev, openProfileByPubkey, userPubkey }: { ev: NDKEvent; openProfileByPubkey?: (pubkey: string) => void; userPubkey?: string }) { + const { data: profile } = useQuery({ + queryKey: ['profile', ev.pubkey || ''], + enabled: !!ev.pubkey, + staleTime: 1000 * 60 * 10, + queryFn: async () => { + try { + const user = ndk.getUser({ pubkey: ev.pubkey || '' }) + try { await withTimeout(user.fetchProfile(), 4000, 'compact reaction profile fetch') } catch {} + const prof: any = user.profile || {} + const name: string = prof.displayName || prof.display_name || prof.name || prof.nip05 || '' + const picture: string | undefined = prof.picture || undefined + const banner: string | undefined = prof.banner || undefined + return { name, picture, banner } + } catch { + return { name: '', picture: undefined as string | undefined, banner: undefined as string | undefined } + } + }, + }) + + // Get the event ID being reacted to from the 'e' tags + const reactionTargetId = useMemo(() => { + const eTags = (ev.tags || []).filter(t => t[0] === 'e') + return eTags.length > 0 ? eTags[0][1] : null + }, [ev.tags]) + + // Fetch the event being reacted to + const { data: referencedEvent } = useQuery({ + queryKey: ['reaction-target', reactionTargetId], + enabled: !!reactionTargetId, + staleTime: 1000 * 60 * 10, + queryFn: async () => { + if (!reactionTargetId) return null + try { + const set = await withTimeout(ndk.fetchEvents({ ids: [reactionTargetId] } as any), 6000, 'fetch reaction target') + const events = Array.from(set) + return events[0] || null + } catch { + return null + } + }, + }) + + const reactionContent = ev.content || '❤️' + const name = profile?.name || shorten(ev.pubkey || '') + const picture = profile?.picture + const banner = profile?.banner + + // Check if reaction content is an image URL + const isImageReaction = reactionContent.startsWith('http') && /\.(jpg|jpeg|png|gif|webp|bmp|svg)(?:\?.*)?$/i.test(reactionContent) + + return ( +
+ {/* Reaction header with banner background */} +
+ {banner &&
} +
+ +
+ + reacted + {isImageReaction ? ( + reaction + ) : ( + {reactionContent} + )} + {formatTime(ev.created_at)} +
+
+
+ + {/* Referenced event below */} + {referencedEvent ? ( +
+
+
+
+ openProfileByPubkey?.(pk)} /> + · + +
+
+ {referencedEvent.content} +
+
+
+
+ ) : reactionTargetId ? ( +
+
Loading referenced event...
+
+ ) : null} +
+ ) +} + +function ReplyComposer({ value, onChange, onClose, onSend, replyKey }: { value: string; onChange: (v: string) => void; onClose: () => void; onSend: () => void; replyKey?: string }) { + return ( +