Browse Source

fixed profile searches

master
silberengel 7 months ago
parent
commit
41da2df0b7
  1. 444
      deno.lock
  2. 27
      src/lib/components/CommentBox.svelte
  3. 423
      src/lib/components/EventDetails.svelte
  4. 11
      src/lib/components/EventInput.svelte
  5. 153
      src/lib/components/EventSearch.svelte
  6. 1
      src/lib/components/Notifications.svelte
  7. 54
      src/lib/components/cards/ProfileHeader.svelte
  8. 2
      src/lib/navigator/EventNetwork/Legend.svelte
  9. 20
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  10. 26
      src/lib/navigator/EventNetwork/index.svelte
  11. 17
      src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts
  12. 8
      src/lib/ndk.ts
  13. 4
      src/lib/state.ts
  14. 11
      src/lib/stores/authStore.Svelte.ts
  15. 541
      src/lib/stores/userStore.ts
  16. 2
      src/lib/utils/eventColors.ts
  17. 18
      src/lib/utils/event_search.ts
  18. 14
      src/lib/utils/nostrUtils.ts
  19. 2
      src/lib/utils/npubCache.ts
  20. 34
      src/lib/utils/profileCache.ts
  21. 767
      src/lib/utils/profile_search.ts
  22. 9
      src/lib/utils/search_constants.ts
  23. 3
      src/lib/utils/search_types.ts
  24. 3
      src/lib/utils/search_utils.ts
  25. 556
      src/lib/utils/subscription_search.ts
  26. 253
      src/lib/utils/user_lists.ts
  27. 167
      src/routes/events/+page.svelte
  28. 4
      src/routes/visualize/+page.svelte

444
deno.lock

@ -3,9 +3,6 @@
"specifiers": { "specifiers": {
"npm:@noble/curves@^1.9.4": "1.9.4", "npm:@noble/curves@^1.9.4": "1.9.4",
"npm:@noble/hashes@^1.8.0": "1.8.0", "npm:@noble/hashes@^1.8.0": "1.8.0",
"npm:@nostr-dev-kit/ndk-cache-dexie@2.6": "2.6.33_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3",
"npm:@nostr-dev-kit/ndk-cache-dexie@^2.6.33": "2.6.33_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3",
"npm:@nostr-dev-kit/ndk@^2.14.32": "2.14.32_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3",
"npm:@playwright/test@^1.54.1": "1.54.1", "npm:@playwright/test@^1.54.1": "1.54.1",
"npm:@popperjs/core@2.11": "2.11.8", "npm:@popperjs/core@2.11": "2.11.8",
"npm:@tailwindcss/forms@0.5": "0.5.10_tailwindcss@3.4.17__postcss@8.5.6", "npm:@tailwindcss/forms@0.5": "0.5.10_tailwindcss@3.4.17__postcss@8.5.6",
@ -18,20 +15,14 @@
"npm:asciidoctor@3.0": "3.0.4_@asciidoctor+core@3.0.4", "npm:asciidoctor@3.0": "3.0.4_@asciidoctor+core@3.0.4",
"npm:autoprefixer@^10.4.21": "10.4.21_postcss@8.5.6", "npm:autoprefixer@^10.4.21": "10.4.21_postcss@8.5.6",
"npm:bech32@2": "2.0.0", "npm:bech32@2": "2.0.0",
"npm:d3@7.9": "7.9.0_d3-selection@3.0.0",
"npm:d3@^7.9.0": "7.9.0_d3-selection@3.0.0",
"npm:eslint-plugin-svelte@^3.11.0": "3.11.0_eslint@9.31.0_svelte@5.36.8__acorn@8.15.0_postcss@8.5.6", "npm:eslint-plugin-svelte@^3.11.0": "3.11.0_eslint@9.31.0_svelte@5.36.8__acorn@8.15.0_postcss@8.5.6",
"npm:flowbite-svelte-icons@2.1": "2.1.1_svelte@5.36.8__acorn@8.15.0_tailwind-merge@3.3.1", "npm:flowbite-svelte-icons@2.1": "2.1.1_svelte@5.36.8__acorn@8.15.0_tailwind-merge@3.3.1",
"npm:flowbite-svelte-icons@^2.2.1": "2.2.1_svelte@5.36.8__acorn@8.15.0",
"npm:flowbite-svelte@0.48": "0.48.6_svelte@5.36.8__acorn@8.15.0", "npm:flowbite-svelte@0.48": "0.48.6_svelte@5.36.8__acorn@8.15.0",
"npm:flowbite-svelte@^1.10.10": "1.10.10_svelte@5.36.8__acorn@8.15.0_tailwindcss@3.4.17__postcss@8.5.6",
"npm:flowbite@2": "2.5.2", "npm:flowbite@2": "2.5.2",
"npm:flowbite@^3.1.2": "3.1.2", "npm:flowbite@^3.1.2": "3.1.2",
"npm:he@1.2": "1.2.0", "npm:he@1.2": "1.2.0",
"npm:highlight.js@^11.11.1": "11.11.1", "npm:highlight.js@^11.11.1": "11.11.1",
"npm:node-emoji@^2.2.0": "2.2.0", "npm:node-emoji@^2.2.0": "2.2.0",
"npm:nostr-tools@2.15": "2.15.1_typescript@5.8.3",
"npm:nostr-tools@^2.15.1": "2.15.1_typescript@5.8.3",
"npm:plantuml-encoder@^1.4.0": "1.4.0", "npm:plantuml-encoder@^1.4.0": "1.4.0",
"npm:playwright@^1.50.1": "1.54.1", "npm:playwright@^1.50.1": "1.54.1",
"npm:playwright@^1.54.1": "1.54.1", "npm:playwright@^1.54.1": "1.54.1",
@ -350,39 +341,15 @@
"@jridgewell/sourcemap-codec" "@jridgewell/sourcemap-codec"
] ]
}, },
"@noble/ciphers@0.5.3": {
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="
},
"@noble/curves@1.1.0": {
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"dependencies": [
"@noble/hashes@1.3.1"
]
},
"@noble/curves@1.2.0": {
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"dependencies": [
"@noble/hashes@1.3.2"
]
},
"@noble/curves@1.9.4": { "@noble/curves@1.9.4": {
"integrity": "sha512-2bKONnuM53lINoDrSmK8qP8W271ms7pygDhZt4SiLOoLwBtoHqeCFi6RG42V8zd3mLHuJFhU/Bmaqo4nX0/kBw==", "integrity": "sha512-2bKONnuM53lINoDrSmK8qP8W271ms7pygDhZt4SiLOoLwBtoHqeCFi6RG42V8zd3mLHuJFhU/Bmaqo4nX0/kBw==",
"dependencies": [ "dependencies": [
"@noble/hashes@1.8.0" "@noble/hashes"
] ]
}, },
"@noble/hashes@1.3.1": {
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="
},
"@noble/hashes@1.3.2": {
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
},
"@noble/hashes@1.8.0": { "@noble/hashes@1.8.0": {
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
}, },
"@noble/secp256k1@2.3.0": {
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
},
"@nodelib/fs.scandir@2.1.5": { "@nodelib/fs.scandir@2.1.5": {
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dependencies": [ "dependencies": [
@ -400,30 +367,6 @@
"fastq" "fastq"
] ]
}, },
"@nostr-dev-kit/ndk-cache-dexie@2.6.33_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3": {
"integrity": "sha512-JzUD5cuJbGQDUXYuW1530vy347Kk3AhdtvPO8tL6kFpV3KzGt/QPZ0SHxcjMhJdf7r6cAIpCEWj9oUlStr0gsg==",
"dependencies": [
"@nostr-dev-kit/ndk",
"debug",
"dexie",
"nostr-tools",
"typescript-lru-cache"
]
},
"@nostr-dev-kit/ndk@2.14.32_nostr-tools@2.15.1__typescript@5.8.3_typescript@5.8.3": {
"integrity": "sha512-LUBO35RCB9/emBYsXNDece7m/WO2rGYR8j4SD0Crb3z8GcKTJq6P8OjpZ6+Kr+sLNo8N0uL07XxtAvEBnp2OqQ==",
"dependencies": [
"@noble/curves@1.9.4",
"@noble/hashes@1.8.0",
"@noble/secp256k1",
"@scure/base@1.2.6",
"debug",
"light-bolt11-decoder",
"nostr-tools",
"tseep",
"typescript-lru-cache"
]
},
"@pkgjs/parseargs@0.11.0": { "@pkgjs/parseargs@0.11.0": {
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==" "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="
}, },
@ -559,27 +502,6 @@
"os": ["win32"], "os": ["win32"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@scure/base@1.1.1": {
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
},
"@scure/base@1.2.6": {
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="
},
"@scure/bip32@1.3.1": {
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"dependencies": [
"@noble/curves@1.1.0",
"@noble/hashes@1.3.2",
"@scure/base@1.1.1"
]
},
"@scure/bip39@1.2.1": {
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"dependencies": [
"@noble/hashes@1.3.2",
"@scure/base@1.1.1"
]
},
"@sindresorhus/is@4.6.0": { "@sindresorhus/is@4.6.0": {
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==" "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="
}, },
@ -589,34 +511,6 @@
"acorn@8.15.0" "acorn@8.15.0"
] ]
}, },
"@svgdotjs/svg.draggable.js@3.0.6_@svgdotjs+svg.js@3.2.4": {
"integrity": "sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==",
"dependencies": [
"@svgdotjs/svg.js"
]
},
"@svgdotjs/svg.filter.js@3.0.9": {
"integrity": "sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==",
"dependencies": [
"@svgdotjs/svg.js"
]
},
"@svgdotjs/svg.js@3.2.4": {
"integrity": "sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg=="
},
"@svgdotjs/svg.resize.js@2.0.5_@svgdotjs+svg.js@3.2.4_@svgdotjs+svg.select.js@4.0.3__@svgdotjs+svg.js@3.2.4": {
"integrity": "sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==",
"dependencies": [
"@svgdotjs/svg.js",
"@svgdotjs/svg.select.js"
]
},
"@svgdotjs/svg.select.js@4.0.3_@svgdotjs+svg.js@3.2.4": {
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
"dependencies": [
"@svgdotjs/svg.js"
]
},
"@tailwindcss/forms@0.5.10_tailwindcss@3.4.17__postcss@8.5.6": { "@tailwindcss/forms@0.5.10_tailwindcss@3.4.17__postcss@8.5.6": {
"integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
"dependencies": [ "dependencies": [
@ -896,17 +790,6 @@
"svg.select.js@3.0.1" "svg.select.js@3.0.1"
] ]
}, },
"apexcharts@4.7.0_@svgdotjs+svg.js@3.2.4_@svgdotjs+svg.select.js@4.0.3__@svgdotjs+svg.js@3.2.4": {
"integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==",
"dependencies": [
"@svgdotjs/svg.draggable.js",
"@svgdotjs/svg.filter.js",
"@svgdotjs/svg.js",
"@svgdotjs/svg.resize.js",
"@svgdotjs/svg.select.js",
"@yr/monotone-cubic-spline"
]
},
"arg@5.0.2": { "arg@5.0.2": {
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
}, },
@ -1094,9 +977,6 @@
"commander@5.1.0": { "commander@5.1.0": {
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="
}, },
"commander@7.2.0": {
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="
},
"concat-map@0.0.1": { "concat-map@0.0.1": {
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
}, },
@ -1119,212 +999,6 @@
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"bin": true "bin": true
}, },
"d3-array@3.2.4": {
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": [
"internmap"
]
},
"d3-axis@3.0.0": {
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="
},
"d3-brush@3.0.0_d3-selection@3.0.0": {
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"dependencies": [
"d3-dispatch",
"d3-drag",
"d3-interpolate",
"d3-selection",
"d3-transition"
]
},
"d3-chord@3.0.1": {
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"dependencies": [
"d3-path"
]
},
"d3-color@3.1.0": {
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
},
"d3-contour@4.0.2": {
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"dependencies": [
"d3-array"
]
},
"d3-delaunay@6.0.4": {
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"dependencies": [
"delaunator"
]
},
"d3-dispatch@3.0.1": {
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="
},
"d3-drag@3.0.0": {
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": [
"d3-dispatch",
"d3-selection"
]
},
"d3-dsv@3.0.1": {
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"dependencies": [
"commander@7.2.0",
"iconv-lite",
"rw"
],
"bin": true
},
"d3-ease@3.0.1": {
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="
},
"d3-fetch@3.0.1": {
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"dependencies": [
"d3-dsv"
]
},
"d3-force@3.0.0": {
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"dependencies": [
"d3-dispatch",
"d3-quadtree",
"d3-timer"
]
},
"d3-format@3.1.0": {
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="
},
"d3-geo@3.1.1": {
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"dependencies": [
"d3-array"
]
},
"d3-hierarchy@3.1.2": {
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="
},
"d3-interpolate@3.0.1": {
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": [
"d3-color"
]
},
"d3-path@3.1.0": {
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
},
"d3-polygon@3.0.1": {
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="
},
"d3-quadtree@3.0.1": {
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="
},
"d3-random@3.0.1": {
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="
},
"d3-scale-chromatic@3.1.0": {
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"dependencies": [
"d3-color",
"d3-interpolate"
]
},
"d3-scale@4.0.2": {
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": [
"d3-array",
"d3-format",
"d3-interpolate",
"d3-time",
"d3-time-format"
]
},
"d3-selection@3.0.0": {
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="
},
"d3-shape@3.2.0": {
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": [
"d3-path"
]
},
"d3-time-format@4.1.0": {
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": [
"d3-time"
]
},
"d3-time@3.1.0": {
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": [
"d3-array"
]
},
"d3-timer@3.0.1": {
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="
},
"d3-transition@3.0.1_d3-selection@3.0.0": {
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dependencies": [
"d3-color",
"d3-dispatch",
"d3-ease",
"d3-interpolate",
"d3-selection",
"d3-timer"
]
},
"d3-zoom@3.0.0_d3-selection@3.0.0": {
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dependencies": [
"d3-dispatch",
"d3-drag",
"d3-interpolate",
"d3-selection",
"d3-transition"
]
},
"d3@7.9.0_d3-selection@3.0.0": {
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"dependencies": [
"d3-array",
"d3-axis",
"d3-brush",
"d3-chord",
"d3-color",
"d3-contour",
"d3-delaunay",
"d3-dispatch",
"d3-drag",
"d3-dsv",
"d3-ease",
"d3-fetch",
"d3-force",
"d3-format",
"d3-geo",
"d3-hierarchy",
"d3-interpolate",
"d3-path",
"d3-polygon",
"d3-quadtree",
"d3-random",
"d3-scale",
"d3-scale-chromatic",
"d3-selection",
"d3-shape",
"d3-time",
"d3-time-format",
"d3-timer",
"d3-transition",
"d3-zoom"
]
},
"date-fns@4.1.0": {
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
},
"debug@4.4.1": { "debug@4.4.1": {
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dependencies": [ "dependencies": [
@ -1340,15 +1014,6 @@
"deepmerge@4.3.1": { "deepmerge@4.3.1": {
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
}, },
"delaunator@5.0.1": {
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"dependencies": [
"robust-predicates"
]
},
"dexie@4.0.11": {
"integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A=="
},
"didyoumean@1.2.2": { "didyoumean@1.2.2": {
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
}, },
@ -1608,40 +1273,17 @@
"integrity": "sha512-VNNMcekjbM1bQEGgbdGsdYR9mRdTj/L0A5ba0P1tiFv5QB9GvbvJMABJoiD80eqpZUkfR2QVOmiZfgCwHicT/Q==", "integrity": "sha512-VNNMcekjbM1bQEGgbdGsdYR9mRdTj/L0A5ba0P1tiFv5QB9GvbvJMABJoiD80eqpZUkfR2QVOmiZfgCwHicT/Q==",
"dependencies": [ "dependencies": [
"svelte", "svelte",
"tailwind-merge@3.3.1" "tailwind-merge"
]
},
"flowbite-svelte-icons@2.2.1_svelte@5.36.8__acorn@8.15.0": {
"integrity": "sha512-SH59319zN4TFpmvFMD7+0ETyDxez4Wyw3mgz7hkjhvrx8HawNAS3Fp7au84pZEs1gniX4hvXIg54U+4YybV2rA==",
"dependencies": [
"clsx",
"svelte",
"tailwind-merge@3.3.1"
] ]
}, },
"flowbite-svelte@0.48.6_svelte@5.36.8__acorn@8.15.0": { "flowbite-svelte@0.48.6_svelte@5.36.8__acorn@8.15.0": {
"integrity": "sha512-/PmeR3ipHHvda8vVY9MZlymaRoJsk8VddEeoLzIygfYwJV68ey8gHuQPC1dq9J6NDCTE5+xOPtBiYUtVjCfvZw==", "integrity": "sha512-/PmeR3ipHHvda8vVY9MZlymaRoJsk8VddEeoLzIygfYwJV68ey8gHuQPC1dq9J6NDCTE5+xOPtBiYUtVjCfvZw==",
"dependencies": [ "dependencies": [
"@floating-ui/dom", "@floating-ui/dom",
"apexcharts@3.54.1", "apexcharts",
"flowbite@3.1.2",
"svelte",
"tailwind-merge@3.3.1"
]
},
"flowbite-svelte@1.10.10_svelte@5.36.8__acorn@8.15.0_tailwindcss@3.4.17__postcss@8.5.6": {
"integrity": "sha512-9YCB3EqQKlu7in9pxE46eeA+zt98vhUK1nb0eR2o5wpRfsWj60u9v43lMtfhpxSTsh2Jebh+wVLNYyyrYa0UGA==",
"dependencies": [
"@floating-ui/dom",
"@floating-ui/utils",
"apexcharts@4.7.0_@svgdotjs+svg.js@3.2.4_@svgdotjs+svg.select.js@4.0.3__@svgdotjs+svg.js@3.2.4",
"clsx",
"date-fns",
"flowbite@3.1.2", "flowbite@3.1.2",
"svelte", "svelte",
"tailwind-merge@3.3.1", "tailwind-merge"
"tailwind-variants",
"tailwindcss"
] ]
}, },
"flowbite@2.5.2": { "flowbite@2.5.2": {
@ -1794,12 +1436,6 @@
"highlight.js@11.11.1": { "highlight.js@11.11.1": {
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==" "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="
}, },
"iconv-lite@0.6.3": {
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dependencies": [
"safer-buffer"
]
},
"ignore@5.3.2": { "ignore@5.3.2": {
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
}, },
@ -1824,9 +1460,6 @@
"inherits@2.0.4": { "inherits@2.0.4": {
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"internmap@2.0.3": {
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="
},
"is-binary-path@2.1.0": { "is-binary-path@2.1.0": {
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dependencies": [ "dependencies": [
@ -1950,12 +1583,6 @@
"type-check" "type-check"
] ]
}, },
"light-bolt11-decoder@3.2.0": {
"integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==",
"dependencies": [
"@scure/base@1.1.1"
]
},
"lilconfig@2.1.0": { "lilconfig@2.1.0": {
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==" "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="
}, },
@ -2081,25 +1708,6 @@
"normalize-range@0.1.2": { "normalize-range@0.1.2": {
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==" "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="
}, },
"nostr-tools@2.15.1_typescript@5.8.3": {
"integrity": "sha512-LpetHDR9ltnkpJDkva/SONgyKBbsoV+5yLB8DWc0/U3lCWGtoWJw6Nbc2vR2Ai67RIQYrBQeZLyMlhwVZRK/9A==",
"dependencies": [
"@noble/ciphers",
"@noble/curves@1.2.0",
"@noble/hashes@1.3.1",
"@scure/base@1.1.1",
"@scure/bip32",
"@scure/bip39",
"nostr-wasm",
"typescript"
],
"optionalPeers": [
"typescript"
]
},
"nostr-wasm@0.1.0": {
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="
},
"nunjucks@3.2.4": { "nunjucks@3.2.4": {
"integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==",
"dependencies": [ "dependencies": [
@ -2477,9 +2085,6 @@
"reusify@1.1.0": { "reusify@1.1.0": {
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="
}, },
"robust-predicates@3.0.2": {
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="
},
"rollup@4.45.1": { "rollup@4.45.1": {
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
"dependencies": [ "dependencies": [
@ -2516,18 +2121,12 @@
"queue-microtask" "queue-microtask"
] ]
}, },
"rw@1.3.3": {
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"sade@1.8.1": { "sade@1.8.1": {
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dependencies": [ "dependencies": [
"mri" "mri"
] ]
}, },
"safer-buffer@2.1.2": {
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"semver@7.7.2": { "semver@7.7.2": {
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"bin": true "bin": true
@ -2705,19 +2304,9 @@
"svg.js" "svg.js"
] ]
}, },
"tailwind-merge@3.0.2": {
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="
},
"tailwind-merge@3.3.1": { "tailwind-merge@3.3.1": {
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==" "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="
}, },
"tailwind-variants@1.0.0_tailwindcss@3.4.17__postcss@8.5.6": {
"integrity": "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==",
"dependencies": [
"tailwind-merge@3.0.2",
"tailwindcss"
]
},
"tailwindcss@3.4.17_postcss@8.5.6": { "tailwindcss@3.4.17_postcss@8.5.6": {
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dependencies": [ "dependencies": [
@ -2770,9 +2359,6 @@
"ts-interface-checker@0.1.13": { "ts-interface-checker@0.1.13": {
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
}, },
"tseep@1.3.1": {
"integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ=="
},
"tslib@2.8.1": { "tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}, },
@ -2782,9 +2368,6 @@
"prelude-ls" "prelude-ls"
] ]
}, },
"typescript-lru-cache@2.0.0": {
"integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA=="
},
"typescript@5.8.3": { "typescript@5.8.3": {
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"bin": true "bin": true
@ -2945,18 +2528,25 @@
}, },
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"npm:@nostr-dev-kit/ndk-cache-dexie@^2.6.33", "npm:@noble/curves@^1.9.4",
"npm:@noble/hashes@^1.8.0",
"npm:@nostr-dev-kit/ndk-cache-dexie@2.6",
"npm:@nostr-dev-kit/ndk@^2.14.32", "npm:@nostr-dev-kit/ndk@^2.14.32",
"npm:@popperjs/core@2.11", "npm:@popperjs/core@2.11",
"npm:@tailwindcss/forms@0.5", "npm:@tailwindcss/forms@0.5",
"npm:@tailwindcss/typography@0.5", "npm:@tailwindcss/typography@0.5",
"npm:asciidoctor@3.0", "npm:asciidoctor@3.0",
"npm:d3@7.9", "npm:bech32@2",
"npm:flowbite-svelte-icons@^2.2.1", "npm:d3@^7.9.0",
"npm:flowbite-svelte@^1.10.10", "npm:flowbite-svelte-icons@2.1",
"npm:flowbite@^3.1.2", "npm:flowbite-svelte@0.48",
"npm:flowbite@2",
"npm:he@1.2", "npm:he@1.2",
"npm:nostr-tools@^2.15.1", "npm:highlight.js@^11.11.1",
"npm:node-emoji@^2.2.0",
"npm:nostr-tools@2.15",
"npm:plantuml-encoder@^1.4.0",
"npm:qrcode@^1.5.4",
"npm:svelte@^5.36.8", "npm:svelte@^5.36.8",
"npm:tailwind-merge@^3.3.1" "npm:tailwind-merge@^3.3.1"
], ],

27
src/lib/components/CommentBox.svelte

@ -10,7 +10,7 @@
ProfileSearchResult, ProfileSearchResult,
} from "$lib/utils/search_utility"; } from "$lib/utils/search_utility";
import { userPubkey } from "$lib/stores/authStore.Svelte";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { import {
@ -174,7 +174,7 @@
success = null; success = null;
try { try {
const pk = $userPubkey || ""; const pk = $userStore.pubkey || "";
const npub = toNpub(pk); const npub = toNpub(pk);
if (!npub) { if (!npub) {
@ -430,7 +430,22 @@
class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3" class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3"
onclick={() => selectMention(profile)} onclick={() => selectMention(profile)}
> >
{#if profile.pubkey && communityStatus[profile.pubkey]} {#if profile.isInUserLists}
<div
class="flex-shrink-0 w-6 h-6 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists"
>
<svg
class="w-4 h-4 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if profile.pubkey && communityStatus[profile.pubkey]}
<div <div
class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community" title="Has posted to the community"
@ -604,10 +619,10 @@
{/if} {/if}
<Button <Button
onclick={() => handleSubmit()} onclick={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !$userPubkey} disabled={isSubmitting || !content.trim() || !$userStore.pubkey}
class="w-full md:w-auto" class="w-full md:w-auto"
> >
{#if !$userPubkey} {#if !$userStore.pubkey}
Not Signed In Not Signed In
{:else if isSubmitting} {:else if isSubmitting}
Publishing... Publishing...
@ -617,7 +632,7 @@
</Button> </Button>
</div> </div>
{#if !$userPubkey} {#if !$userStore.pubkey}
<Alert color="yellow" class="mt-4"> <Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your Please sign in to post comments. Your comments will be signed with your
current account. current account.

423
src/lib/components/EventDetails.svelte

@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser"; import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser";
import EmbeddedEventRenderer from "./EmbeddedEventRenderer.svelte"; import EmbeddedEventRenderer from "./EmbeddedEventRenderer.svelte";
import { getMimeTags } from "$lib/utils/mime"; import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils"; import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { nip19 } from "nostr-tools";
import { searchRelays } from "$lib/consts"; import { activeInboxRelays } from "$lib/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import ProfileHeader from "$components/cards/ProfileHeader.svelte"; import ProfileHeader from "$components/cards/ProfileHeader.svelte";
@ -19,12 +18,14 @@
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte"; import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
import Notifications from "$lib/components/Notifications.svelte"; import Notifications from "$lib/components/Notifications.svelte";
import { parseRepostContent } from "$lib/utils/notification_utils"; import { parseRepostContent } from "$lib/utils/notification_utils";
import RelayActions from "$lib/components/RelayActions.svelte"; import { checkCommunity } from "$lib/utils/search_utility";
import { isPubkeyInUserLists, fetchCurrentUserLists } from "$lib/utils/user_lists";
const { const {
event, event,
profile = null, profile = null,
searchValue = null, searchValue = null,
communityStatusMap = {},
} = $props<{ } = $props<{
event: NDKEvent; event: NDKEvent;
profile?: { profile?: {
@ -38,12 +39,15 @@
nip05?: string; nip05?: string;
} | null; } | null;
searchValue?: string | null; searchValue?: string | null;
communityStatusMap?: Record<string, boolean>;
}>(); }>();
let showFullContent = $state(false); let showFullContent = $state(false);
let parsedContent = $state(""); let parsedContent = $state("");
let contentProcessing = $state(false); let contentProcessing = $state(false);
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
let communityStatus = $state<boolean | null>(null);
let isInUserLists = $state<boolean | null>(null);
// Determine if content should be truncated // Determine if content should be truncated
let shouldTruncate = $state(false); let shouldTruncate = $state(false);
@ -52,6 +56,49 @@
shouldTruncate = event.content.length > 250 && !showFullContent; shouldTruncate = event.content.length > 250 && !showFullContent;
}); });
// Check community status and user list status for the event author
$effect(() => {
if (event?.pubkey) {
// First check if we have cached profileData with user list information
const cachedProfileData = (event as any).profileData;
console.log(`[EventDetails] Checking user list status for ${event.pubkey}, cached profileData:`, cachedProfileData);
if (cachedProfileData && typeof cachedProfileData.isInUserLists === 'boolean') {
isInUserLists = cachedProfileData.isInUserLists;
console.log(`[EventDetails] Using cached user list status for ${event.pubkey}: ${isInUserLists}`);
} else {
console.log(`[EventDetails] No cached user list data, fetching for ${event.pubkey}`);
// Fallback to fetching user lists
fetchCurrentUserLists()
.then((userLists) => {
console.log(`[EventDetails] Fetched ${userLists.length} user lists for ${event.pubkey}`);
isInUserLists = isPubkeyInUserLists(event.pubkey, userLists);
console.log(`[EventDetails] Final user list status for ${event.pubkey}: ${isInUserLists}`);
})
.catch((error) => {
console.error(`[EventDetails] Error fetching user lists for ${event.pubkey}:`, error);
isInUserLists = false;
});
}
// Check community status - use cached data if available
if (communityStatusMap[event.pubkey] !== undefined) {
communityStatus = communityStatusMap[event.pubkey];
console.log(`[EventDetails] Using cached community status for ${event.pubkey}: ${communityStatus}`);
} else {
// Fallback to checking community status
checkCommunity(event.pubkey)
.then((status) => {
communityStatus = status;
})
.catch(() => {
communityStatus = false;
});
}
}
});
// AI-NOTE: Event metadata extraction functions
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
// First try to get title from title tag // First try to get title from title tag
const titleTag = getMatchingTags(event, "title")[0]?.[1]; const titleTag = getMatchingTags(event, "title")[0]?.[1];
@ -101,208 +148,131 @@
return MTag[1].split("/")[1] || `Event Kind ${event.kind}`; return MTag[1].split("/")[1] || `Event Kind ${event.kind}`;
} }
function renderTag(tag: string[]): string { // AI-NOTE: Tag processing utilities
if (tag[0] === "a" && tag.length > 1) { function isValidHexString(str: string): boolean {
const parts = tag[1].split(":"); return /^[0-9a-fA-F]{64}$/.test(str);
if (parts.length >= 3) { }
const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string function createMockEvent(id: string, kind: number = 1): any {
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) { return {
try { id,
const mockEvent = { kind,
kind: +kind, content: "",
tags: [],
pubkey: "",
sig: "",
};
}
function createMockAddressableEvent(kind: number, pubkey: string, d: string): any {
return {
kind,
pubkey, pubkey,
tags: [["d", d]], tags: [["d", d]],
content: "", content: "",
id: "", id: "",
sig: "", sig: "",
} as any; };
}
function renderTag(tag: string[]): string {
const [tagType, tagValue] = tag;
if (!tagValue) {
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tagType}:${tagValue}</span>`;
}
try {
switch (tagType) {
case "a": {
const parts = tagValue.split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
if (pubkey && isValidHexString(pubkey)) {
const mockEvent = createMockAddressableEvent(+kind, pubkey, d);
const naddr = naddrEncode(mockEvent, $activeInboxRelays); const naddr = naddrEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tag[1]}</a>`; return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tagValue}</a>`;
} catch (error) {
console.warn(
"Failed to encode naddr for a tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
} }
} else {
console.warn("Invalid pubkey in a tag in renderTag:", pubkey);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
} }
} else { break;
console.warn("Invalid a tag format in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
} }
} else if (tag[0] === "e" && tag.length > 1) { case "e":
// Validate that event ID is a valid hex string case "note": {
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) { if (isValidHexString(tagValue)) {
try { const mockEvent = createMockEvent(tagValue);
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays); const nevent = neventEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>e:${tag[1]}</a>`; const prefix = tagType === "note" ? "note:" : "e:";
} catch (error) { return `<a href='/events?id=${nevent}' class='underline text-primary-700'>${prefix}${tagValue}</a>`;
console.warn(
"Failed to encode nevent for e tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
} }
} else { break;
console.warn("Invalid event ID in e tag in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
} }
} else if (tag[0] === "note" && tag.length > 1) { case "d": {
// 'note' tags are the same as 'e' tags but with different prefix return `<a href='/events?d=${encodeURIComponent(tagValue)}' class='underline text-primary-700'>d:${tagValue}</a>`;
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>note:${tag[1]}</a>`;
} catch (error) {
console.warn(
"Failed to encode nevent for note tag in renderTag:",
tag[1],
error,
);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
} }
} else {
console.warn("Invalid event ID in note tag in renderTag:", tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
} }
} else if (tag[0] === "d" && tag.length > 1) { } catch (error) {
// 'd' tags are used for identifiers in addressable events console.warn(`Failed to encode ${tagType} tag:`, tagValue, error);
return `<a href='/events?d=${encodeURIComponent(tag[1])}' class='underline text-primary-700'>d:${tag[1]}</a>`;
} else {
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tag[0]}:${tag[1]}</span>`;
} }
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tagType}:${tagValue}</span>`;
} }
function getTagButtonInfo(tag: string[]): { function getTagButtonInfo(tag: string[]): {
text: string; text: string;
gotoValue?: string; gotoValue?: string;
} { } {
if (tag[0] === "a" && tag.length > 1) { const [tagType, tagValue] = tag;
const parts = tag[1].split(":");
if (!tagValue) {
return { text: `${tagType}:${tagValue}` };
}
try {
switch (tagType) {
case "a": {
const parts = tagValue.split(":");
if (parts.length >= 3) { if (parts.length >= 3) {
const [kind, pubkey, d] = parts; const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string if (pubkey && isValidHexString(pubkey)) {
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) { const mockEvent = createMockAddressableEvent(+kind, pubkey, d);
try {
const mockEvent = {
kind: +kind,
pubkey,
tags: [["d", d]],
content: "",
id: "",
sig: "",
} as any;
const naddr = naddrEncode(mockEvent, $activeInboxRelays); const naddr = naddrEncode(mockEvent, $activeInboxRelays);
return { return { text: `a:${tagValue}`, gotoValue: naddr };
text: `a:${tag[1]}`,
gotoValue: naddr,
};
} catch (error) {
console.warn("Failed to encode naddr for a tag:", tag[1], error);
return { text: `a:${tag[1]}` };
} }
} else {
console.warn("Invalid pubkey in a tag:", pubkey);
return { text: `a:${tag[1]}` };
} }
} else { break;
console.warn("Invalid a tag format:", tag[1]);
return { text: `a:${tag[1]}` };
} }
} else if (tag[0] === "e" && tag.length > 1) { case "e":
// Validate that event ID is a valid hex string case "note": {
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) { if (isValidHexString(tagValue)) {
try { const mockEvent = createMockEvent(tagValue);
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays); const nevent = neventEncode(mockEvent, $activeInboxRelays);
return { const prefix = tagType === "note" ? "note:" : "e:";
text: `e:${tag[1]}`, return { text: `${prefix}${tagValue}`, gotoValue: nevent };
gotoValue: nevent,
};
} catch (error) {
console.warn("Failed to encode nevent for e tag:", tag[1], error);
return { text: `e:${tag[1]}` };
} }
} else { break;
console.warn("Invalid event ID in e tag:", tag[1]);
return { text: `e:${tag[1]}` };
} }
} else if (tag[0] === "p" && tag.length > 1) { case "p": {
const npub = toNpub(tag[1]); const npub = toNpub(tagValue);
return { return {
text: `p:${npub || tag[1]}`, text: `p:${npub || tagValue}`,
gotoValue: npub ? npub : undefined, gotoValue: npub || undefined,
}; };
} else if (tag[0] === "note" && tag.length > 1) {
// 'note' tags are the same as 'e' tags but with different prefix
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any;
const nevent = neventEncode(mockEvent, $activeInboxRelays);
return {
text: `note:${tag[1]}`,
gotoValue: nevent,
};
} catch (error) {
console.warn("Failed to encode nevent for note tag:", tag[1], error);
return { text: `note:${tag[1]}` };
} }
} else { case "d": {
console.warn("Invalid event ID in note tag:", tag[1]); return { text: `d:${tagValue}`, gotoValue: `d:${tagValue}` };
return { text: `note:${tag[1]}` }; }
case "t": {
return { text: `t:${tagValue}`, gotoValue: `t:${tagValue}` };
} }
} else if (tag[0] === "d" && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events
return {
text: `d:${tag[1]}`,
gotoValue: `d:${tag[1]}`,
};
} else if (tag[0] === "t" && tag.length > 1) {
// 't' tags are hashtags - navigate to t-tag search
return {
text: `t:${tag[1]}`,
gotoValue: `t:${tag[1]}`,
};
} }
return { text: `${tag[0]}:${tag[1]}` }; } catch (error) {
console.warn(`Failed to encode ${tagType} tag:`, tagValue, error);
}
return { text: `${tagType}:${tagValue}` };
} }
// AI-NOTE: URL generation functions
function getNeventUrl(event: NDKEvent): string { function getNeventUrl(event: NDKEvent): string {
return neventEncode(event, $activeInboxRelays); return neventEncode(event, $activeInboxRelays);
} }
@ -315,6 +285,7 @@
return nprofileEncode(pubkey, $activeInboxRelays); return nprofileEncode(pubkey, $activeInboxRelays);
} }
// AI-NOTE: Content processing effect
$effect(() => { $effect(() => {
if (event && event.kind !== 0 && event.content) { if (event && event.kind !== 0 && event.content) {
contentProcessing = true; contentProcessing = true;
@ -344,6 +315,7 @@
} }
}); });
// AI-NOTE: Author metadata effect
$effect(() => { $effect(() => {
if (!event?.pubkey) { if (!event?.pubkey) {
authorDisplayName = undefined; authorDisplayName = undefined;
@ -358,48 +330,79 @@
}); });
}); });
// --- Identifier helpers --- // AI-NOTE: Identifier helpers
function getIdentifiers( function getIdentifiers(
event: NDKEvent, event: NDKEvent,
profile: any, profile: any,
): { label: string; value: string; link?: string }[] { ): { label: string; value: string; link?: string }[] {
const ids: { label: string; value: string; link?: string }[] = []; const ids: { label: string; value: string; link?: string }[] = [];
if (event.kind === 0) { if (event.kind === 0) {
// NIP-05 // Profile event identifiers
const nip05 = profile?.nip05 || getMatchingTags(event, "nip05")[0]?.[1];
// npub
const npub = toNpub(event.pubkey); const npub = toNpub(event.pubkey);
if (npub) if (npub) {
ids.push({ label: "npub", value: npub, link: `/events?id=${npub}` }); ids.push({ label: "npub", value: npub, link: `/events?id=${npub}` });
// nprofile }
// Decode npub to get raw hex string for nprofile encoding
let rawPubkey = event.pubkey;
if (event.pubkey.startsWith('npub')) {
try {
const decoded = nip19.decode(event.pubkey);
if (decoded.type === 'npub') {
rawPubkey = decoded.data;
}
} catch (error) {
console.warn('Failed to decode npub for nprofile encoding:', error);
}
}
ids.push({ ids.push({
label: "nprofile", label: "nprofile",
value: nprofileEncode(event.pubkey, $activeInboxRelays), value: nprofileEncode(rawPubkey, $activeInboxRelays),
link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}`, link: `/events?id=${nprofileEncode(rawPubkey, $activeInboxRelays)}`,
}); });
// nevent
// For nevent encoding, we need to ensure the event has proper hex strings
try {
const nevent = neventEncode(event, $activeInboxRelays);
ids.push({ ids.push({
label: "nevent", label: "nevent",
value: neventEncode(event, $activeInboxRelays), value: nevent,
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`, link: `/events?id=${nevent}`,
}); });
// hex pubkey } catch (error) {
console.warn('Failed to encode nevent for profile event:', error);
// Fallback: just show the event ID
ids.push({ label: "event id", value: event.id });
}
ids.push({ label: "pubkey", value: event.pubkey }); ids.push({ label: "pubkey", value: event.pubkey });
} else { } else {
// nevent // Non-profile event identifiers
// For nevent encoding, we need to ensure the event has proper hex strings
try {
const nevent = neventEncode(event, $activeInboxRelays);
ids.push({ ids.push({
label: "nevent", label: "nevent",
value: neventEncode(event, $activeInboxRelays), value: nevent,
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`, link: `/events?id=${nevent}`,
}); });
} catch (error) {
console.warn('Failed to encode nevent for non-profile event:', error);
// Fallback: just show the event ID
ids.push({ label: "event id", value: event.id });
}
// naddr (if addressable) // naddr (if addressable)
try { try {
const naddr = naddrEncode(event, $activeInboxRelays); const naddr = naddrEncode(event, $activeInboxRelays);
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` }); ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {} } catch {}
// hex id
ids.push({ label: "id", value: event.id }); ids.push({ label: "id", value: event.id });
} }
return ids; return ids;
} }
@ -410,6 +413,7 @@
return norm(value) === norm(searchValue); return norm(value) === norm(searchValue);
} }
// AI-NOTE: Navigation handler for internal links
onMount(() => { onMount(() => {
function handleInternalLinkClick(event: MouseEvent) { function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
@ -438,20 +442,58 @@
<Notifications {event} /> <Notifications {event} />
{/if} {/if}
{#if !(event.kind === 0)}
<div class="flex items-center space-x-2 min-w-0"> <div class="flex items-center space-x-2 min-w-0">
{#if toNpub(event.pubkey)} {#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400 min-w-0" <span class="text-gray-600 dark:text-gray-400 min-w-0 flex items-center gap-2"
>Author: {@render userBadge( >Author: {@render userBadge(
toNpub(event.pubkey) as string, toNpub(event.pubkey) as string,
profile?.display_name || undefined, profile?.display_name || undefined,
)}</span )}
{#if isInUserLists === true}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if isInUserLists === false}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
{#if communityStatus === true}
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community"
>
<svg
class="w-3 h-3 text-yellow-600 dark:text-yellow-400"
fill="currentColor"
viewBox="0 0 24 24"
> >
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
</div>
{:else if communityStatus === false}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
</span>
{:else} {:else}
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words" <span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span >Author: {profile?.display_name || event.pubkey}</span
> >
{/if} {/if}
</div> </div>
{/if}
<div class="flex items-center space-x-2 min-w-0"> <div class="flex items-center space-x-2 min-w-0">
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span> <span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span>
@ -468,8 +510,6 @@
</div> </div>
{/if} {/if}
<!-- Containing Publications --> <!-- Containing Publications -->
<ContainingIndexes {event} /> <ContainingIndexes {event} />
@ -497,11 +537,12 @@
</div> </div>
{/if} {/if}
<!-- If event is profile --> <!-- Show ProfileHeader for all events except profile events (kind 0) when in search context to avoid redundancy -->
{#if event.kind === 0} {#if (event.kind === 0)}
<ProfileHeader <ProfileHeader
{event} {event}
{profile} {profile}
{communityStatusMap}
/> />
{/if} {/if}

11
src/lib/components/EventInput.svelte

@ -20,7 +20,6 @@
} from "$lib/utils/asciidoc_metadata"; } from "$lib/utils/asciidoc_metadata";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { userPubkey } from "$lib/stores/authStore.Svelte";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -163,11 +162,8 @@
} }
function validate(): { valid: boolean; reason?: string; warning?: string } { function validate(): { valid: boolean; reason?: string; warning?: string } {
const currentUserPubkey = get(userPubkey as any);
const userState = get(userStore); const userState = get(userStore);
const pubkey = userState.pubkey;
// Try userPubkey first, then fallback to userStore
const pubkey = currentUserPubkey || userState.pubkey;
if (!pubkey) return { valid: false, reason: "Not logged in." }; if (!pubkey) return { valid: false, reason: "Not logged in." };
if (!content.trim()) return { valid: false, reason: "Content required." }; if (!content.trim()) return { valid: false, reason: "Content required." };
@ -222,11 +218,8 @@
try { try {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
const currentUserPubkey = get(userPubkey as any);
const userState = get(userStore); const userState = get(userStore);
const pubkey = userState.pubkey;
// Try userPubkey first, then fallback to userStore
const pubkey = currentUserPubkey || userState.pubkey;
if (!ndk || !pubkey) { if (!ndk || !pubkey) {
error = "NDK or pubkey missing."; error = "NDK or pubkey missing.";
loading = false; loading = false;

153
src/lib/components/EventSearch.svelte

@ -41,6 +41,7 @@
addresses: Set<string>, addresses: Set<string>,
searchType?: string, searchType?: string,
searchTerm?: string, searchTerm?: string,
loading?: boolean, // AI-NOTE: 2025-01-24 - Add loading parameter for second-order search message logic
) => void; ) => void;
event: NDKEvent | null; event: NDKEvent | null;
onClear?: () => void; onClear?: () => void;
@ -140,6 +141,7 @@
return; return;
} }
// AI-NOTE: 2025-01-24 - If no specific search type is detected, treat as event ID search
if (clearInput) { if (clearInput) {
navigateToSearch(query, "id"); navigateToSearch(query, "id");
} }
@ -169,6 +171,13 @@
return { type: "nip05", term: query }; return { type: "nip05", term: query };
} }
// AI-NOTE: 2025-01-24 - Treat plain text searches as profile searches by default
// This allows searching for names like "thebeave" or "TheBeave" without needing n: prefix
const trimmedQuery = query.trim();
if (trimmedQuery && !trimmedQuery.startsWith("nevent") && !trimmedQuery.startsWith("npub") && !trimmedQuery.startsWith("naddr")) {
return { type: "n", term: trimmedQuery };
}
return null; return null;
} }
@ -425,12 +434,8 @@
searchTerm, searchTerm,
}); });
if (searchType === "n") { // AI-NOTE: 2025-01-24 - Profile search caching is now handled by centralized searchProfiles function
const cachedResult = await handleCachedProfileSearch(searchTerm); // No need for separate caching logic here as it's handled in profile_search.ts
if (cachedResult) {
return;
}
}
isResetting = false; isResetting = false;
localError = null; localError = null;
@ -445,61 +450,9 @@
} }
} }
async function handleCachedProfileSearch(searchTerm: string): Promise<boolean> { // AI-NOTE: 2025-01-24 - Profile search is now handled by centralized searchProfiles function
if (!searchTerm.startsWith("npub") && !searchTerm.startsWith("nprofile")) { // These functions are no longer needed as profile searches go through subscription_search.ts
return false; // which delegates to the centralized profile_search.ts
}
try {
const { getUserMetadata } = await import("$lib/utils/nostrUtils");
const cachedProfile = await getUserMetadata(searchTerm, false);
if (cachedProfile && cachedProfile.name) {
const mockEvent = await createMockProfileEvent(searchTerm, cachedProfile);
handleFoundEvent(mockEvent);
updateSearchState(false, true, 1, "profile-cached");
setTimeout(async () => {
try {
await performBackgroundProfileSearch("n", searchTerm);
} catch (error) {
console.warn("EventSearch: Background profile search failed:", error);
}
}, 100);
return true;
}
} catch (error) {
console.warn("EventSearch: Cache check failed, proceeding with subscription search:", error);
}
return false;
}
async function createMockProfileEvent(searchTerm: string, profile: any): Promise<NDKEvent> {
const { NDKEvent } = await import("@nostr-dev-kit/ndk");
const { nip19 } = await import("$lib/utils/nostrUtils");
let pubkey = searchTerm;
try {
const decoded = nip19.decode(searchTerm);
if (decoded && decoded.type === "npub") {
pubkey = decoded.data;
}
} catch (error) {
console.warn("EventSearch: Failed to decode npub for mock event:", error);
}
return new NDKEvent(undefined, {
kind: 0,
pubkey: pubkey,
content: JSON.stringify(profile),
tags: [],
created_at: Math.floor(Date.now() / 1000),
id: "",
sig: "",
});
}
async function waitForRelays(): Promise<void> { async function waitForRelays(): Promise<void> {
let retryCount = 0; let retryCount = 0;
@ -565,7 +518,8 @@
updatedResult.eventIds, updatedResult.eventIds,
updatedResult.addresses, updatedResult.addresses,
updatedResult.searchType, updatedResult.searchType,
updatedResult.searchTerm, searchValue || updatedResult.searchTerm, // AI-NOTE: 2025-01-24 - Use original search value for display
false, // AI-NOTE: 2025-01-24 - Second-order update means search is complete
); );
}, },
onSubscriptionCreated: (sub) => { onSubscriptionCreated: (sub) => {
@ -595,7 +549,8 @@
result.eventIds, result.eventIds,
result.addresses, result.addresses,
result.searchType, result.searchType,
result.searchTerm, searchValue || result.searchTerm, // AI-NOTE: 2025-01-24 - Use original search value for display
false, // AI-NOTE: 2025-01-24 - Search is complete
); );
const totalCount = result.events.length + result.secondOrder.length + result.tTagEvents.length; const totalCount = result.events.length + result.secondOrder.length + result.tTagEvents.length;
@ -645,75 +600,9 @@
} }
} }
async function performBackgroundProfileSearch( // AI-NOTE: 2025-01-24 - Background profile search is now handled by centralized searchProfiles function
searchType: "d" | "t" | "n", // This function is no longer needed as profile searches go through subscription_search.ts
searchTerm: string, // which delegates to the centralized profile_search.ts
) {
console.log("EventSearch: Performing background profile search:", {
searchType,
searchTerm,
});
try {
if (currentAbortController) {
currentAbortController.abort();
}
currentAbortController = new AbortController();
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Background search timeout"));
}, 10000);
});
const searchPromise = searchBySubscription(
searchType,
searchTerm,
{
onSecondOrderUpdate: (updatedResult) => {
console.log("EventSearch: Background second order update:", updatedResult);
if (updatedResult.events.length > 0) {
onSearchResults(
updatedResult.events,
updatedResult.secondOrder,
updatedResult.tTagEvents,
updatedResult.eventIds,
updatedResult.addresses,
updatedResult.searchType,
updatedResult.searchTerm,
);
}
},
onSubscriptionCreated: (sub) => {
console.log("EventSearch: Background subscription created:", sub);
if (activeSub) {
activeSub.stop();
}
activeSub = sub;
},
},
currentAbortController.signal,
);
const result = await Promise.race([searchPromise, timeoutPromise]) as any;
console.log("EventSearch: Background search completed:", result);
if (result.events.length > 0) {
onSearchResults(
result.events,
result.secondOrder,
result.tTagEvents,
result.eventIds,
result.addresses,
result.searchType,
result.searchTerm,
);
}
} catch (error) {
console.warn("EventSearch: Background profile search failed:", error);
}
}
function handleClear() { function handleClear() {
isResetting = true; isResetting = true;

1
src/lib/components/Notifications.svelte

@ -4,7 +4,6 @@
import { Heading, P } from "flowbite-svelte"; import { Heading, P } from "flowbite-svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte";
import { ndkInstance, activeInboxRelays } from "$lib/ndk"; import { ndkInstance, activeInboxRelays } from "$lib/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { get } from "svelte/store"; import { get } from "svelte/store";

54
src/lib/components/cards/ProfileHeader.svelte

@ -2,7 +2,8 @@
import { Card, Modal, Button, P } from "flowbite-svelte"; import { Card, Modal, Button, P } from "flowbite-svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts"; import { toNpub } from "$lib/utils/nostrUtils.ts";
import type { NostrProfile } from "$lib/utils/search_types";
import QrCode from "$components/util/QrCode.svelte"; import QrCode from "$components/util/QrCode.svelte";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import LazyImage from "$components/util/LazyImage.svelte"; import LazyImage from "$components/util/LazyImage.svelte";
@ -14,20 +15,24 @@
import { bech32 } from "bech32"; import { bech32 } from "bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { isPubkeyInUserLists, fetchCurrentUserLists } from "$lib/utils/user_lists";
const { const {
event, event,
profile, profile,
identifiers = [], identifiers = [],
communityStatusMap = {},
} = $props<{ } = $props<{
event: NDKEvent; event: NDKEvent;
profile: NostrProfile; profile: NostrProfile;
identifiers?: { label: string; value: string; link?: string }[]; identifiers?: { label: string; value: string; link?: string }[];
communityStatusMap?: Record<string, boolean>;
}>(); }>();
let lnModalOpen = $state(false); let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null); let lnurl = $state<string | null>(null);
let communityStatus = $state<boolean | null>(null); let communityStatus = $state<boolean | null>(null);
let isInUserLists = $state<boolean | null>(null);
onMount(async () => { onMount(async () => {
if (profile?.lud16) { if (profile?.lud16) {
@ -45,6 +50,34 @@
$effect(() => { $effect(() => {
if (event?.pubkey) { if (event?.pubkey) {
// First check if we have cached profileData with user list information
const cachedProfileData = (event as any).profileData;
console.log(`[ProfileHeader] Checking user list status for ${event.pubkey}, cached profileData:`, cachedProfileData);
if (cachedProfileData && typeof cachedProfileData.isInUserLists === 'boolean') {
isInUserLists = cachedProfileData.isInUserLists;
console.log(`[ProfileHeader] Using cached user list status for ${event.pubkey}: ${isInUserLists}`);
} else {
console.log(`[ProfileHeader] No cached user list data, fetching for ${event.pubkey}`);
// Fallback to fetching user lists
fetchCurrentUserLists()
.then((userLists) => {
console.log(`[ProfileHeader] Fetched ${userLists.length} user lists for ${event.pubkey}`);
isInUserLists = isPubkeyInUserLists(event.pubkey, userLists);
console.log(`[ProfileHeader] Final user list status for ${event.pubkey}: ${isInUserLists}`);
})
.catch((error) => {
console.error(`[ProfileHeader] Error fetching user lists for ${event.pubkey}:`, error);
isInUserLists = false;
});
}
// Check community status - use cached data if available
if (communityStatusMap[event.pubkey] !== undefined) {
communityStatus = communityStatusMap[event.pubkey];
console.log(`[ProfileHeader] Using cached community status for ${event.pubkey}: ${communityStatus}`);
} else {
// Fallback to checking community status
checkCommunity(event.pubkey) checkCommunity(event.pubkey)
.then((status) => { .then((status) => {
communityStatus = status; communityStatus = status;
@ -53,6 +86,7 @@
communityStatus = false; communityStatus = false;
}); });
} }
}
}); });
function navigateToIdentifier(link: string) { function navigateToIdentifier(link: string) {
@ -118,6 +152,24 @@
{:else if communityStatus === false} {:else if communityStatus === false}
<div class="flex-shrink-0 w-4 h-4"></div> <div class="flex-shrink-0 w-4 h-4"></div>
{/if} {/if}
{#if isInUserLists === true}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if isInUserLists === false}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
</div> </div>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">

2
src/lib/navigator/EventNetwork/Legend.svelte

@ -467,7 +467,7 @@
></span> ></span>
</div> </div>
<span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};"> <span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};">
{person.displayName || person.pubkey.substring(0, 8)} {person.displayName || person.pubkey}
</span> </span>
</button> </button>
{/each} {/each}

20
src/lib/navigator/EventNetwork/NodeTooltip.svelte

@ -7,7 +7,7 @@
<script lang="ts"> <script lang="ts">
import type { NetworkNode } from "./types"; import type { NetworkNode } from "./types";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import { getEventKindName } from "$lib/utils/eventColors"; import { getEventKindName } from "$lib/utils/eventColors";
import { import {
getDisplayNameSync, getDisplayNameSync,
@ -47,6 +47,11 @@
* Gets the author name from the event tags * Gets the author name from the event tags
*/ */
function getAuthorTag(node: NetworkNode): string { function getAuthorTag(node: NetworkNode): string {
// For person anchor nodes, use the pubkey directly
if (node.isPersonAnchor && node.pubkey) {
return getDisplayNameSync(node.pubkey);
}
if (node.event) { if (node.event) {
const authorTags = getMatchingTags(node.event, "author"); const authorTags = getMatchingTags(node.event, "author");
if (authorTags.length > 0) { if (authorTags.length > 0) {
@ -100,6 +105,11 @@
if (isPublicationEvent(node.kind)) { if (isPublicationEvent(node.kind)) {
return `/publication/id/${node.id}?from=visualize`; return `/publication/id/${node.id}?from=visualize`;
} }
// For person anchor nodes, use the pubkey to create an npub
if (node.isPersonAnchor && node.pubkey) {
const npub = toNpub(node.pubkey);
return `/events?id=${npub}`;
}
return `/events?id=${node.id}`; return `/events?id=${node.id}`;
} }
@ -206,12 +216,18 @@
</div> </div>
<!-- Pub Author --> <!-- Pub Author -->
{#if !node.isPersonAnchor}
<div class="tooltip-metadata"> <div class="tooltip-metadata">
Pub Author: {getAuthorTag(node)} Pub Author: {getAuthorTag(node)}
</div> </div>
{/if}
<!-- Published by (from node.author) --> <!-- Published by (from node.author) -->
{#if node.author} {#if node.isPersonAnchor}
<div class="tooltip-metadata">
Person: {getAuthorTag(node)}
</div>
{:else if node.author}
<div class="tooltip-metadata"> <div class="tooltip-metadata">
published_by: {node.author} published_by: {node.author}
</div> </div>

26
src/lib/navigator/EventNetwork/index.svelte

@ -250,7 +250,7 @@
/** /**
* Generates graph data from events, including tag and person anchors * Generates graph data from events, including tag and person anchors
*/ */
function generateGraphData() { async function generateGraphData() {
debug("Generating graph with events", { debug("Generating graph with events", {
eventCount: events.length, eventCount: events.length,
currentLevels, currentLevels,
@ -309,7 +309,7 @@
personMap = extractUniquePersons(events, followListEvents); personMap = extractUniquePersons(events, followListEvents);
// Create person anchor nodes based on filters // Create person anchor nodes based on filters
const personResult = createPersonAnchorNodes( const personResult = await createPersonAnchorNodes(
personMap, personMap,
width, width,
height, height,
@ -866,7 +866,7 @@
* Updates the graph with new data * Updates the graph with new data
* Generates the graph from events, creates the simulation, and renders nodes and links * Generates the graph from events, creates the simulation, and renders nodes and links
*/ */
function updateGraph() { async function updateGraph() {
debug("updateGraph called", { debug("updateGraph called", {
eventCount: events?.length, eventCount: events?.length,
starVisualization, starVisualization,
@ -878,7 +878,7 @@
try { try {
validateGraphElements(); validateGraphElements();
const graphData = generateGraphData(); const graphData = await generateGraphData();
// Save current positions before filtering // Save current positions before filtering
saveNodePositions(graphData.nodes); saveNodePositions(graphData.nodes);
@ -1011,17 +1011,17 @@
}); });
// Debounced update function // Debounced update function
function scheduleGraphUpdate() { async function scheduleGraphUpdate() {
if (updateTimer) { if (updateTimer) {
clearTimeout(updateTimer); clearTimeout(updateTimer);
} }
updateTimer = setTimeout(() => { updateTimer = setTimeout(async () => {
if (!isUpdating && svg && events?.length > 0) { if (!isUpdating && svg && events?.length > 0) {
debug("Scheduled graph update executing", graphDependencies); debug("Scheduled graph update executing", graphDependencies);
isUpdating = true; isUpdating = true;
try { try {
updateGraph(); await updateGraph();
} catch (error) { } catch (error) {
console.error("Error updating graph:", error); console.error("Error updating graph:", error);
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`;
@ -1235,9 +1235,9 @@
<p>{errorMessage}</p> <p>{errorMessage}</p>
<button <button
class="network-error-retry" class="network-error-retry"
onclick={() => { onclick={async () => {
errorMessage = null; errorMessage = null;
updateGraph(); await updateGraph();
}} }}
> >
Retry Retry
@ -1258,20 +1258,20 @@
{autoDisabledTags} {autoDisabledTags}
bind:showTagAnchors bind:showTagAnchors
bind:selectedTagType bind:selectedTagType
onTagSettingsChange={() => { onTagSettingsChange={async () => {
// Trigger graph update when tag settings change // Trigger graph update when tag settings change
if (svg && events?.length) { if (svg && events?.length) {
updateGraph(); await updateGraph();
} }
}} }}
bind:showPersonNodes bind:showPersonNodes
personAnchors={personAnchorInfo} personAnchors={personAnchorInfo}
{disabledPersons} {disabledPersons}
onPersonToggle={handlePersonToggle} onPersonToggle={handlePersonToggle}
onPersonSettingsChange={() => { onPersonSettingsChange={async () => {
// Trigger graph update when person settings change // Trigger graph update when person settings change
if (svg && events?.length) { if (svg && events?.length) {
updateGraph(); await updateGraph();
} }
}} }}
bind:showSignedBy bind:showSignedBy

17
src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts

@ -6,7 +6,7 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink } from "../types"; import type { NetworkNode, NetworkLink } from "../types";
import { getDisplayNameSync } from "$lib/utils/profileCache"; import { getDisplayNameSync, batchFetchProfiles } from "$lib/utils/profileCache";
import { SeededRandom, createDebugFunction } from "./common"; import { SeededRandom, createDebugFunction } from "./common";
const PERSON_ANCHOR_RADIUS = 15; const PERSON_ANCHOR_RADIUS = 15;
@ -186,14 +186,14 @@ function getEligiblePersons(
/** /**
* Creates person anchor nodes * Creates person anchor nodes
*/ */
export function createPersonAnchorNodes( export async function createPersonAnchorNodes(
personMap: Map<string, PersonConnection>, personMap: Map<string, PersonConnection>,
width: number, width: number,
height: number, height: number,
showSignedBy: boolean, showSignedBy: boolean,
showReferenced: boolean, showReferenced: boolean,
limit: number = MAX_PERSON_NODES limit: number = MAX_PERSON_NODES
): { nodes: NetworkNode[], totalCount: number } { ): Promise<{ nodes: NetworkNode[], totalCount: number }> {
const anchorNodes: NetworkNode[] = []; const anchorNodes: NetworkNode[] = [];
const centerX = width / 2; const centerX = width / 2;
@ -202,6 +202,17 @@ export function createPersonAnchorNodes(
// Calculate eligible persons and their connection counts // Calculate eligible persons and their connection counts
const eligiblePersons = getEligiblePersons(personMap, showSignedBy, showReferenced, limit); const eligiblePersons = getEligiblePersons(personMap, showSignedBy, showReferenced, limit);
// Cache profiles for person anchor nodes
const personPubkeys = eligiblePersons.map(p => p.pubkey);
if (personPubkeys.length > 0) {
debug("Caching profiles for person anchor nodes", { count: personPubkeys.length });
try {
await batchFetchProfiles(personPubkeys);
} catch (error) {
debug("Failed to cache profiles for person anchor nodes", error);
}
}
// Create nodes for the limited set // Create nodes for the limited set
debug("Creating person anchor nodes", { debug("Creating person anchor nodes", {
eligibleCount: eligiblePersons.length, eligibleCount: eligiblePersons.length,

8
src/lib/ndk.ts

@ -20,13 +20,11 @@ import {
// Re-export testRelayConnection for components that need it // Re-export testRelayConnection for components that need it
export { testRelayConnection }; export { testRelayConnection };
import { userStore } from "./stores/userStore.ts"; import { userStore } from "./stores/userStore.ts";
import { userPubkey } from "./stores/authStore.Svelte.ts";
import { startNetworkStatusMonitoring, stopNetworkStatusMonitoring } from "./stores/networkStore.ts"; import { startNetworkStatusMonitoring, stopNetworkStatusMonitoring } from "./stores/networkStore.ts";
import { WebSocketPool } from "./data_structures/websocket_pool.ts"; import { WebSocketPool } from "./data_structures/websocket_pool.ts";
export const ndkInstance: Writable<NDK> = writable(); export const ndkInstance: Writable<NDK> = writable();
export const ndkSignedIn = writable(false); export const ndkSignedIn = writable(false);
export const activePubkey = writable<string | null>(null);
export const inboxRelays = writable<string[]>([]); export const inboxRelays = writable<string[]>([]);
export const outboxRelays = writable<string[]>([]); export const outboxRelays = writable<string[]>([]);
@ -757,8 +755,7 @@ export async function loginWithExtension(
console.debug("[NDK.ts] Switching pubkeys from last login."); console.debug("[NDK.ts] Switching pubkeys from last login.");
} }
activePubkey.set(signerUser.pubkey);
userPubkey.set(signerUser.pubkey);
const user = ndk.getUser({ pubkey: signerUser.pubkey }); const user = ndk.getUser({ pubkey: signerUser.pubkey });
@ -784,8 +781,7 @@ export async function loginWithExtension(
export function logout(user: NDKUser): void { export function logout(user: NDKUser): void {
clearLogin(); clearLogin();
clearPersistedRelays(user); clearPersistedRelays(user);
activePubkey.set(null);
userPubkey.set(null);
ndkSignedIn.set(false); ndkSignedIn.set(false);
// Clear relay stores // Clear relay stores

4
src/lib/state.ts

@ -8,8 +8,6 @@ export const tabs: Writable<Tab[]> = writable([{ id: 0, type: "welcome" }]);
export const tabBehaviour: Writable<string> = writable( export const tabBehaviour: Writable<string> = writable(
(browser && localStorage.getItem("wikinostr_tabBehaviour")) || "normal", (browser && localStorage.getItem("wikinostr_tabBehaviour")) || "normal",
); );
export const userPublickey: Writable<string> = writable(
(browser && localStorage.getItem("wikinostr_loggedInPublicKey")) || "",
);
export const networkFetchLimit: Writable<number> = writable(50); export const networkFetchLimit: Writable<number> = writable(50);
export const levelsToRender: Writable<number> = writable(3); export const levelsToRender: Writable<number> = writable(3);

11
src/lib/stores/authStore.Svelte.ts

@ -1,11 +0,0 @@
import { writable, derived } from "svelte/store";
/**
* Stores the user's public key if logged in, or null otherwise.
*/
export const userPubkey = writable<string | null>(null);
/**
* Derived store indicating if the user is logged in.
*/
export const isLoggedIn = derived(userPubkey, ($userPubkey) => !!$userPubkey);

541
src/lib/stores/userStore.ts

@ -1,30 +1,36 @@
import { writable, get } from "svelte/store"; import { writable, get } from 'svelte/store';
import type { NostrProfile } from "../utils/nostrUtils.ts"; import type { NostrProfile } from '../utils/search_types.ts';
import type { NDKUser, NDKSigner } from "@nostr-dev-kit/ndk"; import type { NDKUser, NDKSigner } from '@nostr-dev-kit/ndk';
import NDK, { import NDK, {
NDKNip07Signer, NDKNip07Signer,
NDKRelayAuthPolicies, NDKRelayAuthPolicies,
NDKRelaySet, NDKRelaySet,
NDKRelay, NDKRelay,
} from "@nostr-dev-kit/ndk"; } from '@nostr-dev-kit/ndk';
import { getUserMetadata } from "../utils/nostrUtils.ts"; import { getUserMetadata } from '../utils/nostrUtils.ts';
import { ndkInstance, activeInboxRelays, activeOutboxRelays, updateActiveRelayStores } from "../ndk.ts"; import { ndkInstance, activeInboxRelays, activeOutboxRelays, updateActiveRelayStores } from '../ndk.ts';
import { loginStorageKey } from "../consts.ts"; import { loginStorageKey } from '../consts.ts';
import { nip19 } from "nostr-tools"; import { nip19 } from 'nostr-tools';
import { userPubkey } from "../stores/authStore.Svelte.ts"; import { fetchCurrentUserLists } from '../utils/user_lists.ts';
import { npubCache } from '../utils/npubCache.ts';
// AI-NOTE: UserStore consolidation - This file contains all user-related state management
// including authentication, profile management, relay preferences, and user lists caching.
export type LoginMethod = 'extension' | 'amber' | 'npub';
export interface UserState { export interface UserState {
pubkey: string | null; pubkey: string | null;
npub: string | null; npub: string | null;
profile: NostrProfile | null; profile: NostrProfile | null;
relays: { inbox: string[]; outbox: string[] }; relays: { inbox: string[]; outbox: string[] };
loginMethod: "extension" | "amber" | "npub" | null; loginMethod: LoginMethod | null;
ndkUser: NDKUser | null; ndkUser: NDKUser | null;
signer: NDKSigner | null; signer: NDKSigner | null;
signedIn: boolean; signedIn: boolean;
} }
export const userStore = writable<UserState>({ const initialUserState: UserState = {
pubkey: null, pubkey: null,
npub: null, npub: null,
profile: null, profile: null,
@ -33,49 +39,83 @@ export const userStore = writable<UserState>({
ndkUser: null, ndkUser: null,
signer: null, signer: null,
signedIn: false, signedIn: false,
}); };
export const userStore = writable<UserState>(initialUserState);
// Storage keys
export const loginMethodStorageKey = 'alexandria/login/method';
const LOGOUT_FLAG_KEY = 'alexandria/logout/flag';
// Performance optimization: Cache for relay storage keys
const relayStorageKeyCache = new Map<string, { inbox: string; outbox: string }>();
/**
* Get relay storage key for a user, with caching for performance
*/
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string {
const cacheKey = user.pubkey;
let cached = relayStorageKeyCache.get(cacheKey);
if (!cached) {
const baseKey = `${loginStorageKey}/${user.pubkey}`;
cached = {
inbox: `${baseKey}/inbox`,
outbox: `${baseKey}/outbox`,
};
relayStorageKeyCache.set(cacheKey, cached);
}
return type === 'inbox' ? cached.inbox : cached.outbox;
}
// Helper functions for relay management /**
function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string { * Safely access localStorage (client-side only)
return `${loginStorageKey}/${user.pubkey}/${type}`; */
function safeLocalStorage(): Storage | null {
return typeof window !== 'undefined' ? window.localStorage : null;
} }
/**
* Persist relay preferences to localStorage
*/
function persistRelays( function persistRelays(
user: NDKUser, user: NDKUser,
inboxes: Set<NDKRelay>, inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>,
): void { ): void {
// Only access localStorage on client-side const storage = safeLocalStorage();
if (typeof window === 'undefined') return; if (!storage) return;
localStorage.setItem( const inboxUrls = Array.from(inboxes).map((relay) => relay.url);
getRelayStorageKey(user, "inbox"), const outboxUrls = Array.from(outboxes).map((relay) => relay.url);
JSON.stringify(Array.from(inboxes).map((relay) => relay.url)),
); storage.setItem(getRelayStorageKey(user, 'inbox'), JSON.stringify(inboxUrls));
localStorage.setItem( storage.setItem(getRelayStorageKey(user, 'outbox'), JSON.stringify(outboxUrls));
getRelayStorageKey(user, "outbox"),
JSON.stringify(Array.from(outboxes).map((relay) => relay.url)),
);
} }
/**
* Get persisted relay preferences from localStorage
*/
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
// Only access localStorage on client-side const storage = safeLocalStorage();
if (typeof window === 'undefined') { if (!storage) {
return [new Set<string>(), new Set<string>()]; return [new Set<string>(), new Set<string>()];
} }
const inboxes = new Set<string>( const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"), JSON.parse(storage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]'),
); );
const outboxes = new Set<string>( const outboxes = new Set<string>(
JSON.parse( JSON.parse(storage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]'),
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
); );
return [inboxes, outboxes]; return [inboxes, outboxes];
} }
/**
* Fetch user's preferred relays from Nostr network
*/
async function getUserPreferredRelays( async function getUserPreferredRelays(
ndk: NDK, ndk: NDK,
user: NDKUser, user: NDKUser,
@ -97,9 +137,11 @@ async function getUserPreferredRelays(
const inboxRelays = new Set<NDKRelay>(); const inboxRelays = new Set<NDKRelay>();
const outboxRelays = new Set<NDKRelay>(); const outboxRelays = new Set<NDKRelay>();
if (relayList == null) { if (!relayList) {
// Fallback to extension relays if available
const relayMap = await globalThis.nostr?.getRelays?.(); const relayMap = await globalThis.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach( if (relayMap) {
Object.entries(relayMap).forEach(
([url, relayType]: [string, Record<string, boolean | undefined>]) => { ([url, relayType]: [string, Record<string, boolean | undefined>]) => {
const relay = new NDKRelay( const relay = new NDKRelay(
url, url,
@ -110,26 +152,23 @@ async function getUserPreferredRelays(
if (relayType.write) outboxRelays.add(relay); if (relayType.write) outboxRelays.add(relay);
}, },
); );
}
} else { } else {
// Parse relay list from event
relayList.tags.forEach((tag: string[]) => { relayList.tags.forEach((tag: string[]) => {
const relay = new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk);
switch (tag[0]) { switch (tag[0]) {
case "r": case 'r':
inboxRelays.add( inboxRelays.add(relay);
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break; break;
case "w": case 'w':
outboxRelays.add( outboxRelays.add(relay);
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break; break;
default: default:
inboxRelays.add( // Default: add to both
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk), inboxRelays.add(relay);
); outboxRelays.add(relay);
outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
break; break;
} }
}); });
@ -138,324 +177,358 @@ async function getUserPreferredRelays(
return [inboxRelays, outboxRelays]; return [inboxRelays, outboxRelays];
} }
// --- Unified login/logout helpers --- /**
* Persist login information to localStorage
export const loginMethodStorageKey = "alexandria/login/method"; */
function persistLogin(user: NDKUser, method: LoginMethod): void {
function persistLogin(user: NDKUser, method: "extension" | "amber" | "npub") { const storage = safeLocalStorage();
// Only access localStorage on client-side if (!storage) return;
if (typeof window === 'undefined') return;
localStorage.setItem(loginStorageKey, user.pubkey);
localStorage.setItem(loginMethodStorageKey, method);
}
function clearLogin() { storage.setItem(loginStorageKey, user.pubkey);
localStorage.removeItem(loginStorageKey); storage.setItem(loginMethodStorageKey, method);
localStorage.removeItem(loginMethodStorageKey);
} }
/** /**
* Login with NIP-07 browser extension * Clear login information from localStorage
*/ */
export async function loginWithExtension() { function clearLogin(): void {
const ndk = get(ndkInstance); const storage = safeLocalStorage();
if (!ndk) throw new Error("NDK not initialized"); if (!storage) return;
// Only clear previous login state after successful login
const signer = new NDKNip07Signer();
const user = await signer.user();
const npub = user.npub;
console.log("Login with extension - fetching profile for npub:", npub); storage.removeItem(loginStorageKey);
storage.removeItem(loginMethodStorageKey);
}
// Try to fetch user metadata, but don't fail if it times out /**
let profile: NostrProfile | null = null; * Fetch user profile with fallback
*/
async function fetchUserProfile(npub: string): Promise<NostrProfile> {
try { try {
console.log("Login with extension - attempting to fetch profile..."); return await getUserMetadata(npub, true);
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with extension - fetched profile:", profile);
} catch (error) { } catch (error) {
console.warn("Failed to fetch user metadata during login:", error); console.warn('Failed to fetch user metadata:', error);
// Continue with login even if metadata fetch fails // Fallback profile
profile = { return {
name: npub.slice(0, 8) + "..." + npub.slice(-4), name: npub.slice(0, 8) + '...' + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4), displayName: npub.slice(0, 8) + '...' + npub.slice(-4),
}; };
console.log("Login with extension - using fallback profile:", profile);
} }
}
// Fetch user's preferred relays /**
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user); * Fetch and cache user lists in background
for (const relay of persistedInboxes) { */
ndk.addExplicitRelay(relay); async function fetchUserListsAndUpdateCache(userPubkey: string): Promise<void> {
} try {
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user); console.log('Fetching user lists and updating profile cache for:', userPubkey);
persistRelays(user, inboxes, outboxes);
ndk.signer = signer;
ndk.activeUser = user;
const userState = { const userLists = await fetchCurrentUserLists();
pubkey: user.pubkey, console.log(`Found ${userLists.length} user lists`);
npub,
profile,
relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
},
loginMethod: "extension" as const,
ndkUser: user,
signer,
signedIn: true,
};
console.log("Login with extension - setting userStore with:", userState); // Collect all unique pubkeys
userStore.set(userState); const allPubkeys = new Set<string>();
userPubkey.set(user.pubkey); userLists.forEach(list => {
list.pubkeys.forEach(pubkey => allPubkeys.add(pubkey));
});
console.log(`Found ${allPubkeys.size} unique pubkeys in user lists`);
// Batch fetch profiles for performance
const batchSize = 20;
const pubkeyArray = Array.from(allPubkeys);
const ndk = get(ndkInstance);
if (!ndk) return;
for (let i = 0; i < pubkeyArray.length; i += batchSize) {
const batch = pubkeyArray.slice(i, i + batchSize);
try {
const events = await ndk.fetchEvents({
kinds: [0],
authors: batch,
});
// Update relay stores with the new user's relays // Cache profiles
for (const event of events) {
if (event.content) {
try { try {
console.debug('[userStore.ts] loginWithExtension: Updating relay stores for authenticated user'); const profileData = JSON.parse(event.content);
await updateActiveRelayStores(ndk, true); // Force update to rebuild relay set for authenticated user const npub = nip19.npubEncode(event.pubkey);
npubCache.set(npub, profileData);
} catch (e) {
console.warn('Failed to parse profile data:', e);
}
}
}
} catch (error) { } catch (error) {
console.warn('[userStore.ts] loginWithExtension: Failed to update relay stores:', error); console.warn('Failed to fetch batch of profiles:', error);
}
} }
clearLogin(); console.log('User lists and profile cache update completed');
// Only access localStorage on client-side } catch (error) {
if (typeof window !== 'undefined') { console.warn('Failed to fetch user lists and update cache:', error);
localStorage.removeItem("alexandria/logout/flag");
} }
persistLogin(user, "extension");
} }
/** /**
* Login with Amber (NIP-46) * Common login logic to reduce code duplication
*/ */
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser) { async function performLogin(
user: NDKUser,
signer: NDKSigner | null,
method: LoginMethod,
): Promise<void> {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized"); if (!ndk) throw new Error('NDK not initialized');
// Only clear previous login state after successful login
const npub = user.npub; const npub = user.npub;
console.log(`Login with ${method} - fetching profile for npub:`, npub);
console.log("Login with Amber - fetching profile for npub:", npub); // Fetch profile
const profile = await fetchUserProfile(npub);
let profile: NostrProfile | null = null; console.log(`Login with ${method} - fetched profile:`, profile);
try {
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with Amber - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during Amber login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with Amber - using fallback profile:", profile);
}
// Handle relays
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user); const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user);
for (const relay of persistedInboxes) { persistedInboxes.forEach(relay => ndk.addExplicitRelay(relay));
ndk.addExplicitRelay(relay);
}
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user); const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes); persistRelays(user, inboxes, outboxes);
ndk.signer = amberSigner;
// Set NDK state
ndk.signer = signer || undefined;
ndk.activeUser = user; ndk.activeUser = user;
const userState = { // Create user state
const userState: UserState = {
pubkey: user.pubkey, pubkey: user.pubkey,
npub, npub,
profile, profile,
relays: { relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url), inbox: Array.from(inboxes || persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map( outbox: Array.from(outboxes || persistedOutboxes).map((relay) => relay.url),
(relay) => relay.url,
),
}, },
loginMethod: "amber" as const, loginMethod: method,
ndkUser: user, ndkUser: user,
signer: amberSigner, signer,
signedIn: true, signedIn: true,
}; };
console.log("Login with Amber - setting userStore with:", userState); console.log(`Login with ${method} - setting userStore with:`, userState);
userStore.set(userState); userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays // Update relay stores
try { try {
console.debug('[userStore.ts] loginWithAmber: Updating relay stores for authenticated user'); console.debug(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Updating relay stores`);
await updateActiveRelayStores(ndk, true); // Force update to rebuild relay set for authenticated user await updateActiveRelayStores(ndk, true);
} catch (error) { } catch (error) {
console.warn('[userStore.ts] loginWithAmber: Failed to update relay stores:', error); console.warn(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Failed to update relay stores:`, error);
} }
// Background tasks
fetchUserListsAndUpdateCache(user.pubkey).catch(error => {
console.warn(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Failed to fetch user lists:`, error);
});
// Cleanup and persist
clearLogin(); clearLogin();
// Only access localStorage on client-side const storage = safeLocalStorage();
if (typeof window !== 'undefined') { if (storage) {
localStorage.removeItem("alexandria/logout/flag"); storage.removeItem(LOGOUT_FLAG_KEY);
} }
persistLogin(user, "amber"); persistLogin(user, method);
}
/**
* Login with NIP-07 browser extension
*/
export async function loginWithExtension(): Promise<void> {
const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized');
const signer = new NDKNip07Signer();
const user = await signer.user();
await performLogin(user, signer, 'extension');
}
/**
* Login with Amber (NIP-46)
*/
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser): Promise<void> {
await performLogin(user, amberSigner, 'amber');
} }
/** /**
* Login with npub (read-only) * Login with npub (read-only)
*/ */
export async function loginWithNpub(pubkeyOrNpub: string) { export async function loginWithNpub(pubkeyOrNpub: string): Promise<void> {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) throw new Error('NDK not initialized');
throw new Error("NDK not initialized");
}
// Decode pubkey
let hexPubkey: string; let hexPubkey: string;
if (pubkeyOrNpub.startsWith("npub1")) { if (pubkeyOrNpub.startsWith('npub1')) {
try { try {
const decoded = nip19.decode(pubkeyOrNpub); const decoded = nip19.decode(pubkeyOrNpub);
if (decoded.type !== "npub") { if (decoded.type !== 'npub') {
throw new Error("Invalid npub format"); throw new Error('Invalid npub format');
} }
hexPubkey = decoded.data; hexPubkey = decoded.data;
} catch (e) { } catch (e) {
console.error("Failed to decode npub:", pubkeyOrNpub, e); console.error('Failed to decode npub:', pubkeyOrNpub, e);
throw e; throw e;
} }
} else { } else {
hexPubkey = pubkeyOrNpub; hexPubkey = pubkeyOrNpub;
} }
// Encode npub
let npub: string; let npub: string;
try { try {
npub = nip19.npubEncode(hexPubkey); npub = nip19.npubEncode(hexPubkey);
} catch (e) { } catch (e) {
console.error("Failed to encode npub from hex pubkey:", hexPubkey, e); console.error('Failed to encode npub from hex pubkey:', hexPubkey, e);
throw e; throw e;
} }
console.log("Login with npub - fetching profile for npub:", npub); console.log('Login with npub - fetching profile for npub:', npub);
const user = ndk.getUser({ npub }); const user = ndk.getUser({ npub });
let profile: NostrProfile | null = null;
// First, update relay stores to ensure we have relays available // Update relay stores first
try { try {
console.debug('[userStore.ts] loginWithNpub: Updating relay stores for authenticated user'); console.debug('[userStore.ts] loginWithNpub: Updating relay stores');
await updateActiveRelayStores(ndk); await updateActiveRelayStores(ndk);
} catch (error) { } catch (error) {
console.warn('[userStore.ts] loginWithNpub: Failed to update relay stores:', error); console.warn('[userStore.ts] loginWithNpub: Failed to update relay stores:', error);
} }
// Wait a moment for relay stores to be properly initialized // Wait for relay stores to initialize
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
try { // Fetch profile
profile = await getUserMetadata(npub, true); // Force fresh fetch const profile = await fetchUserProfile(npub);
console.log("Login with npub - fetched profile:", profile);
} catch (error) {
console.warn("Failed to fetch user metadata during npub login:", error);
// Continue with login even if metadata fetch fails
profile = {
name: npub.slice(0, 8) + "..." + npub.slice(-4),
displayName: npub.slice(0, 8) + "..." + npub.slice(-4),
};
console.log("Login with npub - using fallback profile:", profile);
}
// Set NDK state (no signer for read-only)
ndk.signer = undefined; ndk.signer = undefined;
ndk.activeUser = user; ndk.activeUser = user;
const userState = { // Create user state
const userState: UserState = {
pubkey: user.pubkey, pubkey: user.pubkey,
npub, npub,
profile, profile,
relays: { inbox: [], outbox: [] }, relays: { inbox: [], outbox: [] },
loginMethod: "npub" as const, loginMethod: 'npub',
ndkUser: user, ndkUser: user,
signer: null, signer: null,
signedIn: true, signedIn: true,
}; };
console.log("Login with npub - setting userStore with:", userState); console.log('Login with npub - setting userStore with:', userState);
userStore.set(userState); userStore.set(userState);
userPubkey.set(user.pubkey);
// Background tasks
fetchUserListsAndUpdateCache(user.pubkey).catch(error => {
console.warn('[userStore.ts] loginWithNpub: Failed to fetch user lists:', error);
});
// Cleanup and persist
clearLogin(); clearLogin();
// Only access localStorage on client-side const storage = safeLocalStorage();
if (typeof window !== 'undefined') { if (storage) {
localStorage.removeItem("alexandria/logout/flag"); storage.removeItem(LOGOUT_FLAG_KEY);
} }
persistLogin(user, "npub"); persistLogin(user, 'npub');
} }
/** /**
* Logout and clear all user state * Logout and clear all user state
*/ */
export function logoutUser() { export function logoutUser(): void {
console.log("Logging out user..."); console.log('Logging out user...');
const currentUser = get(userStore); const currentUser = get(userStore);
// Only access localStorage on client-side // Clear localStorage
if (typeof window !== 'undefined') { const storage = safeLocalStorage();
if (storage) {
if (currentUser.ndkUser) { if (currentUser.ndkUser) {
// Clear persisted relays for the user // Clear persisted relays
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "inbox")); storage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'inbox'));
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "outbox")); storage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'outbox'));
} }
// Clear all possible login states from localStorage // Clear login data
clearLogin(); clearLogin();
// Also clear any other potential login keys that might exist // Clear any other potential login keys
const keysToRemove = []; const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < storage.length; i++) {
const key = localStorage.key(i); const key = storage.key(i);
if ( if (key && (
key && key.includes('login') ||
(key.includes("login") || key.includes('nostr') ||
key.includes("nostr") || key.includes('user') ||
key.includes("user") || key.includes('alexandria') ||
key.includes("alexandria") || key === 'pubkey'
key === "pubkey") )) {
) {
keysToRemove.push(key); keysToRemove.push(key);
} }
} }
// Specifically target the login storage key // Clear specific keys
keysToRemove.push("alexandria/login/pubkey"); keysToRemove.push('alexandria/login/pubkey', 'alexandria/login/method');
keysToRemove.push("alexandria/login/method"); keysToRemove.forEach(key => {
console.log('Removing localStorage key:', key);
keysToRemove.forEach((key) => { storage.removeItem(key);
console.log("Removing localStorage key:", key);
localStorage.removeItem(key);
}); });
// Clear Amber-specific flags // Clear Amber-specific flags
localStorage.removeItem("alexandria/amber/fallback"); storage.removeItem('alexandria/amber/fallback');
// Set a flag to prevent auto-login on next page load // Set logout flag
localStorage.setItem("alexandria/logout/flag", "true"); storage.setItem(LOGOUT_FLAG_KEY, 'true');
console.log("Cleared all login data from localStorage"); console.log('Cleared all login data from localStorage');
} }
userStore.set({ // Clear cache
pubkey: null, relayStorageKeyCache.clear();
npub: null,
profile: null, // Reset user store
relays: { inbox: [], outbox: [] }, userStore.set(initialUserState);
loginMethod: null,
ndkUser: null,
signer: null,
signedIn: false,
});
userPubkey.set(null);
// Clear NDK state
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (ndk) { if (ndk) {
ndk.activeUser = undefined; ndk.activeUser = undefined;
ndk.signer = undefined; ndk.signer = undefined;
} }
console.log("Logout complete"); console.log('Logout complete');
}
/**
* Reset user store to initial state
*/
export function resetUserStore(): void {
userStore.set(initialUserState);
relayStorageKeyCache.clear();
}
/**
* Get current user state
*/
export function getCurrentUser(): UserState {
return get(userStore);
}
/**
* Check if user is signed in
*/
export function isUserSignedIn(): boolean {
return get(userStore).signedIn;
} }

2
src/lib/utils/eventColors.ts

@ -56,6 +56,8 @@ export function getEventKindName(kind: number): string {
30001: 'Categorized Bookmark List', 30001: 'Categorized Bookmark List',
30008: 'Profile Badges', 30008: 'Profile Badges',
30009: 'Badge Definition', 30009: 'Badge Definition',
39089: 'Starter packs',
39092: 'Media starter packs',
30017: 'Create or update a stall', 30017: 'Create or update a stall',
30018: 'Create or update a product', 30018: 'Create or update a product',
30023: 'Long-form Content', 30023: 'Long-form Content',

18
src/lib/utils/event_search.ts

@ -207,7 +207,23 @@ export async function searchNip05(
const data = await res.json(); const data = await res.json();
const pubkey = data.names?.[name]; // Try exact match first
let pubkey = data.names?.[name];
// If not found, try case-insensitive search
if (!pubkey && data.names) {
const names = Object.keys(data.names);
const matchingName = names.find(
(n) => n.toLowerCase() === name.toLowerCase(),
);
if (matchingName) {
pubkey = data.names[matchingName];
console.log(
`[searchNip05] Found case-insensitive match: ${name} -> ${matchingName}`,
);
}
}
if (pubkey) { if (pubkey) {
const profileFilter = { kinds: [0], authors: [pubkey] }; const profileFilter = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback( const profileEvent = await fetchEventWithFallback(

14
src/lib/utils/nostrUtils.ts

@ -4,7 +4,7 @@ import { ndkInstance } from "../ndk.ts";
import { npubCache } from "./npubCache.ts"; import { npubCache } from "./npubCache.ts";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKKind, NostrEvent } from "@nostr-dev-kit/ndk"; import type { NDKKind, NostrEvent } from "@nostr-dev-kit/ndk";
import type { Filter } from "./search_types.ts"; import type { Filter, NostrProfile } from "./search_types.ts";
import { communityRelays, secondaryRelays, searchRelays, anonymousRelays } from "../consts.ts"; import { communityRelays, secondaryRelays, searchRelays, anonymousRelays } from "../consts.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk"; import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
@ -26,16 +26,7 @@ export const NOSTR_PROFILE_REGEX =
export const NOSTR_NOTE_REGEX = export const NOSTR_NOTE_REGEX =
/(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
export interface NostrProfile { // AI-NOTE: 2025-01-24 - NostrProfile interface moved to search_types.ts for consistency
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
}
/** /**
* HTML escape a string * HTML escape a string
@ -130,6 +121,7 @@ export async function getUserMetadata(
banner: profile?.banner, banner: profile?.banner,
website: profile?.website, website: profile?.website,
lud16: profile?.lud16, lud16: profile?.lud16,
created_at: profileEvent?.created_at, // AI-NOTE: 2025-01-24 - Preserve timestamp for proper date display
}; };
console.log("getUserMetadata: Final metadata:", metadata); console.log("getUserMetadata: Final metadata:", metadata);

2
src/lib/utils/npubCache.ts

@ -1,4 +1,4 @@
import type { NostrProfile } from "./nostrUtils"; import type { NostrProfile } from "./search_types";
export type NpubMetadata = NostrProfile; export type NpubMetadata = NostrProfile;

34
src/lib/utils/profileCache.ts

@ -2,6 +2,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { toNpub } from "./nostrUtils";
interface ProfileData { interface ProfileData {
display_name?: string; display_name?: string;
@ -50,24 +51,27 @@ async function fetchProfile(pubkey: string): Promise<ProfileData | null> {
/** /**
* Gets the display name for a pubkey, using cache * Gets the display name for a pubkey, using cache
* @param pubkey - The public key to get display name for * @param pubkey - The public key to get display name for
* @returns Display name, name, or shortened pubkey * @returns Display name, name, or shortened npub (never hex ID)
*/ */
export async function getDisplayName(pubkey: string): Promise<string> { export async function getDisplayName(pubkey: string): Promise<string> {
// Check cache first // Check cache first
if (profileCache.has(pubkey)) { if (profileCache.has(pubkey)) {
const profile = profileCache.get(pubkey)!; const profile = profileCache.get(pubkey)!;
return profile.display_name || profile.name || shortenPubkey(pubkey); const npub = toNpub(pubkey);
return profile.display_name || profile.name || (npub ? shortenNpub(npub) : pubkey);
} }
// Fetch profile // Fetch profile
const profile = await fetchProfile(pubkey); const profile = await fetchProfile(pubkey);
if (profile) { if (profile) {
profileCache.set(pubkey, profile); profileCache.set(pubkey, profile);
return profile.display_name || profile.name || shortenPubkey(pubkey); const npub = toNpub(pubkey);
return profile.display_name || profile.name || (npub ? shortenNpub(npub) : pubkey);
} }
// Fallback to shortened pubkey // Fallback to shortened npub or pubkey
return shortenPubkey(pubkey); const npub = toNpub(pubkey);
return npub ? shortenNpub(npub) : pubkey;
} }
/** /**
@ -139,24 +143,26 @@ export async function batchFetchProfiles(
/** /**
* Gets display name synchronously from cache * Gets display name synchronously from cache
* @param pubkey - The public key to get display name for * @param pubkey - The public key to get display name for
* @returns Display name, name, or shortened pubkey * @returns Display name, name, or shortened npub (never hex ID)
*/ */
export function getDisplayNameSync(pubkey: string): string { export function getDisplayNameSync(pubkey: string): string {
if (profileCache.has(pubkey)) { if (profileCache.has(pubkey)) {
const profile = profileCache.get(pubkey)!; const profile = profileCache.get(pubkey)!;
return profile.display_name || profile.name || shortenPubkey(pubkey); const npub = toNpub(pubkey);
return profile.display_name || profile.name || (npub ? shortenNpub(npub) : pubkey);
} }
return shortenPubkey(pubkey); const npub = toNpub(pubkey);
return npub ? shortenNpub(npub) : pubkey;
} }
/** /**
* Shortens a pubkey for display * Shortens an npub for display
* @param pubkey - The public key to shorten * @param npub - The npub to shorten
* @returns Shortened pubkey (first 8 chars...last 4 chars) * @returns Shortened npub (first 8 chars...last 4 chars)
*/ */
function shortenPubkey(pubkey: string): string { function shortenNpub(npub: string): string {
if (pubkey.length <= 12) return pubkey; if (npub.length <= 12) return npub;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; return `${npub.slice(0, 8)}...${npub.slice(-4)}`;
} }
/** /**

767
src/lib/utils/profile_search.ts

@ -1,8 +1,8 @@
import { ndkInstance, activeInboxRelays } from "../ndk.ts"; import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts"; import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts";
import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "./searchCache.ts"; import { searchCache } from "./searchCache.ts";
import { searchRelays, communityRelays, secondaryRelays } from "../consts.ts"; import { searchRelays, communityRelays, secondaryRelays, localRelays } from "../consts.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from "./search_types.ts"; import type { NostrProfile, ProfileSearchResult } from "./search_types.ts";
import { import {
@ -11,138 +11,418 @@ import {
normalizeSearchTerm, normalizeSearchTerm,
createProfileFromEvent, createProfileFromEvent,
} from "./search_utils.ts"; } from "./search_utils.ts";
import {
fetchCurrentUserLists,
getPubkeysFromUserLists,
isPubkeyInUserLists,
getListKindsForPubkey,
updateProfileCacheForPubkeys,
PEOPLE_LIST_KINDS
} from "./user_lists.ts";
import { nip19 } from "nostr-tools";
import { TIMEOUTS, SEARCH_LIMITS, CACHE_DURATIONS } from "./search_constants.ts";
// AI-NOTE: 2025-01-24 - User list cache with stale-while-revalidate for performance
// This prevents redundant relay queries by caching user lists for 5 minutes
// Fresh cache: Return immediately
// Stale cache: Return stale data immediately, update in background
// No cache: Wait for fresh data
/** /**
* Search for profiles by various criteria (display name, name, NIP-05, npub) * User list cache interface
*/ */
export async function searchProfiles( interface UserListCache {
searchTerm: string, lists: any[];
): Promise<ProfileSearchResult> { pubkeys: Set<string>;
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); lastUpdated: number;
isUpdating: boolean;
}
console.log( /**
"searchProfiles called with:", * Search strategy types
searchTerm, */
"normalized:", type SearchStrategy = 'npub' | 'nip05' | 'userLists' | 'nip05Domains' | 'relaySearch';
normalizedSearchTerm,
);
// Check cache first /**
const cachedResult = searchCache.get("profile", normalizedSearchTerm); * Global user list cache instance
if (cachedResult) { */
console.log("Found cached result for:", normalizedSearchTerm); let userListCache: UserListCache | null = null;
const profiles = cachedResult.events
.map((event) => { /**
* Get user lists with stale-while-revalidate caching
* Returns cached data immediately if available, updates in background if stale
*/
async function getUserListsWithCache(): Promise<{ lists: any[]; pubkeys: Set<string> }> {
const now = Date.now();
// If we have fresh cache, return it immediately
if (userListCache && (now - userListCache.lastUpdated) < CACHE_DURATIONS.SEARCH_CACHE) {
console.log("profile_search: Using fresh user list cache");
return {
lists: userListCache.lists,
pubkeys: userListCache.pubkeys
};
}
// If we have stale cache and no update in progress, return stale data and update in background
if (userListCache && !userListCache.isUpdating) {
console.log("profile_search: Using stale user list cache, updating in background");
// Start background update
userListCache.isUpdating = true;
updateUserListCacheInBackground().catch(error => {
console.warn("profile_search: Background user list cache update failed:", error);
if (userListCache) {
userListCache.isUpdating = false;
}
});
return {
lists: userListCache.lists,
pubkeys: userListCache.pubkeys
};
}
// If no cache or update in progress, wait for fresh data
console.log("profile_search: Fetching fresh user lists");
return await updateUserListCache();
}
/**
* Update user list cache in background
*/
async function updateUserListCacheInBackground(): Promise<void> {
try { try {
const profileData = JSON.parse(event.content); const { lists, pubkeys } = await updateUserListCache();
return createProfileFromEvent(event, profileData); console.log("profile_search: Background user list cache update completed");
} catch { } catch (error) {
return null; console.warn("profile_search: Background user list cache update failed:", error);
} finally {
if (userListCache) {
userListCache.isUpdating = false;
} }
}) }
.filter(Boolean) as NostrProfile[]; }
console.log("Cached profiles found:", profiles.length); /**
return { profiles, Status: {} }; * Update user list cache with fresh data
*/
async function updateUserListCache(): Promise<{ lists: any[]; pubkeys: Set<string> }> {
const lists = await fetchCurrentUserLists([...PEOPLE_LIST_KINDS]);
const pubkeys = getPubkeysFromUserLists(lists);
userListCache = {
lists,
pubkeys,
lastUpdated: Date.now(),
isUpdating: false
};
console.log(`profile_search: Updated user list cache with ${lists.length} lists and ${pubkeys.size} pubkeys`);
// Update profile cache for all user list pubkeys to ensure follows are cached
if (pubkeys.size > 0) {
updateProfileCacheForPubkeys(Array.from(pubkeys)).catch(error => {
console.warn("profile_search: Failed to update profile cache:", error);
});
} }
const ndk = get(ndkInstance); return { lists, pubkeys };
}
/**
* Clear user list cache (useful for logout or force refresh)
*/
export function clearUserListCache(): void {
userListCache = null;
console.log("profile_search: User list cache cleared");
}
/**
* Force refresh user list cache (useful when user follows/unfollows someone)
*/
export async function refreshUserListCache(): Promise<void> {
console.log("profile_search: Forcing user list cache refresh");
userListCache = null;
await updateUserListCache();
}
/**
* Get user list cache status for debugging
*/
export function getUserListCacheStatus(): {
hasCache: boolean;
isStale: boolean;
isUpdating: boolean;
ageMinutes: number | null;
listCount: number | null;
pubkeyCount: number | null;
} {
if (!userListCache) {
return {
hasCache: false,
isStale: false,
isUpdating: false,
ageMinutes: null,
listCount: null,
pubkeyCount: null
};
}
const now = Date.now();
const ageMs = now - userListCache.lastUpdated;
const ageMinutes = Math.round(ageMs / (60 * 1000));
const isStale = ageMs > CACHE_DURATIONS.SEARCH_CACHE;
return {
hasCache: true,
isStale,
isUpdating: userListCache.isUpdating,
ageMinutes,
listCount: userListCache.lists.length,
pubkeyCount: userListCache.pubkeys.size
};
}
/**
* Wait for NDK to be properly initialized
*/
async function waitForNdk(): Promise<NDK> {
let ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
console.error("NDK not initialized"); console.log("profile_search: Waiting for NDK initialization...");
let retryCount = 0;
const maxRetries = 10;
const retryDelay = 500; // milliseconds
while (retryCount < maxRetries && !ndk) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
ndk = get(ndkInstance);
retryCount++;
}
if (!ndk) {
console.error("profile_search: NDK not initialized after waiting");
throw new Error("NDK not initialized"); throw new Error("NDK not initialized");
} }
}
console.log("NDK initialized, starting search logic"); return ndk;
}
/**
* Check if search term is a valid npub/nprofile identifier
*/
function isNostrIdentifier(searchTerm: string): boolean {
return searchTerm.startsWith("npub") || searchTerm.startsWith("nprofile");
}
let foundProfiles: NostrProfile[] = []; /**
* Check if search term is a NIP-05 address
*/
function isNip05Address(searchTerm: string): boolean {
return searchTerm.includes("@");
}
/**
* Determine search strategy based on search term
*/
function determineSearchStrategy(searchTerm: string): SearchStrategy {
if (isNostrIdentifier(searchTerm)) {
return 'npub';
}
if (isNip05Address(searchTerm)) {
return 'nip05';
}
return 'userLists'; // Default to user lists first, then other strategies
}
/**
* Search for profiles by npub/nprofile identifier
*/
async function searchByNostrIdentifier(searchTerm: string, ndk: NDK): Promise<NostrProfile[]> {
try { try {
// Check if it's a valid npub/nprofile first const cleanId = searchTerm.replace(/^nostr:/, "");
if ( const decoded = nip19.decode(cleanId);
normalizedSearchTerm.startsWith("npub") ||
normalizedSearchTerm.startsWith("nprofile") if (!decoded) {
) { return [];
}
let pubkey: string;
if (decoded.type === "npub") {
pubkey = decoded.data;
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} else {
console.warn("Unsupported identifier type:", decoded.type);
return [];
}
// AI-NOTE: 2025-01-24 - For npub/nprofile searches, fetch the actual event to preserve timestamp
const events = await ndk.fetchEvents({
kinds: [0],
authors: [pubkey],
});
if (events.size > 0) {
// Get the most recent profile event
const event = Array.from(events).sort((a, b) =>
(b.created_at || 0) - (a.created_at || 0)
)[0];
if (event && event.content) {
try { try {
const metadata = await getUserMetadata(normalizedSearchTerm); const profileData = JSON.parse(event.content);
if (metadata) { const profile = createProfileFromEvent(event, profileData);
foundProfiles = [metadata]; return [profile];
} catch (error) {
console.error("Error parsing profile content for npub:", error);
}
} }
}
// Fallback to metadata
const metadata = await getUserMetadata(searchTerm);
const profileWithPubkey: NostrProfile = {
...metadata,
pubkey: pubkey,
};
return [profileWithPubkey];
} catch (error) { } catch (error) {
console.error("Error fetching metadata for npub:", error); console.error("Error fetching metadata for npub:", error);
return [];
} }
} else if (normalizedSearchTerm.includes("@")) { }
// Check if it's a NIP-05 address - normalize it properly
const normalizedNip05 = normalizedSearchTerm.toLowerCase(); /**
* Search for profiles by NIP-05 address
*/
async function searchByNip05Address(searchTerm: string): Promise<NostrProfile[]> {
try { try {
const normalizedNip05 = searchTerm.toLowerCase();
const npub = await getNpubFromNip05(normalizedNip05); const npub = await getNpubFromNip05(normalizedNip05);
if (npub) { if (npub) {
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub);
const profile: NostrProfile = { const profile: NostrProfile = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
}; };
foundProfiles = [profile]; return [profile];
} }
} catch (e) { } catch (error) {
console.error("[Search] NIP-05 lookup failed:", e); console.error("[Search] NIP-05 lookup failed:", error);
} }
} else {
// Try NIP-05 search first (faster than relay search)
console.log("Starting NIP-05 search for:", normalizedSearchTerm);
foundProfiles = await searchNip05Domains(normalizedSearchTerm);
console.log(
"NIP-05 search completed, found:",
foundProfiles.length,
"profiles",
);
// If no NIP-05 results, try quick relay search return [];
if (foundProfiles.length === 0) { }
console.log("No NIP-05 results, trying quick relay search");
foundProfiles = await quickRelaySearch(normalizedSearchTerm, ndk); /**
console.log( * Fuzzy match function for user list searches
"Quick relay search completed, found:", */
foundProfiles.length, function fuzzyMatch(text: string, searchTerm: string): boolean {
"profiles", if (!text || !searchTerm) return false;
);
const normalizedText = text.toLowerCase();
const normalizedSearchTerm = searchTerm.toLowerCase();
// Direct substring match
if (normalizedText.includes(normalizedSearchTerm)) {
return true;
}
// AI-NOTE: 2025-01-24 - More strict word boundary matching for profile searches
// Only match if the search term is a significant part of a word
const words = normalizedText.split(/[\s\-_\.]+/);
for (const word of words) {
// Only match if search term is at least 3 characters and represents a significant part of the word
if (normalizedSearchTerm.length >= 3) {
if (word.includes(normalizedSearchTerm) || normalizedSearchTerm.includes(word)) {
return true;
}
} }
} }
// Cache the results return false;
if (foundProfiles.length > 0) { }
const events = foundProfiles.map((profile) => {
const event = new NDKEvent(ndk); /**
event.content = JSON.stringify(profile); * Search for profiles within user's lists with fuzzy matching
event.pubkey = profile.pubkey || ""; */
return event; async function searchWithinUserLists(
searchTerm: string,
userLists: any[],
ndk: NDK,
): Promise<NostrProfile[]> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
const foundProfiles: NostrProfile[] = [];
const processedPubkeys = new Set<string>();
// Get all pubkeys from user lists
const allPubkeys: string[] = [];
userLists.forEach(list => {
list.pubkeys.forEach((pubkey: string) => {
if (!processedPubkeys.has(pubkey)) {
allPubkeys.push(pubkey);
processedPubkeys.add(pubkey);
}
});
}); });
const result = { if (allPubkeys.length === 0) {
events, return foundProfiles;
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: "profile",
searchTerm: normalizedSearchTerm,
};
searchCache.set("profile", normalizedSearchTerm, result);
} }
console.log("Search completed, found profiles:", foundProfiles.length); console.log(`searchWithinUserLists: Searching ${allPubkeys.length} pubkeys from user lists with fuzzy matching`);
return { profiles: foundProfiles, Status: {} };
// Fetch profiles for all pubkeys in batches
for (let i = 0; i < allPubkeys.length; i += SEARCH_LIMITS.BATCH_SIZE) {
const batch = allPubkeys.slice(i, i + SEARCH_LIMITS.BATCH_SIZE);
try {
const events = await ndk.fetchEvents({
kinds: [0],
authors: batch,
});
for (const event of events) {
try {
if (!event.content) continue;
const profileData = JSON.parse(event.content);
const displayName = profileData.displayName || profileData.display_name || "";
const name = profileData.name || "";
const nip05 = profileData.nip05 || "";
const about = profileData.about || "";
// Check if any field matches the search term with exact field matching only
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
if (matchesDisplayName || matchesName || matchesNip05 || matchesAbout) {
const profile = createProfileFromEvent(event, profileData);
foundProfiles.push(profile);
}
} catch {
// Invalid JSON, skip
}
}
} catch (error) { } catch (error) {
console.error("Error searching profiles:", error); console.warn("searchWithinUserLists: Error fetching batch:", error);
return { profiles: [], Status: {} };
} }
}
console.log(`searchWithinUserLists: Found ${foundProfiles.length} matching profiles in user lists with fuzzy matching`);
return foundProfiles;
} }
/** /**
* Search for NIP-05 addresses across common domains * Search for NIP-05 addresses across common domains
*/ */
async function searchNip05Domains( async function searchNip05Domains(searchTerm: string): Promise<NostrProfile[]> {
searchTerm: string,
): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = []; const foundProfiles: NostrProfile[] = [];
// Enhanced list of common domains for NIP-05 lookups // Enhanced list of common domains for NIP-05 lookups
@ -180,33 +460,25 @@ async function searchNip05Domains(
try { try {
const npub = await getNpubFromNip05(gitcitadelAddress); const npub = await getNpubFromNip05(gitcitadelAddress);
if (npub) { if (npub) {
console.log( console.log("NIP-05 search: SUCCESS! found npub for gitcitadel.com:", npub);
"NIP-05 search: SUCCESS! found npub for gitcitadel.com:",
npub,
);
const metadata = await getUserMetadata(npub); const metadata = await getUserMetadata(npub);
const profile: NostrProfile = { const profile: NostrProfile = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
}; };
console.log( console.log("NIP-05 search: created profile for gitcitadel.com:", profile);
"NIP-05 search: created profile for gitcitadel.com:",
profile,
);
foundProfiles.push(profile); foundProfiles.push(profile);
return foundProfiles; // Return immediately if we found it on gitcitadel.com return foundProfiles; // Return immediately if we found it on gitcitadel.com
} else { } else {
console.log("NIP-05 search: no npub found for gitcitadel.com"); console.log("NIP-05 search: no npub found for gitcitadel.com");
} }
} catch (e) { } catch (error) {
console.log("NIP-05 search: error for gitcitadel.com:", e); console.log("NIP-05 search: error for gitcitadel.com:", error);
} }
// If gitcitadel.com didn't work, try other domains // If gitcitadel.com didn't work, try other domains
console.log("NIP-05 search: gitcitadel.com failed, trying other domains..."); console.log("NIP-05 search: gitcitadel.com failed, trying other domains...");
const otherDomains = commonDomains.filter( const otherDomains = commonDomains.filter(domain => domain !== "gitcitadel.com");
(domain) => domain !== "gitcitadel.com",
);
// Search all other domains in parallel with timeout // Search all other domains in parallel with timeout
const searchPromises = otherDomains.map(async (domain) => { const searchPromises = otherDomains.map(async (domain) => {
@ -221,18 +493,13 @@ async function searchNip05Domains(
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
}; };
console.log( console.log("NIP-05 search: created profile for", nip05Address, ":", profile);
"NIP-05 search: created profile for",
nip05Address,
":",
profile,
);
return profile; return profile;
} else { } else {
console.log("NIP-05 search: no npub found for", nip05Address); console.log("NIP-05 search: no npub found for", nip05Address);
} }
} catch (e) { } catch (error) {
console.log("NIP-05 search: error for", nip05Address, ":", e); console.log("NIP-05 search: error for", nip05Address, ":", error);
// Continue to next domain // Continue to next domain
} }
return null; return null;
@ -251,39 +518,57 @@ async function searchNip05Domains(
return foundProfiles; return foundProfiles;
} }
/**
* Get all available relay URLs for comprehensive search
*/
function getAllRelayUrls(): string[] {
const userInboxRelays = get(activeInboxRelays);
const userOutboxRelays = get(activeOutboxRelays);
// AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive profile search coverage
// This includes all relays from consts.ts, user's personal relays, and local relays
const allRelayUrls = [
...searchRelays, // Dedicated profile search relays
...communityRelays, // Community relays
...secondaryRelays, // Secondary relays
...localRelays, // Local relays
...userInboxRelays, // User's personal inbox relays
...userOutboxRelays // User's personal outbox relays
];
// Deduplicate relay URLs
return [...new Set(allRelayUrls)];
}
/** /**
* Quick relay search with short timeout * Quick relay search with short timeout
*/ */
async function quickRelaySearch( async function quickRelaySearch(searchTerm: string, ndk: NDK): Promise<NostrProfile[]> {
searchTerm: string,
ndk: NDK,
): Promise<NostrProfile[]> {
console.log("quickRelaySearch called with:", searchTerm); console.log("quickRelaySearch called with:", searchTerm);
// Normalize the search term for relay search // Normalize the search term for relay search
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("Normalized search term for relay search:", normalizedSearchTerm); console.log("Normalized search term for relay search:", normalizedSearchTerm);
// Use search relays (optimized for profiles) + user's inbox relays + community relays const uniqueRelayUrls = getAllRelayUrls();
const userInboxRelays = get(activeInboxRelays); console.log("Using ALL available relays for profile search:", uniqueRelayUrls);
const quickRelayUrls = [ console.log("Relay breakdown:", {
...searchRelays, // Dedicated profile search relays searchRelays: searchRelays.length,
...userInboxRelays, // User's personal inbox relays communityRelays: communityRelays.length,
...communityRelays, // Community relays secondaryRelays: secondaryRelays.length,
...secondaryRelays // Secondary relays as fallback localRelays: localRelays.length,
]; userInboxRelays: get(activeInboxRelays).length,
userOutboxRelays: get(activeOutboxRelays).length,
// Deduplicate relay URLs totalUnique: uniqueRelayUrls.length
const uniqueRelayUrls = [...new Set(quickRelayUrls)]; });
console.log("Using relays for profile search:", uniqueRelayUrls);
// Create relay sets for parallel search // Create relay sets for parallel search
const relaySets = uniqueRelayUrls const relaySets = uniqueRelayUrls
.map((url) => { .map((url) => {
try { try {
return NDKRelaySet.fromRelayUrls([url], ndk); return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (e) { } catch (error) {
console.warn(`Failed to create relay set for ${url}:`, e); console.warn(`Failed to create relay set for ${url}:`, error);
return null; return null;
} }
}) })
@ -297,9 +582,7 @@ async function quickRelaySearch(
const foundInRelay: NostrProfile[] = []; const foundInRelay: NostrProfile[] = [];
let eventCount = 0; let eventCount = 0;
console.log( console.log(`Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`);
`Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`,
);
const sub = ndk.subscribe( const sub = ndk.subscribe(
{ kinds: [0] }, { kinds: [0] },
@ -312,22 +595,15 @@ async function quickRelaySearch(
try { try {
if (!event.content) return; if (!event.content) return;
const profileData = JSON.parse(event.content); const profileData = JSON.parse(event.content);
const displayName = const displayName = profileData.displayName || profileData.display_name || "";
profileData.displayName || profileData.display_name || "";
const display_name = profileData.display_name || ""; const display_name = profileData.display_name || "";
const name = profileData.name || ""; const name = profileData.name || "";
const nip05 = profileData.nip05 || ""; const nip05 = profileData.nip05 || "";
const about = profileData.about || ""; const about = profileData.about || "";
// Check if any field matches the search term using normalized comparison // Check if any field matches the search term using exact field matching only
const matchesDisplayName = fieldMatches( const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
displayName, const matchesDisplay_name = fieldMatches(display_name, normalizedSearchTerm);
normalizedSearchTerm,
);
const matchesDisplay_name = fieldMatches(
display_name,
normalizedSearchTerm,
);
const matchesName = fieldMatches(name, normalizedSearchTerm); const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm); const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm); const matchesAbout = fieldMatches(about, normalizedSearchTerm);
@ -375,7 +651,7 @@ async function quickRelaySearch(
); );
sub.stop(); sub.stop();
resolve(foundInRelay); resolve(foundInRelay);
}, 1500); // 1.5 second timeout per relay }, TIMEOUTS.RELAY_TIMEOUT);
}); });
}); });
@ -395,8 +671,211 @@ async function quickRelaySearch(
} }
} }
console.log( console.log(`Total unique profiles found: ${Object.keys(allProfiles).length}`);
`Total unique profiles found: ${Object.keys(allProfiles).length}`,
);
return Object.values(allProfiles); return Object.values(allProfiles);
} }
/**
* Add user list information to profiles and prioritize them
*/
function prioritizeProfiles(profiles: NostrProfile[], userLists: any[]): NostrProfile[] {
return profiles.map(profile => {
if (profile.pubkey) {
const inLists = isPubkeyInUserLists(profile.pubkey, userLists);
const listKinds = getListKindsForPubkey(profile.pubkey, userLists);
return {
...profile,
isInUserLists: inLists,
listKinds: listKinds,
};
}
return profile;
}).sort((a, b) => {
const aInLists = a.isInUserLists || false;
const bInLists = b.isInUserLists || false;
if (aInLists && !bInLists) return -1;
if (!aInLists && bInLists) return 1;
// If both are in lists, prioritize by list kind (follows first)
if (aInLists && bInLists && a.listKinds && b.listKinds) {
const aHasFollows = a.listKinds.includes(3);
const bHasFollows = b.listKinds.includes(3);
if (aHasFollows && !bHasFollows) return -1;
if (!aHasFollows && bHasFollows) return 1;
}
return 0;
});
}
/**
* Cache search results
*/
function cacheSearchResults(profiles: NostrProfile[], searchTerm: string, ndk: NDK): void {
if (profiles.length > 0) {
const events = profiles.map((profile) => {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = profile.pubkey || "";
// AI-NOTE: 2025-01-24 - Preserve timestamp for proper date display
if (profile.created_at) {
event.created_at = profile.created_at;
}
return event;
});
const result = {
events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: "profile",
searchTerm: searchTerm,
};
searchCache.set("profile", searchTerm, result);
}
}
/**
* Get cached search results
*/
function getCachedResults(searchTerm: string): NostrProfile[] | null {
const cachedResult = searchCache.get("profile", searchTerm);
if (cachedResult) {
console.log("Found cached result for:", searchTerm);
const profiles = cachedResult.events
.map((event) => {
try {
const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData);
} catch {
return null;
}
})
.filter(Boolean) as NostrProfile[];
console.log("Cached profiles found:", profiles.length);
return profiles;
}
return null;
}
/**
* Execute search strategy based on search term type
*/
async function executeSearchStrategy(
strategy: SearchStrategy,
searchTerm: string,
ndk: NDK,
userLists: any[],
): Promise<NostrProfile[]> {
switch (strategy) {
case 'npub':
return await searchByNostrIdentifier(searchTerm, ndk);
case 'nip05':
return await searchByNip05Address(searchTerm);
case 'userLists':
const foundProfiles: NostrProfile[] = [];
// First, search within user's lists for exact matches
if (userLists.length > 0) {
console.log("Searching within user's lists first for:", searchTerm);
const listMatches = await searchWithinUserLists(searchTerm, userLists, ndk);
foundProfiles.push(...listMatches);
console.log("User list search completed, found:", listMatches.length, "profiles");
}
// If we found enough matches in user lists, return them
if (foundProfiles.length >= 5) {
console.log("Found sufficient matches in user lists, skipping other searches");
return foundProfiles;
}
// Try NIP-05 search (faster than relay search)
console.log("Starting NIP-05 search for:", searchTerm);
const nip05Profiles = await searchNip05Domains(searchTerm);
console.log("NIP-05 search completed, found:", nip05Profiles.length, "profiles");
foundProfiles.push(...nip05Profiles);
// If still not enough results, try quick relay search
if (foundProfiles.length < 10) {
console.log("Not enough results, trying quick relay search");
const relayProfiles = await quickRelaySearch(searchTerm, ndk);
console.log("Quick relay search completed, found:", relayProfiles.length, "profiles");
foundProfiles.push(...relayProfiles);
}
// AI-NOTE: 2025-01-24 - Limit results to prevent overwhelming the UI
// For profile searches, we want quality over quantity
if (foundProfiles.length > SEARCH_LIMITS.MAX_PROFILE_RESULTS) {
console.log(`Limiting results from ${foundProfiles.length} to ${SEARCH_LIMITS.MAX_PROFILE_RESULTS} most relevant profiles`);
return foundProfiles.slice(0, SEARCH_LIMITS.MAX_PROFILE_RESULTS);
}
return foundProfiles;
default:
return [];
}
}
/**
* Search for profiles by various criteria (display name, name, NIP-05, npub)
* Prioritizes profiles from user's lists (follows, etc.)
*/
export async function searchProfiles(searchTerm: string): Promise<ProfileSearchResult> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("searchProfiles called with:", searchTerm, "normalized:", normalizedSearchTerm);
// Check cache first
const cachedProfiles = getCachedResults(normalizedSearchTerm);
if (cachedProfiles) {
return { profiles: cachedProfiles, Status: {} };
}
// Get user lists with stale-while-revalidate caching
let userLists: any[] = [];
let userPubkeys: Set<string> = new Set();
try {
const userListResult = await getUserListsWithCache();
userLists = userListResult.lists;
userPubkeys = userListResult.pubkeys;
console.log(`searchProfiles: Using user lists - ${userLists.length} lists with ${userPubkeys.size} unique pubkeys`);
} catch (error) {
console.warn("searchProfiles: Failed to get user lists:", error);
}
// Wait for NDK to be properly initialized
const ndk = await waitForNdk();
console.log("profile_search: NDK initialized, starting search logic");
try {
// Determine search strategy
const strategy = determineSearchStrategy(normalizedSearchTerm);
console.log("profile_search: Using search strategy:", strategy);
// Execute search strategy
const foundProfiles = await executeSearchStrategy(strategy, normalizedSearchTerm, ndk, userLists);
// Cache the results
cacheSearchResults(foundProfiles, normalizedSearchTerm, ndk);
// Add user list information to profiles and prioritize them
const prioritizedProfiles = prioritizeProfiles(foundProfiles, userLists);
console.log("Search completed, found profiles:", foundProfiles.length);
console.log("Prioritized profiles - follows first:", prioritizedProfiles.length);
return { profiles: prioritizedProfiles, Status: {} };
} catch (error) {
console.error("Error searching profiles:", error);
return { profiles: [], Status: {} };
}
}

9
src/lib/utils/search_constants.ts

@ -27,6 +27,9 @@ export const TIMEOUTS = {
/** Cache cleanup interval */ /** Cache cleanup interval */
CACHE_CLEANUP: 60000, CACHE_CLEANUP: 60000,
/** Timeout for relay search operations */
RELAY_TIMEOUT: 1500, // 1.5 seconds for quick relay searches
} as const; } as const;
// Cache duration constants (in milliseconds) // Cache duration constants (in milliseconds)
@ -54,6 +57,12 @@ export const SEARCH_LIMITS = {
/** Limit for second-order search results */ /** Limit for second-order search results */
SECOND_ORDER_RESULTS: 100, SECOND_ORDER_RESULTS: 100,
/** Maximum results for profile searches */
MAX_PROFILE_RESULTS: 20,
/** Batch size for profile fetching operations */
BATCH_SIZE: 50,
} as const; } as const;
// Nostr event kind ranges // Nostr event kind ranges

3
src/lib/utils/search_types.ts

@ -27,6 +27,9 @@ export interface NostrProfile {
website?: string; website?: string;
lud16?: string; lud16?: string;
pubkey?: string; pubkey?: string;
isInUserLists?: boolean;
listKinds?: number[];
created_at?: number; // AI-NOTE: 2025-01-24 - Timestamp for proper date display
} }
/** /**

3
src/lib/utils/search_utils.ts

@ -106,5 +106,8 @@ export function createProfileFromEvent(event: NDKEvent, profileData: any): any {
website: profileData.website, website: profileData.website,
lud16: profileData.lud16, lud16: profileData.lud16,
pubkey: event.pubkey, pubkey: event.pubkey,
created_at: event.created_at, // AI-NOTE: 2025-01-24 - Preserve timestamp for proper date display
isInUserLists: profileData.isInUserLists, // AI-NOTE: 2025-01-24 - Preserve user list information
listKinds: profileData.listKinds, // AI-NOTE: 2025-01-24 - Preserve list kinds information
}; };
} }

556
src/lib/utils/subscription_search.ts

@ -1,10 +1,10 @@
// deno-lint-ignore-file no-explicit-any // deno-lint-ignore-file no-explicit-any
import { ndkInstance } from "../ndk.ts"; import { ndkInstance } from "../ndk.ts";
import { getMatchingTags, getNpubFromNip05 } from "./nostrUtils.ts"; import { getMatchingTags } from "./nostrUtils.ts";
import { nip19 } from "./nostrUtils.ts"; import { nip19 } from "./nostrUtils.ts";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "./searchCache.ts"; import { searchCache } from "./searchCache.ts";
import { communityRelays, searchRelays } from "../consts.ts"; import { communityRelays, searchRelays, secondaryRelays, localRelays } from "../consts.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { import type {
SearchResult, SearchResult,
@ -15,11 +15,12 @@ import type {
import { import {
fieldMatches, fieldMatches,
nip05Matches, nip05Matches,
COMMON_DOMAINS,
isEmojiReaction, isEmojiReaction,
} from "./search_utils.ts"; } from "./search_utils.ts";
import { TIMEOUTS, SEARCH_LIMITS } from "./search_constants.ts"; import { TIMEOUTS, SEARCH_LIMITS } from "./search_constants.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
// AI-NOTE: 2025-01-24 - User list functionality is now handled by centralized searchProfiles function
import { searchProfiles } from "./profile_search.ts";
// Helper function to normalize URLs for comparison // Helper function to normalize URLs for comparison
const normalizeUrl = (url: string): string => { const normalizeUrl = (url: string): string => {
@ -55,19 +56,105 @@ export async function searchBySubscription(
normalizedSearchTerm, normalizedSearchTerm,
}); });
// Check cache first // For profile searches (n:), use the centralized searchProfiles function
if (searchType === "n") {
console.log("subscription_search: Using centralized searchProfiles for profile search");
try {
const profileResult = await searchProfiles(searchTerm);
// Get NDK instance for creating events
const ndk = get(ndkInstance);
if (!ndk) {
console.error("subscription_search: NDK not initialized for profile search");
throw new Error("NDK not initialized");
}
// Convert profile results to NDK events for compatibility
const events = await Promise.all(profileResult.profiles.map(async (profile) => {
const event = new NDKEvent(ndk);
// AI-NOTE: 2025-01-24 - Clean profile data before JSON serialization to prevent corruption
const cleanProfile = {
name: profile.name,
displayName: profile.displayName,
display_name: profile.displayName, // AI-NOTE: 2025-01-24 - Use displayName for both fields for compatibility
nip05: profile.nip05,
picture: profile.picture,
about: profile.about,
banner: profile.banner,
website: profile.website,
lud16: profile.lud16,
pubkey: profile.pubkey,
created_at: profile.created_at,
isInUserLists: profile.isInUserLists,
listKinds: profile.listKinds,
};
event.content = JSON.stringify(cleanProfile);
event.pubkey = profile.pubkey || "";
event.kind = 0;
// AI-NOTE: 2025-01-24 - Preserve timestamp for proper date display
if (profile.created_at) {
event.created_at = profile.created_at;
}
// AI-NOTE: 2025-01-24 - Generate a proper ID for the event
// This is required for the event details panel to work correctly
if (profile.pubkey) {
// Create a deterministic ID based on pubkey and kind
const idData = new TextEncoder().encode(`${profile.pubkey}:0:${profile.created_at || Date.now()}`);
const hashBuffer = await crypto.subtle.digest('SHA-256', idData);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const eventId = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
event.id = eventId;
}
// AI-NOTE: 2025-01-24 - Attach profile data directly to event for easy access
// This ensures user list information is preserved
(event as any).profileData = cleanProfile;
return event;
}));
const result = {
events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: "n",
searchTerm: normalizedSearchTerm,
};
// AI-NOTE: 2025-01-24 - Perform second-order search for n: searches
// This finds events that mention the found profiles
if (events.length > 0) {
performSecondOrderSearchInBackground(
"n",
events,
new Set<string>(),
new Set<string>(),
events[0].pubkey, // Use first profile's pubkey for second-order search
callbacks,
);
}
// Cache the result
searchCache.set(searchType, normalizedSearchTerm, result);
return result;
} catch (error) {
console.error("subscription_search: Error in centralized profile search:", error);
throw error;
}
}
// Check cache first for other search types
const cachedResult = searchCache.get(searchType, normalizedSearchTerm); const cachedResult = searchCache.get(searchType, normalizedSearchTerm);
if (cachedResult) { if (cachedResult) {
console.log("subscription_search: Found cached result:", cachedResult); console.log("subscription_search: Found cached result:", cachedResult);
// AI-NOTE: 2025-01-24 - For profile searches, return cached results immediately
// The EventSearch component now handles cache checking before calling this function
if (searchType === "n") {
console.log("subscription_search: Returning cached profile result immediately");
return cachedResult;
} else {
return cachedResult; return cachedResult;
} }
}
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
@ -79,6 +166,9 @@ export async function searchBySubscription(
const searchState = createSearchState(); const searchState = createSearchState();
const cleanup = createCleanupFunction(searchState); const cleanup = createCleanupFunction(searchState);
// User lists are now handled by the centralized searchProfiles function
// No need to fetch them here for n: searches
// Set a timeout to force completion after subscription search timeout // Set a timeout to force completion after subscription search timeout
searchState.timeoutId = setTimeout(() => { searchState.timeoutId = setTimeout(() => {
console.log("subscription_search: Search timeout reached"); console.log("subscription_search: Search timeout reached");
@ -144,26 +234,7 @@ export async function searchBySubscription(
); );
searchCache.set(searchType, normalizedSearchTerm, immediateResult); searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// AI-NOTE: 2025-01-08 - For profile searches, return immediately when found // Start Phase 2 in background for additional results
// but still start background search for second-order results
if (searchType === "n") {
console.log("subscription_search: Profile found, returning immediately but starting background second-order search");
// Start Phase 2 in background for second-order results
searchOtherRelaysInBackground(
searchType,
searchFilter,
searchState,
callbacks,
cleanup,
);
const elapsed = Date.now() - startTime;
console.log(`subscription_search: Profile search completed in ${elapsed}ms`);
return immediateResult;
}
// Start Phase 2 in background for additional results (only for non-profile searches)
searchOtherRelaysInBackground( searchOtherRelaysInBackground(
searchType, searchType,
searchFilter, searchFilter,
@ -178,70 +249,10 @@ export async function searchBySubscription(
"subscription_search: No results from primary relay", "subscription_search: No results from primary relay",
); );
// AI-NOTE: 2025-01-08 - For profile searches, if no results found in search relays,
// try all relays as fallback
if (searchType === "n") {
console.log(
"subscription_search: No profile found in search relays, trying all relays",
);
// Try with all relays as fallback
const allRelaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())) as any, ndk);
try {
const fallbackEvents = await ndk.fetchEvents(
searchFilter.filter,
{ closeOnEose: true },
allRelaySet,
);
console.log(
"subscription_search: Fallback search returned",
fallbackEvents.size,
"events",
);
processPrimaryRelayResults(
fallbackEvents,
searchType,
searchFilter.subscriptionType,
normalizedSearchTerm,
searchState,
abortSignal,
cleanup,
);
if (hasResults(searchState, searchType)) {
console.log(
"subscription_search: Found profile in fallback search, returning immediately",
);
const fallbackResult = createSearchResult(
searchState,
searchType,
normalizedSearchTerm,
);
searchCache.set(searchType, normalizedSearchTerm, fallbackResult);
const elapsed = Date.now() - startTime;
console.log(`subscription_search: Profile search completed in ${elapsed}ms (fallback)`);
return fallbackResult;
}
} catch (fallbackError) {
console.error("subscription_search: Fallback search failed:", fallbackError);
}
console.log(
"subscription_search: Profile not found in any relays, returning empty result",
);
const emptyResult = createEmptySearchResult(searchType, normalizedSearchTerm);
// AI-NOTE: 2025-01-08 - Don't cache empty profile results as they may be due to search issues
// rather than the profile not existing
const elapsed = Date.now() - startTime;
console.log(`subscription_search: Profile search completed in ${elapsed}ms (not found)`);
return emptyResult;
} else {
console.log( console.log(
"subscription_search: No results from primary relay, continuing to Phase 2", "subscription_search: No results from primary relay, continuing to Phase 2",
); );
} }
}
} catch (error) { } catch (error) {
console.error( console.error(
`subscription_search: Error searching primary relay:`, `subscription_search: Error searching primary relay:`,
@ -263,11 +274,9 @@ export async function searchBySubscription(
cleanup, cleanup,
); );
// AI-NOTE: 2025-01-08 - Log performance for non-profile searches // Log performance for all searches
if (searchType !== "n") {
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
console.log(`subscription_search: ${searchType} search completed in ${elapsed}ms`); console.log(`subscription_search: ${searchType} search completed in ${elapsed}ms`);
}
return result; return result;
} }
@ -283,9 +292,10 @@ function createSearchState() {
tTagEvents: [] as NDKEvent[], tTagEvents: [] as NDKEvent[],
eventIds: new Set<string>(), eventIds: new Set<string>(),
eventAddresses: new Set<string>(), eventAddresses: new Set<string>(),
foundProfiles: [] as NDKEvent[], // AI-NOTE: 2025-01-24 - foundProfiles removed as profile searches are now handled by centralized searchProfiles function
isCompleted: false, isCompleted: false,
currentSubscription: null as any, currentSubscription: null as any,
// AI-NOTE: 2025-01-24 - userLists and userPubkeys removed as they're now handled by centralized searchProfiles function
}; };
} }
@ -351,51 +361,14 @@ async function createSearchFilter(
/** /**
* Create profile search filter * Create profile search filter
* AI-NOTE: 2025-01-24 - This function is now redundant as profile searches are handled by centralized searchProfiles function
* Keeping minimal implementation for compatibility with existing code structure
*/ */
async function createProfileSearchFilter( async function createProfileSearchFilter(
normalizedSearchTerm: string, normalizedSearchTerm: string,
): Promise<SearchFilter> { ): Promise<SearchFilter> {
// For npub searches, try to decode the search term first // AI-NOTE: 2025-01-24 - Profile search logic is now centralized in profile_search.ts
try { // This function is kept for compatibility but profile searches should go through searchProfiles function
const decoded = nip19.decode(normalizedSearchTerm);
if (decoded && decoded.type === "npub") {
return {
filter: {
kinds: [0],
authors: [decoded.data],
limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search
},
subscriptionType: "npub-specific",
};
}
} catch {
// Not a valid npub, continue with other strategies
}
// Try NIP-05 lookup first
try {
for (const domain of COMMON_DOMAINS) {
const nip05Address = `${normalizedSearchTerm}@${domain}`;
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
return {
filter: {
kinds: [0],
authors: [npub],
limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search
},
subscriptionType: "nip05-found",
};
}
} catch {
// Continue to next domain
}
}
} catch {
// Fallback to reasonable profile search
}
return { return {
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE }, filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE },
subscriptionType: "profile", subscriptionType: "profile",
@ -480,12 +453,8 @@ function processPrimaryRelayResults(
try { try {
if (searchType === "n") { if (searchType === "n") {
processProfileEvent( // AI-NOTE: 2025-01-24 - Profile processing is now handled by centralized searchProfiles function
event, // No need to process profile events here as they're handled in profile_search.ts
subscriptionType,
normalizedSearchTerm,
searchState,
);
} else { } else {
processContentEvent(event, searchType, searchState); processContentEvent(event, searchType, searchState);
} }
@ -498,64 +467,11 @@ function processPrimaryRelayResults(
console.log( console.log(
"subscription_search: Processed events - firstOrder:", "subscription_search: Processed events - firstOrder:",
searchState.firstOrderEvents.length, searchState.firstOrderEvents.length,
"profiles:",
searchState.foundProfiles.length,
"tTag:", "tTag:",
searchState.tTagEvents.length, searchState.tTagEvents.length,
); );
} }
/**
* Process profile event
*/
function processProfileEvent(
event: NDKEvent,
subscriptionType: string,
normalizedSearchTerm: string,
searchState: any,
) {
if (!event.content) return;
// If this is a specific npub search or NIP-05 found search, include all matching events
if (
subscriptionType === "npub-specific" ||
subscriptionType === "nip05-found"
) {
searchState.foundProfiles.push(event);
return;
}
// For general profile searches, filter by content
const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || "";
const name = profileData.name || "";
const nip05 = profileData.nip05 || "";
const username = profileData.username || "";
const about = profileData.about || "";
const bio = profileData.bio || "";
const description = profileData.description || "";
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesUsername = fieldMatches(username, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
const matchesBio = fieldMatches(bio, normalizedSearchTerm);
const matchesDescription = fieldMatches(description, normalizedSearchTerm);
if (
matchesDisplayName ||
matchesName ||
matchesNip05 ||
matchesUsername ||
matchesAbout ||
matchesBio ||
matchesDescription
) {
searchState.foundProfiles.push(event);
}
}
/** /**
* Process content event * Process content event
*/ */
@ -601,9 +517,8 @@ function hasResults(
searchState: any, searchState: any,
searchType: SearchSubscriptionType, searchType: SearchSubscriptionType,
): boolean { ): boolean {
if (searchType === "n") { // AI-NOTE: 2025-01-24 - Profile searches (n:) are now handled by centralized searchProfiles function
return searchState.foundProfiles.length > 0; if (searchType === "d") {
} else if (searchType === "d") {
return searchState.firstOrderEvents.length > 0; return searchState.firstOrderEvents.length > 0;
} else if (searchType === "t") { } else if (searchType === "t") {
return searchState.tTagEvents.length > 0; return searchState.tTagEvents.length > 0;
@ -619,13 +534,17 @@ function createSearchResult(
searchType: SearchSubscriptionType, searchType: SearchSubscriptionType,
normalizedSearchTerm: string, normalizedSearchTerm: string,
): SearchResult { ): SearchResult {
let events;
// AI-NOTE: 2025-01-24 - Profile searches (n:) are now handled by centralized searchProfiles function
if (searchType === "t") {
events = searchState.tTagEvents;
} else {
events = searchState.firstOrderEvents;
}
return { return {
events: events,
searchType === "n"
? searchState.foundProfiles
: searchType === "t"
? searchState.tTagEvents
: searchState.firstOrderEvents,
secondOrder: [], secondOrder: [],
tTagEvents: [], tTagEvents: [],
eventIds: searchState.eventIds, eventIds: searchState.eventIds,
@ -649,13 +568,14 @@ function searchOtherRelaysInBackground(
// AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive search coverage // AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive search coverage
// This ensures we don't miss events that might be on any available relay // This ensures we don't miss events that might be on any available relay
const allRelays = Array.from(ndk.pool.relays.values());
const otherRelays = new NDKRelaySet( const otherRelays = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values())), new Set(allRelays),
ndk, ndk,
); );
console.debug('subscription_search: Background search using ALL relays:', console.debug('subscription_search: Background search using ALL relays:',
Array.from(ndk.pool.relays.values()).map((r: any) => r.url)); allRelays.map((r: any) => r.url));
// Subscribe to events from other relays // Subscribe to events from other relays
const sub = ndk.subscribe( const sub = ndk.subscribe(
@ -675,12 +595,8 @@ function searchOtherRelaysInBackground(
sub.on("event", (event: NDKEvent) => { sub.on("event", (event: NDKEvent) => {
try { try {
if (searchType === "n") { if (searchType === "n") {
processProfileEvent( // AI-NOTE: 2025-01-24 - Profile processing is now handled by centralized searchProfiles function
event, // No need to process profile events here as they're handled in profile_search.ts
searchFilter.subscriptionType,
searchState.normalizedSearchTerm,
searchState,
);
} else { } else {
processContentEvent(event, searchType, searchState); processContentEvent(event, searchType, searchState);
} }
@ -713,92 +629,21 @@ function processEoseResults(
searchFilter: SearchFilter, searchFilter: SearchFilter,
callbacks?: SearchCallbacks, callbacks?: SearchCallbacks,
): SearchResult { ): SearchResult {
if (searchType === "n") { // AI-NOTE: 2025-01-24 - Profile searches (n:) are now handled by centralized searchProfiles function
return processProfileEoseResults(searchState, searchFilter, callbacks); if (searchType === "d") {
} else if (searchType === "d") {
return processContentEoseResults(searchState, searchType); return processContentEoseResults(searchState, searchType);
} else if (searchType === "t") { } else if (searchType === "t") {
return processTTagEoseResults(searchState); return processTTagEoseResults(searchState);
} else if (searchType === "n") {
// AI-NOTE: 2025-01-24 - n: searches are handled by centralized searchProfiles function
// Second-order search is performed in the main searchBySubscription function
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
} }
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm); return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
} }
/** // AI-NOTE: 2025-01-24 - processProfileEoseResults function removed as profile searches are now handled by centralized searchProfiles function
* Process profile EOSE results
*/
function processProfileEoseResults(
searchState: any,
searchFilter: SearchFilter,
callbacks?: SearchCallbacks,
): SearchResult {
if (searchState.foundProfiles.length === 0) {
return createEmptySearchResult("n", searchState.normalizedSearchTerm);
}
// Deduplicate by pubkey, keep only newest
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
for (const event of searchState.foundProfiles) {
const pubkey = event.pubkey;
const created_at = event.created_at || 0;
if (!deduped[pubkey] || deduped[pubkey].created_at < created_at) {
deduped[pubkey] = { event, created_at };
}
}
// Sort by creation time (newest first) and take only the most recent profiles
const dedupedProfiles = Object.values(deduped)
.sort((a, b) => b.created_at - a.created_at)
.map((x) => x.event);
// Perform second-order search for npub searches
if (
searchFilter.subscriptionType === "npub-specific" ||
searchFilter.subscriptionType === "nip05-found"
) {
const targetPubkey = dedupedProfiles[0]?.pubkey;
if (targetPubkey) {
console.log("subscription_search: Triggering second-order search for npub-specific profile:", targetPubkey);
performSecondOrderSearchInBackground(
"n",
dedupedProfiles,
new Set(),
new Set(),
targetPubkey,
callbacks,
);
} else {
console.log("subscription_search: No targetPubkey found for second-order search");
}
} else if (searchFilter.subscriptionType === "profile") {
// For general profile searches, perform second-order search for each found profile
for (const profile of dedupedProfiles) {
if (profile.pubkey) {
console.log("subscription_search: Triggering second-order search for general profile:", profile.pubkey);
performSecondOrderSearchInBackground(
"n",
dedupedProfiles,
new Set(),
new Set(),
profile.pubkey,
callbacks,
);
}
}
} else {
console.log("subscription_search: No second-order search triggered for subscription type:", searchFilter.subscriptionType);
}
return {
events: dedupedProfiles,
secondOrder: [],
tTagEvents: [],
eventIds: new Set(dedupedProfiles.map((p) => p.id)),
addresses: new Set(),
searchType: "n",
searchTerm: searchState.normalizedSearchTerm,
};
}
/** /**
* Process content EOSE results * Process content EOSE results
@ -855,6 +700,37 @@ function processTTagEoseResults(searchState: any): SearchResult {
return createEmptySearchResult("t", searchState.normalizedSearchTerm); return createEmptySearchResult("t", searchState.normalizedSearchTerm);
} }
// AI-NOTE: 2025-01-24 - Perform second-order search for t-tag searches
// This finds events that reference the t-tag events
if (searchState.tTagEvents.length > 0) {
// Collect event IDs and addresses from t-tag events for second-order search
const eventIds = new Set<string>();
const addresses = new Set<string>();
for (const event of searchState.tTagEvents) {
if (event.id) {
eventIds.add(event.id);
}
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
tags.forEach((tag: string[]) => {
if (tag[1]) {
addresses.add(tag[1]);
}
});
}
performSecondOrderSearchInBackground(
"d", // Use "d" type for second-order search since t-tag events are addressable
searchState.tTagEvents,
eventIds,
addresses,
);
}
return { return {
events: searchState.tTagEvents, events: searchState.tTagEvents,
secondOrder: [], secondOrder: [],
@ -924,38 +800,92 @@ async function performSecondOrderSearchInBackground(
console.log("subscription_search: Using", activeRelays.length, "active relays for second-order search"); console.log("subscription_search: Using", activeRelays.length, "active relays for second-order search");
// Search for events that mention this pubkey via p-tags // AI-NOTE: 2025-01-24 - Search for events that mention this pubkey in various tags
const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging // Focus on events that are actually about the profile, not just random mentions
const pTagEvents = await ndk.fetchEvents( const searchFilters = [
pTagFilter, { authors: [targetPubkey], limit: 50 }, // Events written by this pubkey (most relevant)
{ closeOnEose: true }, { "#p": [targetPubkey], limit: 25 }, // p-tags (mentions) - reduced limit
relaySet, { "#q": [targetPubkey], limit: 25 }, // q-tags (quotes) - reduced limit
); ];
console.log("subscription_search: Found", pTagEvents.size, "events with p-tag for", targetPubkey);
// AI-NOTE: 2025-01-24 - Also search for events written by this pubkey with limit const searchPromises = searchFilters.map(async (filter, index) => {
const authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging const filterName = index === 0 ? "author" : index === 1 ? "p-tag" : "q-tag";
const authorEvents = await ndk.fetchEvents( try {
authorFilter, const events = await ndk.fetchEvents(filter, { closeOnEose: true }, relaySet);
{ closeOnEose: true }, console.log(`subscription_search: Found ${events.size} events with ${filterName} for ${targetPubkey}`);
relaySet, return filterUnwantedEvents(Array.from(events));
); } catch (error) {
console.log("subscription_search: Found", authorEvents.size, "events written by", targetPubkey); console.warn(`subscription_search: Error searching ${filterName}:`, error);
return [];
}
});
const searchResults = await Promise.allSettled(searchPromises);
// Combine all results, prioritizing author events
for (const result of searchResults) {
if (result.status === "fulfilled") {
allSecondOrderEvents.push(...result.value);
}
}
// Filter out unwanted events from both sets // AI-NOTE: 2025-01-24 - Filter events to ensure they're relevant to the profile
const filteredPTagEvents = filterUnwantedEvents(Array.from(pTagEvents)); // Remove events that are just random mentions without meaningful content
const filteredAuthorEvents = filterUnwantedEvents(Array.from(authorEvents)); const relevantEvents = allSecondOrderEvents.filter(event => {
// Always include events written by the target pubkey
if (event.pubkey === targetPubkey) {
return true;
}
// For events that mention the pubkey, check if they have meaningful content
if (event.content && event.content.trim().length > 0) {
// Include events with substantial content (more than just a mention)
return event.content.trim().length > 10;
}
// Include events with tags that suggest they're about the profile
const pTags = getMatchingTags(event, "p");
const qTags = getMatchingTags(event, "q");
if (pTags.length > 0 || qTags.length > 0) {
return true;
}
return false;
});
// AI-NOTE: 2025-01-24 - Further filter to prioritize events that are actually about the profile
// This helps reduce noise from random mentions
const highQualityEvents = relevantEvents.filter(event => {
// Always keep events written by the target pubkey
if (event.pubkey === targetPubkey) {
return true;
}
console.log("subscription_search: After filtering unwanted events:", filteredPTagEvents.length, "p-tag events,", filteredAuthorEvents.length, "author events"); // For events by others, require more substantial content
if (event.content && event.content.trim().length > 0) {
const content = event.content.toLowerCase();
// Prefer events that mention the profile name or have substantial discussion
return content.length > 20; // Require more substantial content
}
// Combine both sets of events // Keep events with relevant tags but limit them
allSecondOrderEvents = [...filteredPTagEvents, ...filteredAuthorEvents]; const pTags = getMatchingTags(event, "p");
const qTags = getMatchingTags(event, "q");
return pTags.length > 0 || qTags.length > 0;
});
allSecondOrderEvents = highQualityEvents;
console.log("subscription_search: Total relevant second-order events found:", allSecondOrderEvents.length);
} else if (searchType === "d") { } else if (searchType === "d") {
// Parallel fetch for #e and #a tag events // Parallel fetch for #e and #a tag events
const allRelays = Array.from(ndk.pool.relays.values());
const relaySet = new NDKRelaySet( const relaySet = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values())), new Set(allRelays),
ndk, ndk,
); );
console.debug('subscription_search: Second-order search using ALL relays:',
allRelays.map((r: any) => r.url));
const [eTagEvents, aTagEvents] = await Promise.all([ const [eTagEvents, aTagEvents] = await Promise.all([
eventIds.size > 0 eventIds.size > 0
? ndk.fetchEvents( ? ndk.fetchEvents(

253
src/lib/utils/user_lists.ts

@ -0,0 +1,253 @@
import { ndkInstance, activeInboxRelays } from "../ndk.ts";
import { get } from "svelte/store";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { userStore } from "../stores/userStore.ts";
import { nip19 } from "nostr-tools";
import { npubCache } from "./npubCache.ts";
/**
* NIP-51 List kinds for user lists
* @see https://github.com/nostr-protocol/nips/blob/master/51.md
*/
export const NIP51_LIST_KINDS = {
FOLLOWS: 3, // Follow list
MUTED: 10000, // Mute list
PINNED: 10001, // Pin list
RELAYS: 10002, // Relay list
PEOPLE: 30000, // Categorized people list
BOOKMARKS: 30001, // Categorized bookmark list
COMMUNITIES: 34550, // Community definition
STARTER_PACKS: 39089, // Starter packs
MEDIA_STARTER_PACKS: 39092, // Media starter packs
} as const;
/**
* Get all list kinds that contain people (npubs)
*/
export const PEOPLE_LIST_KINDS = [
NIP51_LIST_KINDS.FOLLOWS,
NIP51_LIST_KINDS.PEOPLE,
NIP51_LIST_KINDS.STARTER_PACKS,
NIP51_LIST_KINDS.MEDIA_STARTER_PACKS,
] as const;
/**
* Interface for a user list event
*/
export interface UserListEvent {
event: NDKEvent;
kind: number;
pubkeys: string[];
listName?: string;
listDescription?: string;
}
/**
* Fetch all user lists for a given pubkey
* @param pubkey - The pubkey to fetch lists for
* @param listKinds - Array of list kinds to fetch (defaults to all people list kinds)
* @returns Promise that resolves to an array of UserListEvent objects
*/
export async function fetchUserLists(
pubkey: string,
listKinds: number[] = [...PEOPLE_LIST_KINDS]
): Promise<UserListEvent[]> {
const ndk = get(ndkInstance);
if (!ndk) {
console.warn("fetchUserLists: No NDK instance available");
return [];
}
console.log(`fetchUserLists: Fetching lists for ${pubkey}, kinds:`, listKinds);
try {
const events = await ndk.fetchEvents({
kinds: listKinds,
authors: [pubkey],
});
const userLists: UserListEvent[] = [];
for (const event of events) {
const pubkeys: string[] = [];
// Extract pubkeys from p-tags
event.tags.forEach(tag => {
if (tag[0] === 'p' && tag[1]) {
pubkeys.push(tag[1]);
}
});
// Extract list metadata from content if available
let listName: string | undefined;
let listDescription: string | undefined;
if (event.content) {
try {
const content = JSON.parse(event.content);
listName = content.name || content.title;
listDescription = content.description;
} catch {
// Content is not JSON, ignore
}
}
// Get list name from d-tag if available (for addressable lists)
if (!listName && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.getMatchingTags('d')[0]?.[1];
if (dTag) {
listName = dTag;
}
}
userLists.push({
event,
kind: event.kind,
pubkeys,
listName,
listDescription,
});
}
console.log(`fetchUserLists: Found ${userLists.length} lists with ${userLists.reduce((sum, list) => sum + list.pubkeys.length, 0)} total pubkeys`);
return userLists;
} catch (error) {
console.error("fetchUserLists: Error fetching user lists:", error);
return [];
}
}
/**
* Fetch the current user's lists
* @param listKinds - Array of list kinds to fetch (defaults to all people list kinds)
* @returns Promise that resolves to an array of UserListEvent objects
*/
export async function fetchCurrentUserLists(
listKinds: number[] = [...PEOPLE_LIST_KINDS]
): Promise<UserListEvent[]> {
const userState = get(userStore);
if (!userState.signedIn || !userState.pubkey) {
console.warn("fetchCurrentUserLists: No active user found in userStore");
return [];
}
console.log("fetchCurrentUserLists: Found user pubkey:", userState.pubkey);
return fetchUserLists(userState.pubkey, listKinds);
}
/**
* Get all pubkeys from user lists
* @param userLists - Array of UserListEvent objects
* @returns Set of unique pubkeys
*/
export function getPubkeysFromUserLists(userLists: UserListEvent[]): Set<string> {
const pubkeys = new Set<string>();
userLists.forEach(list => {
list.pubkeys.forEach(pubkey => {
pubkeys.add(pubkey);
});
});
return pubkeys;
}
/**
* Get pubkeys from a specific list kind
* @param userLists - Array of UserListEvent objects
* @param kind - The list kind to filter by
* @returns Set of unique pubkeys from the specified list kind
*/
export function getPubkeysFromListKind(userLists: UserListEvent[], kind: number): Set<string> {
const pubkeys = new Set<string>();
userLists.forEach(list => {
if (list.kind === kind) {
list.pubkeys.forEach(pubkey => {
pubkeys.add(pubkey);
});
}
});
return pubkeys;
}
/**
* Check if a pubkey is in any of the user's lists
* @param pubkey - The pubkey to check
* @param userLists - Array of UserListEvent objects
* @returns True if the pubkey is in any list
*/
export function isPubkeyInUserLists(pubkey: string, userLists: UserListEvent[]): boolean {
const result = userLists.some(list => list.pubkeys.includes(pubkey));
console.log(`isPubkeyInUserLists: Checking ${pubkey} against ${userLists.length} lists, result: ${result}`);
if (result) {
console.log(`isPubkeyInUserLists: Found ${pubkey} in lists:`, userLists.filter(list => list.pubkeys.includes(pubkey)).map(list => ({ kind: list.kind, name: list.listName })));
}
return result;
}
/**
* Get the list kinds that contain a specific pubkey
* @param pubkey - The pubkey to check
* @param userLists - Array of UserListEvent objects
* @returns Array of list kinds that contain the pubkey
*/
export function getListKindsForPubkey(pubkey: string, userLists: UserListEvent[]): number[] {
return userLists
.filter(list => list.pubkeys.includes(pubkey))
.map(list => list.kind);
}
/**
* Update profile cache when new follows are discovered
* This ensures follows are always cached and prioritized
* @param pubkeys - Array of pubkeys to cache profiles for
*/
export async function updateProfileCacheForPubkeys(pubkeys: string[]): Promise<void> {
if (pubkeys.length === 0) return;
try {
console.log(`Updating profile cache for ${pubkeys.length} pubkeys`);
const ndk = get(ndkInstance);
if (!ndk) {
console.warn("updateProfileCacheForPubkeys: No NDK instance available");
return;
}
// Fetch profiles for all pubkeys in batches
const batchSize = 20;
for (let i = 0; i < pubkeys.length; i += batchSize) {
const batch = pubkeys.slice(i, i + batchSize);
try {
const events = await ndk.fetchEvents({
kinds: [0],
authors: batch,
});
// Cache each profile
for (const event of events) {
if (event.content) {
try {
const profileData = JSON.parse(event.content);
const npub = nip19.npubEncode(event.pubkey);
npubCache.set(npub, profileData);
console.log(`Cached profile for: ${npub}`);
} catch (e) {
console.warn("Failed to parse profile data:", e);
}
}
}
} catch (error) {
console.warn("Failed to fetch batch of profiles:", error);
}
}
console.log("Profile cache update completed");
} catch (error) {
console.warn("Failed to update profile cache:", error);
}
}

167
src/routes/events/+page.svelte

@ -13,7 +13,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub, getUserMetadata } from "$lib/utils/nostrUtils"; import { getMatchingTags, toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import EventInput from "$lib/components/EventInput.svelte"; import EventInput from "$lib/components/EventInput.svelte";
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays, logCurrentRelayConfiguration } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays, logCurrentRelayConfiguration } from "$lib/ndk";
@ -21,6 +21,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte"; import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
import { checkCommunity } from "$lib/utils/search_utility"; import { checkCommunity } from "$lib/utils/search_utility";
import { parseRepostContent, parseContent } from "$lib/utils/notification_utils"; import { parseRepostContent, parseContent } from "$lib/utils/notification_utils";
import { fetchCurrentUserLists, isPubkeyInUserLists } from "$lib/utils/user_lists";
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -54,6 +55,13 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
userStore.subscribe((val) => (user = val)); userStore.subscribe((val) => (user = val));
// Debug: Check if user is logged in
$effect(() => {
console.log("[Events Page] User state:", user);
console.log("[Events Page] User signed in:", user?.signedIn);
console.log("[Events Page] User pubkey:", user?.pubkey);
});
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
event = newEvent; event = newEvent;
showSidePanel = true; showSidePanel = true;
@ -69,10 +77,35 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
// searchInProgress = false; // searchInProgress = false;
// secondOrderSearchMessage = null; // secondOrderSearchMessage = null;
// AI-NOTE: 2025-01-24 - Properly parse profile data for kind 0 events
if (newEvent.kind === 0) { if (newEvent.kind === 0) {
try { try {
profile = JSON.parse(newEvent.content); const parsedProfile = parseProfileContent(newEvent);
} catch { if (parsedProfile) {
profile = parsedProfile;
console.log("[Events Page] Parsed profile data:", parsedProfile);
// If the event doesn't have user list information, fetch it
if (typeof parsedProfile.isInUserLists !== 'boolean') {
fetchCurrentUserLists()
.then((userLists) => {
const isInLists = isPubkeyInUserLists(newEvent.pubkey, userLists);
// Update the profile with user list information
profile = { ...parsedProfile, isInUserLists: isInLists } as any;
// Also update the event's profileData
(newEvent as any).profileData = { ...parsedProfile, isInUserLists: isInLists };
})
.catch(() => {
profile = { ...parsedProfile, isInUserLists: false } as any;
(newEvent as any).profileData = { ...parsedProfile, isInUserLists: false };
});
}
} else {
console.warn("[Events Page] Failed to parse profile content for event:", newEvent.id);
profile = null;
}
} catch (error) {
console.error("[Events Page] Error parsing profile content:", error);
profile = null; profile = null;
} }
} else { } else {
@ -82,6 +115,20 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
// AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author // AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author
if (newEvent.pubkey) { if (newEvent.pubkey) {
cacheProfileForPubkey(newEvent.pubkey); cacheProfileForPubkey(newEvent.pubkey);
// Update profile data with user list information
updateProfileDataWithUserLists([newEvent]);
// Also check community status for the individual event
if (!communityStatus[newEvent.pubkey]) {
checkCommunity(newEvent.pubkey)
.then((status) => {
communityStatus = { ...communityStatus, [newEvent.pubkey]: status };
})
.catch(() => {
communityStatus = { ...communityStatus, [newEvent.pubkey]: false };
});
}
} }
} }
@ -160,6 +207,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
addresses: Set<string> = new Set(), addresses: Set<string> = new Set(),
searchTypeParam?: string, searchTypeParam?: string,
searchTermParam?: string, searchTermParam?: string,
loading: boolean = false, // AI-NOTE: 2025-01-24 - Add loading parameter for second-order search message logic
) { ) {
searchResults = results; searchResults = results;
secondOrderResults = secondOrder; secondOrderResults = secondOrder;
@ -230,9 +278,31 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
const cachePromises = Array.from(uniquePubkeys).map(pubkey => cacheProfileForPubkey(pubkey)); const cachePromises = Array.from(uniquePubkeys).map(pubkey => cacheProfileForPubkey(pubkey));
await Promise.allSettled(cachePromises); await Promise.allSettled(cachePromises);
// AI-NOTE: 2025-01-24 - Update profile data with user list information for cached events
await updateProfileDataWithUserLists(events);
console.log(`[Events Page] Profile caching complete`); console.log(`[Events Page] Profile caching complete`);
} }
// AI-NOTE: 2025-01-24 - Function to update profile data with user list information
async function updateProfileDataWithUserLists(events: NDKEvent[]) {
try {
const userLists = await fetchCurrentUserLists();
for (const event of events) {
if (event.kind === 0 && event.pubkey) {
const existingProfileData = (event as any).profileData || parseProfileContent(event);
if (existingProfileData) {
const isInLists = isPubkeyInUserLists(event.pubkey, userLists);
(event as any).profileData = { ...existingProfileData, isInUserLists: isInLists };
}
}
}
} catch (error) {
console.warn("[Events Page] Failed to update profile data with user lists:", error);
}
}
function handleClear() { function handleClear() {
searchType = null; searchType = null;
searchTerm = null; searchTerm = null;
@ -310,6 +380,8 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
website?: string; website?: string;
lud16?: string; lud16?: string;
nip05?: string; nip05?: string;
isInUserLists?: boolean;
listKinds?: number[];
} | null { } | null {
if (event.kind !== 0 || !event.content) { if (event.kind !== 0 || !event.content) {
return null; return null;
@ -365,6 +437,16 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
return addr.slice(0, head) + "…" + addr.slice(-tail); return addr.slice(0, head) + "…" + addr.slice(-tail);
} }
function formatEventDate(event: NDKEvent): string {
if (event.created_at) {
return new Date(event.created_at * 1000).toLocaleDateString();
}
if ((event as any).timestamp) {
return new Date((event as any).timestamp * 1000).toLocaleDateString();
}
return "Unknown date";
}
function onLoadingChange(val: boolean) { function onLoadingChange(val: boolean) {
loading = val; loading = val;
searchInProgress = searchInProgress =
@ -394,7 +476,9 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
} }
} }
// AI-NOTE: 2025-01-24 - Ensure proper reactivity by creating a new object
communityStatus = { ...communityStatus, ...newCommunityStatus }; communityStatus = { ...communityStatus, ...newCommunityStatus };
console.log("Community status updated:", communityStatus);
} }
@ -490,7 +574,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
</Heading> </Heading>
<div class="space-y-4"> <div class="space-y-4">
{#each searchResults as result, index} {#each searchResults as result, index}
{@const profileData = parseProfileContent(result)} {@const profileData = (result as any).profileData || parseProfileContent(result)}
<button <button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-primary-900/70 hover:bg-gray-100 dark:hover:bg-primary-800 focus:bg-gray-100 dark:focus:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden" class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-primary-900/70 hover:bg-gray-100 dark:hover:bg-primary-800 focus:bg-gray-100 dark:focus:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden"
onclick={() => handleEventFound(result)} onclick={() => handleEventFound(result)}
@ -504,6 +588,22 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if profileData?.isInUserLists}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{/if}
{#if result.pubkey && communityStatus[result.pubkey]} {#if result.pubkey && communityStatus[result.pubkey]}
<div <div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
@ -519,7 +619,8 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
/> />
</svg> </svg>
</div> </div>
{:else} {/if}
{#if !profileData?.isInUserLists && !(result.pubkey && communityStatus[result.pubkey])}
<div class="flex-shrink-0 w-4 h-4"></div> <div class="flex-shrink-0 w-4 h-4"></div>
{/if} {/if}
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
@ -531,11 +632,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span <span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
{result.created_at {formatEventDate(result)}
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span> </span>
</div> </div>
{#if result.kind === 0 && profileData} {#if result.kind === 0 && profileData}
@ -664,7 +761,22 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]} {#if profileData?.isInUserLists}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if result.pubkey && communityStatus[result.pubkey]}
<div <div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community" title="Has posted to the community"
@ -691,11 +803,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span <span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
{result.created_at {formatEventDate(result)}
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span> </span>
</div> </div>
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1"> <div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
@ -825,7 +933,22 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]} {#if profileData?.isInUserLists}
<div
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center"
title="In your lists (follows, etc.)"
>
<svg
class="w-3 h-3 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
{:else if result.pubkey && communityStatus[result.pubkey]}
<div <div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
title="Has posted to the community" title="Has posted to the community"
@ -852,11 +975,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span <span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
> >
{result.created_at {formatEventDate(result)}
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
</span> </span>
</div> </div>
{#if result.kind === 0 && profileData} {#if result.kind === 0 && profileData}
@ -997,7 +1116,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
{/if} {/if}
<div class="min-w-0 overflow-hidden"> <div class="min-w-0 overflow-hidden">
<EventDetails {event} {profile} {searchValue} /> <EventDetails {event} {profile} {searchValue} communityStatusMap={communityStatus} />
</div> </div>
<div class="min-w-0 overflow-hidden"> <div class="min-w-0 overflow-hidden">
<RelayActions {event} /> <RelayActions {event} />
@ -1007,7 +1126,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<CommentViewer {event} /> <CommentViewer {event} />
</div> </div>
{#if isLoggedIn && userPubkey} {#if user?.signedIn}
<div class="mt-8 min-w-0 overflow-hidden"> <div class="mt-8 min-w-0 overflow-hidden">
<Heading tag="h3" class="h-leather mb-4 break-words">Add Comment</Heading> <Heading tag="h3" class="h-leather mb-4 break-words">Add Comment</Heading>
<CommentBox {event} {userRelayPreference} /> <CommentBox {event} {userRelayPreference} />

4
src/routes/visualize/+page.svelte

@ -17,7 +17,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors"; import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors";
import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/profileCache"; import { extractPubkeysFromEvents, batchFetchProfiles } from "$lib/utils/profileCache";
import { activePubkey } from "$lib/ndk"; import { userStore } from "$lib/stores/userStore";
// Import utility functions for tag-based event fetching // Import utility functions for tag-based event fetching
// These functions handle the complex logic of finding publications by tags // These functions handle the complex logic of finding publications by tags
// and extracting their associated content events // and extracting their associated content events
@ -122,7 +122,7 @@
} }
// Get the current user's pubkey // Get the current user's pubkey
const currentUserPubkey = get(activePubkey); const currentUserPubkey = get(userStore).pubkey;
if (!currentUserPubkey) { if (!currentUserPubkey) {
console.warn("No logged-in user, cannot fetch user's follow list"); console.warn("No logged-in user, cannot fetch user's follow list");
return []; return [];

Loading…
Cancel
Save