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. 465
      src/lib/components/EventDetails.svelte
  4. 13
      src/lib/components/EventInput.svelte
  5. 153
      src/lib/components/EventSearch.svelte
  6. 1
      src/lib/components/Notifications.svelte
  7. 68
      src/lib/components/cards/ProfileHeader.svelte
  8. 2
      src/lib/navigator/EventNetwork/Legend.svelte
  9. 26
      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. 569
      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. 801
      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. 570
      src/lib/utils/subscription_search.ts
  26. 253
      src/lib/utils/user_lists.ts
  27. 167
      src/routes/events/+page.svelte
  28. 6
      src/routes/visualize/+page.svelte

444
deno.lock

@ -3,9 +3,6 @@ @@ -3,9 +3,6 @@
"specifiers": {
"npm:@noble/curves@^1.9.4": "1.9.4",
"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:@popperjs/core@2.11": "2.11.8",
"npm:@tailwindcss/forms@0.5": "0.5.10_tailwindcss@3.4.17__postcss@8.5.6",
@ -18,20 +15,14 @@ @@ -18,20 +15,14 @@
"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: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: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@^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@^3.1.2": "3.1.2",
"npm:he@1.2": "1.2.0",
"npm:highlight.js@^11.11.1": "11.11.1",
"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:playwright@^1.50.1": "1.54.1",
"npm:playwright@^1.54.1": "1.54.1",
@ -350,39 +341,15 @@ @@ -350,39 +341,15 @@
"@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": {
"integrity": "sha512-2bKONnuM53lINoDrSmK8qP8W271ms7pygDhZt4SiLOoLwBtoHqeCFi6RG42V8zd3mLHuJFhU/Bmaqo4nX0/kBw==",
"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": {
"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": {
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dependencies": [
@ -400,30 +367,6 @@ @@ -400,30 +367,6 @@
"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": {
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="
},
@ -559,27 +502,6 @@ @@ -559,27 +502,6 @@
"os": ["win32"],
"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": {
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="
},
@ -589,34 +511,6 @@ @@ -589,34 +511,6 @@
"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": {
"integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
"dependencies": [
@ -896,17 +790,6 @@ @@ -896,17 +790,6 @@
"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": {
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
},
@ -1094,9 +977,6 @@ @@ -1094,9 +977,6 @@
"commander@5.1.0": {
"integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="
},
"commander@7.2.0": {
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="
},
"concat-map@0.0.1": {
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
@ -1119,212 +999,6 @@ @@ -1119,212 +999,6 @@
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"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": {
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dependencies": [
@ -1340,15 +1014,6 @@ @@ -1340,15 +1014,6 @@
"deepmerge@4.3.1": {
"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": {
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
},
@ -1608,40 +1273,17 @@ @@ -1608,40 +1273,17 @@
"integrity": "sha512-VNNMcekjbM1bQEGgbdGsdYR9mRdTj/L0A5ba0P1tiFv5QB9GvbvJMABJoiD80eqpZUkfR2QVOmiZfgCwHicT/Q==",
"dependencies": [
"svelte",
"tailwind-merge@3.3.1"
]
},
"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"
"tailwind-merge"
]
},
"flowbite-svelte@0.48.6_svelte@5.36.8__acorn@8.15.0": {
"integrity": "sha512-/PmeR3ipHHvda8vVY9MZlymaRoJsk8VddEeoLzIygfYwJV68ey8gHuQPC1dq9J6NDCTE5+xOPtBiYUtVjCfvZw==",
"dependencies": [
"@floating-ui/dom",
"apexcharts@3.54.1",
"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",
"apexcharts",
"flowbite@3.1.2",
"svelte",
"tailwind-merge@3.3.1",
"tailwind-variants",
"tailwindcss"
"tailwind-merge"
]
},
"flowbite@2.5.2": {
@ -1794,12 +1436,6 @@ @@ -1794,12 +1436,6 @@
"highlight.js@11.11.1": {
"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": {
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
},
@ -1824,9 +1460,6 @@ @@ -1824,9 +1460,6 @@
"inherits@2.0.4": {
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"internmap@2.0.3": {
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="
},
"is-binary-path@2.1.0": {
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dependencies": [
@ -1950,12 +1583,6 @@ @@ -1950,12 +1583,6 @@
"type-check"
]
},
"light-bolt11-decoder@3.2.0": {
"integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==",
"dependencies": [
"@scure/base@1.1.1"
]
},
"lilconfig@2.1.0": {
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="
},
@ -2081,25 +1708,6 @@ @@ -2081,25 +1708,6 @@
"normalize-range@0.1.2": {
"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": {
"integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==",
"dependencies": [
@ -2477,9 +2085,6 @@ @@ -2477,9 +2085,6 @@
"reusify@1.1.0": {
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="
},
"robust-predicates@3.0.2": {
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="
},
"rollup@4.45.1": {
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
"dependencies": [
@ -2516,18 +2121,12 @@ @@ -2516,18 +2121,12 @@
"queue-microtask"
]
},
"rw@1.3.3": {
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"sade@1.8.1": {
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dependencies": [
"mri"
]
},
"safer-buffer@2.1.2": {
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"semver@7.7.2": {
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"bin": true
@ -2705,19 +2304,9 @@ @@ -2705,19 +2304,9 @@
"svg.js"
]
},
"tailwind-merge@3.0.2": {
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="
},
"tailwind-merge@3.3.1": {
"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": {
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dependencies": [
@ -2770,9 +2359,6 @@ @@ -2770,9 +2359,6 @@
"ts-interface-checker@0.1.13": {
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
},
"tseep@1.3.1": {
"integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ=="
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
@ -2782,9 +2368,6 @@ @@ -2782,9 +2368,6 @@
"prelude-ls"
]
},
"typescript-lru-cache@2.0.0": {
"integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA=="
},
"typescript@5.8.3": {
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"bin": true
@ -2945,18 +2528,25 @@ @@ -2945,18 +2528,25 @@
},
"workspace": {
"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:@popperjs/core@2.11",
"npm:@tailwindcss/forms@0.5",
"npm:@tailwindcss/typography@0.5",
"npm:asciidoctor@3.0",
"npm:d3@7.9",
"npm:flowbite-svelte-icons@^2.2.1",
"npm:flowbite-svelte@^1.10.10",
"npm:flowbite@^3.1.2",
"npm:bech32@2",
"npm:d3@^7.9.0",
"npm:flowbite-svelte-icons@2.1",
"npm:flowbite-svelte@0.48",
"npm:flowbite@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:tailwind-merge@^3.3.1"
],

27
src/lib/components/CommentBox.svelte

@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
ProfileSearchResult,
} from "$lib/utils/search_utility";
import { userPubkey } from "$lib/stores/authStore.Svelte";
import { userStore } from "$lib/stores/userStore";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
@ -174,7 +174,7 @@ @@ -174,7 +174,7 @@
success = null;
try {
const pk = $userPubkey || "";
const pk = $userStore.pubkey || "";
const npub = toNpub(pk);
if (!npub) {
@ -430,7 +430,22 @@ @@ -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"
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
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"
@ -604,10 +619,10 @@ @@ -604,10 +619,10 @@
{/if}
<Button
onclick={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !$userPubkey}
disabled={isSubmitting || !content.trim() || !$userStore.pubkey}
class="w-full md:w-auto"
>
{#if !$userPubkey}
{#if !$userStore.pubkey}
Not Signed In
{:else if isSubmitting}
Publishing...
@ -617,7 +632,7 @@ @@ -617,7 +632,7 @@
</Button>
</div>
{#if !$userPubkey}
{#if !$userStore.pubkey}
<Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your
current account.

465
src/lib/components/EventDetails.svelte

@ -1,13 +1,12 @@ @@ -1,13 +1,12 @@
<script lang="ts">
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser";
import EmbeddedEventRenderer from "./EmbeddedEventRenderer.svelte";
import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { searchRelays } from "$lib/consts";
import { nip19 } from "nostr-tools";
import { activeInboxRelays } from "$lib/ndk";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import ProfileHeader from "$components/cards/ProfileHeader.svelte";
@ -19,12 +18,14 @@ @@ -19,12 +18,14 @@
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
import Notifications from "$lib/components/Notifications.svelte";
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 {
event,
profile = null,
searchValue = null,
communityStatusMap = {},
} = $props<{
event: NDKEvent;
profile?: {
@ -38,12 +39,15 @@ @@ -38,12 +39,15 @@
nip05?: string;
} | null;
searchValue?: string | null;
communityStatusMap?: Record<string, boolean>;
}>();
let showFullContent = $state(false);
let parsedContent = $state("");
let contentProcessing = $state(false);
let authorDisplayName = $state<string | undefined>(undefined);
let communityStatus = $state<boolean | null>(null);
let isInUserLists = $state<boolean | null>(null);
// Determine if content should be truncated
let shouldTruncate = $state(false);
@ -52,6 +56,49 @@ @@ -52,6 +56,49 @@
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 {
// First try to get title from title tag
const titleTag = getMatchingTags(event, "title")[0]?.[1];
@ -101,208 +148,131 @@ @@ -101,208 +148,131 @@
return MTag[1].split("/")[1] || `Event Kind ${event.kind}`;
}
// AI-NOTE: Tag processing utilities
function isValidHexString(str: string): boolean {
return /^[0-9a-fA-F]{64}$/.test(str);
}
function createMockEvent(id: string, kind: number = 1): any {
return {
id,
kind,
content: "",
tags: [],
pubkey: "",
sig: "",
};
}
function createMockAddressableEvent(kind: number, pubkey: string, d: string): any {
return {
kind,
pubkey,
tags: [["d", d]],
content: "",
id: "",
sig: "",
};
}
function renderTag(tag: string[]): string {
if (tag[0] === "a" && tag.length > 1) {
const parts = tag[1].split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
try {
const mockEvent = {
kind: +kind,
pubkey,
tags: [["d", d]],
content: "",
id: "",
sig: "",
} as any;
const naddr = naddrEncode(mockEvent, $activeInboxRelays);
return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tag[1]}</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>`;
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);
return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tagValue}</a>`;
}
}
} 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>`;
break;
}
} else {
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) {
// Validate that event ID is a valid hex string
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'>e:${tag[1]}</a>`;
} catch (error) {
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>`;
case "e":
case "note": {
if (isValidHexString(tagValue)) {
const mockEvent = createMockEvent(tagValue);
const nevent = neventEncode(mockEvent, $activeInboxRelays);
const prefix = tagType === "note" ? "note:" : "e:";
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>${prefix}${tagValue}</a>`;
}
break;
}
} else {
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) {
// '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 `<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>`;
case "d": {
return `<a href='/events?d=${encodeURIComponent(tagValue)}' class='underline text-primary-700'>d:${tagValue}</a>`;
}
} 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) {
// 'd' tags are used for identifiers in addressable events
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>`;
} catch (error) {
console.warn(`Failed to encode ${tagType} tag:`, tagValue, error);
}
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[]): {
text: string;
gotoValue?: string;
} {
if (tag[0] === "a" && tag.length > 1) {
const parts = tag[1].split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
try {
const mockEvent = {
kind: +kind,
pubkey,
tags: [["d", d]],
content: "",
id: "",
sig: "",
} as any;
const naddr = naddrEncode(mockEvent, $activeInboxRelays);
return {
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]}` };
const [tagType, tagValue] = tag;
if (!tagValue) {
return { text: `${tagType}:${tagValue}` };
}
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);
return { text: `a:${tagValue}`, gotoValue: naddr };
}
}
} else {
console.warn("Invalid pubkey in a tag:", pubkey);
return { text: `a:${tag[1]}` };
break;
}
} else {
console.warn("Invalid a tag format:", tag[1]);
return { text: `a:${tag[1]}` };
}
} else if (tag[0] === "e" && tag.length > 1) {
// Validate that event ID is a valid hex string
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: `e:${tag[1]}`,
gotoValue: nevent,
};
} catch (error) {
console.warn("Failed to encode nevent for e tag:", tag[1], error);
return { text: `e:${tag[1]}` };
case "e":
case "note": {
if (isValidHexString(tagValue)) {
const mockEvent = createMockEvent(tagValue);
const nevent = neventEncode(mockEvent, $activeInboxRelays);
const prefix = tagType === "note" ? "note:" : "e:";
return { text: `${prefix}${tagValue}`, gotoValue: nevent };
}
break;
}
} else {
console.warn("Invalid event ID in e tag:", tag[1]);
return { text: `e:${tag[1]}` };
}
} else if (tag[0] === "p" && tag.length > 1) {
const npub = toNpub(tag[1]);
return {
text: `p:${npub || tag[1]}`,
gotoValue: npub ? 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);
case "p": {
const npub = toNpub(tagValue);
return {
text: `note:${tag[1]}`,
gotoValue: nevent,
text: `p:${npub || tagValue}`,
gotoValue: npub || undefined,
};
} catch (error) {
console.warn("Failed to encode nevent for note tag:", tag[1], error);
return { text: `note:${tag[1]}` };
}
} else {
console.warn("Invalid event ID in note tag:", tag[1]);
return { text: `note:${tag[1]}` };
case "d": {
return { text: `d:${tagValue}`, gotoValue: `d:${tagValue}` };
}
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]}`,
};
} catch (error) {
console.warn(`Failed to encode ${tagType} tag:`, tagValue, error);
}
return { text: `${tag[0]}:${tag[1]}` };
return { text: `${tagType}:${tagValue}` };
}
// AI-NOTE: URL generation functions
function getNeventUrl(event: NDKEvent): string {
return neventEncode(event, $activeInboxRelays);
}
@ -315,6 +285,7 @@ @@ -315,6 +285,7 @@
return nprofileEncode(pubkey, $activeInboxRelays);
}
// AI-NOTE: Content processing effect
$effect(() => {
if (event && event.kind !== 0 && event.content) {
contentProcessing = true;
@ -344,6 +315,7 @@ @@ -344,6 +315,7 @@
}
});
// AI-NOTE: Author metadata effect
$effect(() => {
if (!event?.pubkey) {
authorDisplayName = undefined;
@ -358,48 +330,79 @@ @@ -358,48 +330,79 @@
});
});
// --- Identifier helpers ---
// AI-NOTE: Identifier helpers
function getIdentifiers(
event: NDKEvent,
profile: any,
): { label: string; value: string; link?: string }[] {
const ids: { label: string; value: string; link?: string }[] = [];
if (event.kind === 0) {
// NIP-05
const nip05 = profile?.nip05 || getMatchingTags(event, "nip05")[0]?.[1];
// npub
// Profile event identifiers
const npub = toNpub(event.pubkey);
if (npub)
if (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({
label: "nprofile",
value: nprofileEncode(event.pubkey, $activeInboxRelays),
link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}`,
value: nprofileEncode(rawPubkey, $activeInboxRelays),
link: `/events?id=${nprofileEncode(rawPubkey, $activeInboxRelays)}`,
});
// nevent
ids.push({
label: "nevent",
value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
});
// hex pubkey
// For nevent encoding, we need to ensure the event has proper hex strings
try {
const nevent = neventEncode(event, $activeInboxRelays);
ids.push({
label: "nevent",
value: nevent,
link: `/events?id=${nevent}`,
});
} 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 });
} else {
// nevent
ids.push({
label: "nevent",
value: neventEncode(event, $activeInboxRelays),
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`,
});
// 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({
label: "nevent",
value: nevent,
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)
try {
const naddr = naddrEncode(event, $activeInboxRelays);
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {}
// hex id
ids.push({ label: "id", value: event.id });
}
return ids;
}
@ -410,6 +413,7 @@ @@ -410,6 +413,7 @@
return norm(value) === norm(searchValue);
}
// AI-NOTE: Navigation handler for internal links
onMount(() => {
function handleInternalLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
@ -438,20 +442,58 @@ @@ -438,20 +442,58 @@
<Notifications {event} />
{/if}
{#if !(event.kind === 0)}
<div class="flex items-center space-x-2 min-w-0">
{#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(
toNpub(event.pubkey) as string,
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}
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span
>
{/if}
</div>
{/if}
<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>
@ -468,8 +510,6 @@ @@ -468,8 +510,6 @@
</div>
{/if}
<!-- Containing Publications -->
<ContainingIndexes {event} />
@ -497,11 +537,12 @@ @@ -497,11 +537,12 @@
</div>
{/if}
<!-- If event is profile -->
{#if event.kind === 0}
<!-- Show ProfileHeader for all events except profile events (kind 0) when in search context to avoid redundancy -->
{#if (event.kind === 0)}
<ProfileHeader
{event}
{profile}
{communityStatusMap}
/>
{/if}

13
src/lib/components/EventInput.svelte

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

153
src/lib/components/EventSearch.svelte

@ -41,6 +41,7 @@ @@ -41,6 +41,7 @@
addresses: Set<string>,
searchType?: string,
searchTerm?: string,
loading?: boolean, // AI-NOTE: 2025-01-24 - Add loading parameter for second-order search message logic
) => void;
event: NDKEvent | null;
onClear?: () => void;
@ -140,6 +141,7 @@ @@ -140,6 +141,7 @@
return;
}
// AI-NOTE: 2025-01-24 - If no specific search type is detected, treat as event ID search
if (clearInput) {
navigateToSearch(query, "id");
}
@ -169,6 +171,13 @@ @@ -169,6 +171,13 @@
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;
}
@ -425,12 +434,8 @@ @@ -425,12 +434,8 @@
searchTerm,
});
if (searchType === "n") {
const cachedResult = await handleCachedProfileSearch(searchTerm);
if (cachedResult) {
return;
}
}
// AI-NOTE: 2025-01-24 - Profile search caching is now handled by centralized searchProfiles function
// No need for separate caching logic here as it's handled in profile_search.ts
isResetting = false;
localError = null;
@ -445,61 +450,9 @@ @@ -445,61 +450,9 @@
}
}
async function handleCachedProfileSearch(searchTerm: string): Promise<boolean> {
if (!searchTerm.startsWith("npub") && !searchTerm.startsWith("nprofile")) {
return false;
}
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: "",
});
}
// AI-NOTE: 2025-01-24 - Profile search is now handled by centralized searchProfiles function
// These functions are no longer needed as profile searches go through subscription_search.ts
// which delegates to the centralized profile_search.ts
async function waitForRelays(): Promise<void> {
let retryCount = 0;
@ -565,7 +518,8 @@ @@ -565,7 +518,8 @@
updatedResult.eventIds,
updatedResult.addresses,
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) => {
@ -595,7 +549,8 @@ @@ -595,7 +549,8 @@
result.eventIds,
result.addresses,
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;
@ -645,75 +600,9 @@ @@ -645,75 +600,9 @@
}
}
async function performBackgroundProfileSearch(
searchType: "d" | "t" | "n",
searchTerm: string,
) {
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);
}
}
// AI-NOTE: 2025-01-24 - Background profile search is now handled by centralized searchProfiles function
// This function is no longer needed as profile searches go through subscription_search.ts
// which delegates to the centralized profile_search.ts
function handleClear() {
isResetting = true;

1
src/lib/components/Notifications.svelte

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

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

@ -2,7 +2,8 @@ @@ -2,7 +2,8 @@
import { Card, Modal, Button, P } from "flowbite-svelte";
import { onMount } from "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 CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import LazyImage from "$components/util/LazyImage.svelte";
@ -14,20 +15,24 @@ @@ -14,20 +15,24 @@
import { bech32 } from "bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { goto } from "$app/navigation";
import { isPubkeyInUserLists, fetchCurrentUserLists } from "$lib/utils/user_lists";
const {
event,
profile,
identifiers = [],
communityStatusMap = {},
} = $props<{
event: NDKEvent;
profile: NostrProfile;
identifiers?: { label: string; value: string; link?: string }[];
communityStatusMap?: Record<string, boolean>;
}>();
let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null);
let communityStatus = $state<boolean | null>(null);
let isInUserLists = $state<boolean | null>(null);
onMount(async () => {
if (profile?.lud16) {
@ -45,13 +50,42 @@ @@ -45,13 +50,42 @@
$effect(() => {
if (event?.pubkey) {
checkCommunity(event.pubkey)
.then((status) => {
communityStatus = status;
})
.catch(() => {
communityStatus = false;
});
// 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)
.then((status) => {
communityStatus = status;
})
.catch(() => {
communityStatus = false;
});
}
}
});
@ -118,6 +152,24 @@ @@ -118,6 +152,24 @@
{:else if communityStatus === false}
<div class="flex-shrink-0 w-4 h-4"></div>
{/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 class="min-w-0">

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

@ -467,7 +467,7 @@ @@ -467,7 +467,7 @@
></span>
</div>
<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>
</button>
{/each}

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

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
<script lang="ts">
import type { NetworkNode } from "./types";
import { onMount } from "svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import { getEventKindName } from "$lib/utils/eventColors";
import {
getDisplayNameSync,
@ -47,6 +47,11 @@ @@ -47,6 +47,11 @@
* Gets the author name from the event tags
*/
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) {
const authorTags = getMatchingTags(node.event, "author");
if (authorTags.length > 0) {
@ -100,6 +105,11 @@ @@ -100,6 +105,11 @@
if (isPublicationEvent(node.kind)) {
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}`;
}
@ -206,12 +216,18 @@ @@ -206,12 +216,18 @@
</div>
<!-- Pub Author -->
<div class="tooltip-metadata">
Pub Author: {getAuthorTag(node)}
</div>
{#if !node.isPersonAnchor}
<div class="tooltip-metadata">
Pub Author: {getAuthorTag(node)}
</div>
{/if}
<!-- 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">
published_by: {node.author}
</div>

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

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

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

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink } from "../types";
import { getDisplayNameSync } from "$lib/utils/profileCache";
import { getDisplayNameSync, batchFetchProfiles } from "$lib/utils/profileCache";
import { SeededRandom, createDebugFunction } from "./common";
const PERSON_ANCHOR_RADIUS = 15;
@ -186,14 +186,14 @@ function getEligiblePersons( @@ -186,14 +186,14 @@ function getEligiblePersons(
/**
* Creates person anchor nodes
*/
export function createPersonAnchorNodes(
export async function createPersonAnchorNodes(
personMap: Map<string, PersonConnection>,
width: number,
height: number,
showSignedBy: boolean,
showReferenced: boolean,
limit: number = MAX_PERSON_NODES
): { nodes: NetworkNode[], totalCount: number } {
): Promise<{ nodes: NetworkNode[], totalCount: number }> {
const anchorNodes: NetworkNode[] = [];
const centerX = width / 2;
@ -202,6 +202,17 @@ export function createPersonAnchorNodes( @@ -202,6 +202,17 @@ export function createPersonAnchorNodes(
// Calculate eligible persons and their connection counts
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
debug("Creating person anchor nodes", {
eligibleCount: eligiblePersons.length,

8
src/lib/ndk.ts

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

4
src/lib/state.ts

@ -8,8 +8,6 @@ export const tabs: Writable<Tab[]> = writable([{ id: 0, type: "welcome" }]); @@ -8,8 +8,6 @@ export const tabs: Writable<Tab[]> = writable([{ id: 0, type: "welcome" }]);
export const tabBehaviour: Writable<string> = writable(
(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 levelsToRender: Writable<number> = writable(3);

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

@ -1,11 +0,0 @@ @@ -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);

569
src/lib/stores/userStore.ts

@ -1,30 +1,36 @@ @@ -1,30 +1,36 @@
import { writable, get } from "svelte/store";
import type { NostrProfile } from "../utils/nostrUtils.ts";
import type { NDKUser, NDKSigner } from "@nostr-dev-kit/ndk";
import { writable, get } from 'svelte/store';
import type { NostrProfile } from '../utils/search_types.ts';
import type { NDKUser, NDKSigner } from '@nostr-dev-kit/ndk';
import NDK, {
NDKNip07Signer,
NDKRelayAuthPolicies,
NDKRelaySet,
NDKRelay,
} from "@nostr-dev-kit/ndk";
import { getUserMetadata } from "../utils/nostrUtils.ts";
import { ndkInstance, activeInboxRelays, activeOutboxRelays, updateActiveRelayStores } from "../ndk.ts";
import { loginStorageKey } from "../consts.ts";
import { nip19 } from "nostr-tools";
import { userPubkey } from "../stores/authStore.Svelte.ts";
} from '@nostr-dev-kit/ndk';
import { getUserMetadata } from '../utils/nostrUtils.ts';
import { ndkInstance, activeInboxRelays, activeOutboxRelays, updateActiveRelayStores } from '../ndk.ts';
import { loginStorageKey } from '../consts.ts';
import { nip19 } from 'nostr-tools';
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 {
pubkey: string | null;
npub: string | null;
profile: NostrProfile | null;
relays: { inbox: string[]; outbox: string[] };
loginMethod: "extension" | "amber" | "npub" | null;
loginMethod: LoginMethod | null;
ndkUser: NDKUser | null;
signer: NDKSigner | null;
signedIn: boolean;
}
export const userStore = writable<UserState>({
const initialUserState: UserState = {
pubkey: null,
npub: null,
profile: null,
@ -33,53 +39,87 @@ export const userStore = writable<UserState>({ @@ -33,53 +39,87 @@ export const userStore = writable<UserState>({
ndkUser: null,
signer: null,
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 {
return `${loginStorageKey}/${user.pubkey}/${type}`;
/**
* Safely access localStorage (client-side only)
*/
function safeLocalStorage(): Storage | null {
return typeof window !== 'undefined' ? window.localStorage : null;
}
/**
* Persist relay preferences to localStorage
*/
function persistRelays(
user: NDKUser,
inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>,
): void {
// Only access localStorage on client-side
if (typeof window === 'undefined') return;
const storage = safeLocalStorage();
if (!storage) return;
localStorage.setItem(
getRelayStorageKey(user, "inbox"),
JSON.stringify(Array.from(inboxes).map((relay) => relay.url)),
);
localStorage.setItem(
getRelayStorageKey(user, "outbox"),
JSON.stringify(Array.from(outboxes).map((relay) => relay.url)),
);
const inboxUrls = Array.from(inboxes).map((relay) => relay.url);
const outboxUrls = Array.from(outboxes).map((relay) => relay.url);
storage.setItem(getRelayStorageKey(user, 'inbox'), JSON.stringify(inboxUrls));
storage.setItem(getRelayStorageKey(user, 'outbox'), JSON.stringify(outboxUrls));
}
/**
* Get persisted relay preferences from localStorage
*/
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
// Only access localStorage on client-side
if (typeof window === 'undefined') {
const storage = safeLocalStorage();
if (!storage) {
return [new Set<string>(), 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>(
JSON.parse(
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
JSON.parse(storage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]'),
);
return [inboxes, outboxes];
}
/**
* Fetch user's preferred relays from Nostr network
*/
async function getUserPreferredRelays(
ndk: NDK,
user: NDKUser,
fallbacks: readonly string[] = [...get(activeInboxRelays), ...get(activeOutboxRelays)],
fallbacks: readonly string[] = [...get(activeInboxRelays), ...get(activeOutboxRelays)],
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent(
{
@ -97,39 +137,38 @@ async function getUserPreferredRelays( @@ -97,39 +137,38 @@ async function getUserPreferredRelays(
const inboxRelays = 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?.();
Object.entries(relayMap ?? {}).forEach(
([url, relayType]: [string, Record<string, boolean | undefined>]) => {
const relay = new NDKRelay(
url,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
},
);
if (relayMap) {
Object.entries(relayMap).forEach(
([url, relayType]: [string, Record<string, boolean | undefined>]) => {
const relay = new NDKRelay(
url,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
},
);
}
} else {
// Parse relay list from event
relayList.tags.forEach((tag: string[]) => {
const relay = new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk);
switch (tag[0]) {
case "r":
inboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
case 'r':
inboxRelays.add(relay);
break;
case "w":
outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
case 'w':
outboxRelays.add(relay);
break;
default:
inboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
outboxRelays.add(
new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk),
);
// Default: add to both
inboxRelays.add(relay);
outboxRelays.add(relay);
break;
}
});
@ -138,324 +177,358 @@ async function getUserPreferredRelays( @@ -138,324 +177,358 @@ async function getUserPreferredRelays(
return [inboxRelays, outboxRelays];
}
// --- Unified login/logout helpers ---
export const loginMethodStorageKey = "alexandria/login/method";
function persistLogin(user: NDKUser, method: "extension" | "amber" | "npub") {
// Only access localStorage on client-side
if (typeof window === 'undefined') return;
localStorage.setItem(loginStorageKey, user.pubkey);
localStorage.setItem(loginMethodStorageKey, method);
}
/**
* Persist login information to localStorage
*/
function persistLogin(user: NDKUser, method: LoginMethod): void {
const storage = safeLocalStorage();
if (!storage) return;
function clearLogin() {
localStorage.removeItem(loginStorageKey);
localStorage.removeItem(loginMethodStorageKey);
storage.setItem(loginStorageKey, user.pubkey);
storage.setItem(loginMethodStorageKey, method);
}
/**
* Login with NIP-07 browser extension
* Clear login information from localStorage
*/
export async function loginWithExtension() {
const ndk = get(ndkInstance);
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
const signer = new NDKNip07Signer();
const user = await signer.user();
const npub = user.npub;
function clearLogin(): void {
const storage = safeLocalStorage();
if (!storage) return;
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 {
console.log("Login with extension - attempting to fetch profile...");
profile = await getUserMetadata(npub, true); // Force fresh fetch
console.log("Login with extension - fetched profile:", profile);
return await getUserMetadata(npub, true);
} catch (error) {
console.warn("Failed to fetch user metadata during 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.warn('Failed to fetch user metadata:', error);
// Fallback profile
return {
name: 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);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes);
ndk.signer = signer;
ndk.activeUser = user;
/**
* Fetch and cache user lists in background
*/
async function fetchUserListsAndUpdateCache(userPubkey: string): Promise<void> {
try {
console.log('Fetching user lists and updating profile cache for:', userPubkey);
const userState = {
pubkey: user.pubkey,
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,
};
const userLists = await fetchCurrentUserLists();
console.log(`Found ${userLists.length} user lists`);
console.log("Login with extension - setting userStore with:", userState);
userStore.set(userState);
userPubkey.set(user.pubkey);
// Collect all unique pubkeys
const allPubkeys = new Set<string>();
userLists.forEach(list => {
list.pubkeys.forEach(pubkey => allPubkeys.add(pubkey));
});
// Update relay stores with the new user's relays
try {
console.debug('[userStore.ts] loginWithExtension: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk, true); // Force update to rebuild relay set for authenticated user
} catch (error) {
console.warn('[userStore.ts] loginWithExtension: Failed to update relay stores:', error);
}
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,
});
// Cache profiles
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);
} catch (e) {
console.warn('Failed to parse profile data:', e);
}
}
}
} catch (error) {
console.warn('Failed to fetch batch of profiles:', error);
}
}
clearLogin();
// Only access localStorage on client-side
if (typeof window !== 'undefined') {
localStorage.removeItem("alexandria/logout/flag");
console.log('User lists and profile cache update completed');
} catch (error) {
console.warn('Failed to fetch user lists and update cache:', error);
}
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);
if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login
const npub = user.npub;
if (!ndk) throw new Error('NDK not initialized');
console.log("Login with Amber - fetching profile for npub:", npub);
const npub = user.npub;
console.log(`Login with ${method} - fetching profile for npub:`, npub);
let profile: NostrProfile | null = null;
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);
}
// Fetch profile
const profile = await fetchUserProfile(npub);
console.log(`Login with ${method} - fetched profile:`, profile);
// Handle relays
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(user);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
persistedInboxes.forEach(relay => ndk.addExplicitRelay(relay));
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
persistRelays(user, inboxes, outboxes);
ndk.signer = amberSigner;
// Set NDK state
ndk.signer = signer || undefined;
ndk.activeUser = user;
const userState = {
// Create user state
const userState: UserState = {
pubkey: user.pubkey,
npub,
profile,
relays: {
inbox: Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes ?? persistedOutboxes).map(
(relay) => relay.url,
),
inbox: Array.from(inboxes || persistedInboxes).map((relay) => relay.url),
outbox: Array.from(outboxes || persistedOutboxes).map((relay) => relay.url),
},
loginMethod: "amber" as const,
loginMethod: method,
ndkUser: user,
signer: amberSigner,
signer,
signedIn: true,
};
console.log("Login with Amber - setting userStore with:", userState);
console.log(`Login with ${method} - setting userStore with:`, userState);
userStore.set(userState);
userPubkey.set(user.pubkey);
// Update relay stores with the new user's relays
// Update relay stores
try {
console.debug('[userStore.ts] loginWithAmber: Updating relay stores for authenticated user');
await updateActiveRelayStores(ndk, true); // Force update to rebuild relay set for authenticated user
console.debug(`[userStore.ts] loginWith${method.charAt(0).toUpperCase() + method.slice(1)}: Updating relay stores`);
await updateActiveRelayStores(ndk, true);
} 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();
// Only access localStorage on client-side
if (typeof window !== 'undefined') {
localStorage.removeItem("alexandria/logout/flag");
const storage = safeLocalStorage();
if (storage) {
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)
*/
export async function loginWithNpub(pubkeyOrNpub: string) {
export async function loginWithNpub(pubkeyOrNpub: string): Promise<void> {
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error("NDK not initialized");
}
if (!ndk) throw new Error('NDK not initialized');
// Decode pubkey
let hexPubkey: string;
if (pubkeyOrNpub.startsWith("npub1")) {
if (pubkeyOrNpub.startsWith('npub1')) {
try {
const decoded = nip19.decode(pubkeyOrNpub);
if (decoded.type !== "npub") {
throw new Error("Invalid npub format");
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
hexPubkey = decoded.data;
} catch (e) {
console.error("Failed to decode npub:", pubkeyOrNpub, e);
console.error('Failed to decode npub:', pubkeyOrNpub, e);
throw e;
}
} else {
hexPubkey = pubkeyOrNpub;
}
// Encode npub
let npub: string;
try {
npub = nip19.npubEncode(hexPubkey);
} 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;
}
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 });
let profile: NostrProfile | null = null;
// First, update relay stores to ensure we have relays available
// Update relay stores first
try {
console.debug('[userStore.ts] loginWithNpub: Updating relay stores for authenticated user');
console.debug('[userStore.ts] loginWithNpub: Updating relay stores');
await updateActiveRelayStores(ndk);
} catch (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));
try {
profile = await getUserMetadata(npub, true); // Force fresh fetch
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);
}
// Fetch profile
const profile = await fetchUserProfile(npub);
// Set NDK state (no signer for read-only)
ndk.signer = undefined;
ndk.activeUser = user;
const userState = {
// Create user state
const userState: UserState = {
pubkey: user.pubkey,
npub,
profile,
relays: { inbox: [], outbox: [] },
loginMethod: "npub" as const,
loginMethod: 'npub',
ndkUser: user,
signer: null,
signedIn: true,
};
console.log("Login with npub - setting userStore with:", userState);
console.log('Login with npub - setting userStore with:', 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();
// Only access localStorage on client-side
if (typeof window !== 'undefined') {
localStorage.removeItem("alexandria/logout/flag");
const storage = safeLocalStorage();
if (storage) {
storage.removeItem(LOGOUT_FLAG_KEY);
}
persistLogin(user, "npub");
persistLogin(user, 'npub');
}
/**
* Logout and clear all user state
*/
export function logoutUser() {
console.log("Logging out user...");
export function logoutUser(): void {
console.log('Logging out user...');
const currentUser = get(userStore);
// Only access localStorage on client-side
if (typeof window !== 'undefined') {
// Clear localStorage
const storage = safeLocalStorage();
if (storage) {
if (currentUser.ndkUser) {
// Clear persisted relays for the user
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "inbox"));
localStorage.removeItem(getRelayStorageKey(currentUser.ndkUser, "outbox"));
// Clear persisted relays
storage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'inbox'));
storage.removeItem(getRelayStorageKey(currentUser.ndkUser, 'outbox'));
}
// Clear all possible login states from localStorage
// Clear login data
clearLogin();
// Also clear any other potential login keys that might exist
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (
key &&
(key.includes("login") ||
key.includes("nostr") ||
key.includes("user") ||
key.includes("alexandria") ||
key === "pubkey")
) {
// Clear any other potential login keys
const keysToRemove: string[] = [];
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key && (
key.includes('login') ||
key.includes('nostr') ||
key.includes('user') ||
key.includes('alexandria') ||
key === 'pubkey'
)) {
keysToRemove.push(key);
}
}
// Specifically target the login storage key
keysToRemove.push("alexandria/login/pubkey");
keysToRemove.push("alexandria/login/method");
keysToRemove.forEach((key) => {
console.log("Removing localStorage key:", key);
localStorage.removeItem(key);
// Clear specific keys
keysToRemove.push('alexandria/login/pubkey', 'alexandria/login/method');
keysToRemove.forEach(key => {
console.log('Removing localStorage key:', key);
storage.removeItem(key);
});
// 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
localStorage.setItem("alexandria/logout/flag", "true");
// Set logout flag
storage.setItem(LOGOUT_FLAG_KEY, 'true');
console.log("Cleared all login data from localStorage");
console.log('Cleared all login data from localStorage');
}
userStore.set({
pubkey: null,
npub: null,
profile: null,
relays: { inbox: [], outbox: [] },
loginMethod: null,
ndkUser: null,
signer: null,
signedIn: false,
});
userPubkey.set(null);
// Clear cache
relayStorageKeyCache.clear();
// Reset user store
userStore.set(initialUserState);
// Clear NDK state
const ndk = get(ndkInstance);
if (ndk) {
ndk.activeUser = 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 { @@ -56,6 +56,8 @@ export function getEventKindName(kind: number): string {
30001: 'Categorized Bookmark List',
30008: 'Profile Badges',
30009: 'Badge Definition',
39089: 'Starter packs',
39092: 'Media starter packs',
30017: 'Create or update a stall',
30018: 'Create or update a product',
30023: 'Long-form Content',

18
src/lib/utils/event_search.ts

@ -207,7 +207,23 @@ export async function searchNip05( @@ -207,7 +207,23 @@ export async function searchNip05(
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) {
const profileFilter = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback(

14
src/lib/utils/nostrUtils.ts

@ -4,7 +4,7 @@ import { ndkInstance } from "../ndk.ts"; @@ -4,7 +4,7 @@ import { ndkInstance } from "../ndk.ts";
import { npubCache } from "./npubCache.ts";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } 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 { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
@ -26,16 +26,7 @@ export const NOSTR_PROFILE_REGEX = @@ -26,16 +26,7 @@ export const NOSTR_PROFILE_REGEX =
export const NOSTR_NOTE_REGEX =
/(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
export interface NostrProfile {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
}
// AI-NOTE: 2025-01-24 - NostrProfile interface moved to search_types.ts for consistency
/**
* HTML escape a string
@ -130,6 +121,7 @@ export async function getUserMetadata( @@ -130,6 +121,7 @@ export async function getUserMetadata(
banner: profile?.banner,
website: profile?.website,
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);

2
src/lib/utils/npubCache.ts

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

34
src/lib/utils/profileCache.ts

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

801
src/lib/utils/profile_search.ts

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
import { ndkInstance, activeInboxRelays } from "../ndk.ts";
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts";
import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
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 type { NostrProfile, ProfileSearchResult } from "./search_types.ts";
import {
@ -11,138 +11,418 @@ import { @@ -11,138 +11,418 @@ import {
normalizeSearchTerm,
createProfileFromEvent,
} 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(
searchTerm: string,
): Promise<ProfileSearchResult> {
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
interface UserListCache {
lists: any[];
pubkeys: Set<string>;
lastUpdated: number;
isUpdating: boolean;
}
console.log(
"searchProfiles called with:",
searchTerm,
"normalized:",
normalizedSearchTerm,
);
/**
* Search strategy types
*/
type SearchStrategy = 'npub' | 'nip05' | 'userLists' | 'nip05Domains' | 'relaySearch';
// Check cache first
const cachedResult = searchCache.get("profile", normalizedSearchTerm);
if (cachedResult) {
console.log("Found cached result for:", normalizedSearchTerm);
const profiles = cachedResult.events
.map((event) => {
try {
const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData);
} catch {
return null;
}
})
.filter(Boolean) as NostrProfile[];
/**
* Global user list cache instance
*/
let userListCache: UserListCache | null = null;
console.log("Cached profiles found:", profiles.length);
return { profiles, Status: {} };
/**
* 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 {
const { lists, pubkeys } = await updateUserListCache();
console.log("profile_search: Background user list cache update completed");
} catch (error) {
console.warn("profile_search: Background user list cache update failed:", error);
} finally {
if (userListCache) {
userListCache.isUpdating = false;
}
}
}
/**
* 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);
});
}
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 ndk = get(ndkInstance);
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) {
console.error("NDK not initialized");
throw new 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");
}
}
console.log("NDK initialized, starting search logic");
return ndk;
}
let foundProfiles: NostrProfile[] = [];
/**
* Check if search term is a valid npub/nprofile identifier
*/
function isNostrIdentifier(searchTerm: string): boolean {
return searchTerm.startsWith("npub") || searchTerm.startsWith("nprofile");
}
/**
* 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 {
// Check if it's a valid npub/nprofile first
if (
normalizedSearchTerm.startsWith("npub") ||
normalizedSearchTerm.startsWith("nprofile")
) {
try {
const metadata = await getUserMetadata(normalizedSearchTerm);
if (metadata) {
foundProfiles = [metadata];
}
} catch (error) {
console.error("Error fetching metadata for npub:", error);
}
} else if (normalizedSearchTerm.includes("@")) {
// Check if it's a NIP-05 address - normalize it properly
const normalizedNip05 = normalizedSearchTerm.toLowerCase();
try {
const npub = await getNpubFromNip05(normalizedNip05);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub,
};
foundProfiles = [profile];
}
} catch (e) {
console.error("[Search] NIP-05 lookup failed:", e);
}
const cleanId = searchTerm.replace(/^nostr:/, "");
const decoded = nip19.decode(cleanId);
if (!decoded) {
return [];
}
let pubkey: string;
if (decoded.type === "npub") {
pubkey = decoded.data;
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} 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",
);
console.warn("Unsupported identifier type:", decoded.type);
return [];
}
// If no NIP-05 results, try quick relay search
if (foundProfiles.length === 0) {
console.log("No NIP-05 results, trying quick relay search");
foundProfiles = await quickRelaySearch(normalizedSearchTerm, ndk);
console.log(
"Quick relay search completed, found:",
foundProfiles.length,
"profiles",
);
// 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 {
const profileData = JSON.parse(event.content);
const profile = createProfileFromEvent(event, profileData);
return [profile];
} catch (error) {
console.error("Error parsing profile content for npub:", error);
}
}
}
// Cache the results
if (foundProfiles.length > 0) {
const events = foundProfiles.map((profile) => {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = profile.pubkey || "";
return event;
});
// Fallback to metadata
const metadata = await getUserMetadata(searchTerm);
const profileWithPubkey: NostrProfile = {
...metadata,
pubkey: pubkey,
};
return [profileWithPubkey];
} catch (error) {
console.error("Error fetching metadata for npub:", error);
return [];
}
}
const result = {
events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: "profile",
searchTerm: normalizedSearchTerm,
/**
* Search for profiles by NIP-05 address
*/
async function searchByNip05Address(searchTerm: string): Promise<NostrProfile[]> {
try {
const normalizedNip05 = searchTerm.toLowerCase();
const npub = await getNpubFromNip05(normalizedNip05);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub,
};
searchCache.set("profile", normalizedSearchTerm, result);
return [profile];
}
console.log("Search completed, found profiles:", foundProfiles.length);
return { profiles: foundProfiles, Status: {} };
} catch (error) {
console.error("Error searching profiles:", error);
return { profiles: [], Status: {} };
console.error("[Search] NIP-05 lookup failed:", error);
}
return [];
}
/**
* Search for NIP-05 addresses across common domains
* Fuzzy match function for user list searches
*/
async function searchNip05Domains(
function fuzzyMatch(text: string, searchTerm: string): boolean {
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;
}
}
}
return false;
}
/**
* Search for profiles within user's lists with fuzzy matching
*/
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);
}
});
});
if (allPubkeys.length === 0) {
return foundProfiles;
}
console.log(`searchWithinUserLists: Searching ${allPubkeys.length} pubkeys from user lists with fuzzy matching`);
// 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) {
console.warn("searchWithinUserLists: Error fetching batch:", error);
}
}
console.log(`searchWithinUserLists: Found ${foundProfiles.length} matching profiles in user lists with fuzzy matching`);
return foundProfiles;
}
/**
* Search for NIP-05 addresses across common domains
*/
async function searchNip05Domains(searchTerm: string): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = [];
// Enhanced list of common domains for NIP-05 lookups
@ -180,33 +460,25 @@ async function searchNip05Domains( @@ -180,33 +460,25 @@ async function searchNip05Domains(
try {
const npub = await getNpubFromNip05(gitcitadelAddress);
if (npub) {
console.log(
"NIP-05 search: SUCCESS! found npub for gitcitadel.com:",
npub,
);
console.log("NIP-05 search: SUCCESS! found npub for gitcitadel.com:", npub);
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub,
};
console.log(
"NIP-05 search: created profile for gitcitadel.com:",
profile,
);
console.log("NIP-05 search: created profile for gitcitadel.com:", profile);
foundProfiles.push(profile);
return foundProfiles; // Return immediately if we found it on gitcitadel.com
} else {
console.log("NIP-05 search: no npub found for gitcitadel.com");
}
} catch (e) {
console.log("NIP-05 search: error for gitcitadel.com:", e);
} catch (error) {
console.log("NIP-05 search: error for gitcitadel.com:", error);
}
// If gitcitadel.com didn't work, try other domains
console.log("NIP-05 search: gitcitadel.com failed, trying other domains...");
const otherDomains = commonDomains.filter(
(domain) => domain !== "gitcitadel.com",
);
const otherDomains = commonDomains.filter(domain => domain !== "gitcitadel.com");
// Search all other domains in parallel with timeout
const searchPromises = otherDomains.map(async (domain) => {
@ -221,18 +493,13 @@ async function searchNip05Domains( @@ -221,18 +493,13 @@ async function searchNip05Domains(
...metadata,
pubkey: npub,
};
console.log(
"NIP-05 search: created profile for",
nip05Address,
":",
profile,
);
console.log("NIP-05 search: created profile for", nip05Address, ":", profile);
return profile;
} else {
console.log("NIP-05 search: no npub found for", nip05Address);
}
} catch (e) {
console.log("NIP-05 search: error for", nip05Address, ":", e);
} catch (error) {
console.log("NIP-05 search: error for", nip05Address, ":", error);
// Continue to next domain
}
return null;
@ -251,39 +518,57 @@ async function searchNip05Domains( @@ -251,39 +518,57 @@ async function searchNip05Domains(
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
*/
async function quickRelaySearch(
searchTerm: string,
ndk: NDK,
): Promise<NostrProfile[]> {
async function quickRelaySearch(searchTerm: string, ndk: NDK): Promise<NostrProfile[]> {
console.log("quickRelaySearch called with:", searchTerm);
// Normalize the search term for relay search
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("Normalized search term for relay search:", normalizedSearchTerm);
// Use search relays (optimized for profiles) + user's inbox relays + community relays
const userInboxRelays = get(activeInboxRelays);
const quickRelayUrls = [
...searchRelays, // Dedicated profile search relays
...userInboxRelays, // User's personal inbox relays
...communityRelays, // Community relays
...secondaryRelays // Secondary relays as fallback
];
// Deduplicate relay URLs
const uniqueRelayUrls = [...new Set(quickRelayUrls)];
console.log("Using relays for profile search:", uniqueRelayUrls);
const uniqueRelayUrls = getAllRelayUrls();
console.log("Using ALL available relays for profile search:", uniqueRelayUrls);
console.log("Relay breakdown:", {
searchRelays: searchRelays.length,
communityRelays: communityRelays.length,
secondaryRelays: secondaryRelays.length,
localRelays: localRelays.length,
userInboxRelays: get(activeInboxRelays).length,
userOutboxRelays: get(activeOutboxRelays).length,
totalUnique: uniqueRelayUrls.length
});
// Create relay sets for parallel search
const relaySets = uniqueRelayUrls
.map((url) => {
try {
return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (e) {
console.warn(`Failed to create relay set for ${url}:`, e);
} catch (error) {
console.warn(`Failed to create relay set for ${url}:`, error);
return null;
}
})
@ -297,9 +582,7 @@ async function quickRelaySearch( @@ -297,9 +582,7 @@ async function quickRelaySearch(
const foundInRelay: NostrProfile[] = [];
let eventCount = 0;
console.log(
`Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`,
);
console.log(`Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`);
const sub = ndk.subscribe(
{ kinds: [0] },
@ -312,22 +595,15 @@ async function quickRelaySearch( @@ -312,22 +595,15 @@ async function quickRelaySearch(
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName =
profileData.displayName || profileData.display_name || "";
const displayName = profileData.displayName || profileData.display_name || "";
const display_name = profileData.display_name || "";
const name = profileData.name || "";
const nip05 = profileData.nip05 || "";
const about = profileData.about || "";
// Check if any field matches the search term using normalized comparison
const matchesDisplayName = fieldMatches(
displayName,
normalizedSearchTerm,
);
const matchesDisplay_name = fieldMatches(
display_name,
normalizedSearchTerm,
);
// Check if any field matches the search term using exact field matching only
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesDisplay_name = fieldMatches(display_name, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
@ -375,7 +651,7 @@ async function quickRelaySearch( @@ -375,7 +651,7 @@ async function quickRelaySearch(
);
sub.stop();
resolve(foundInRelay);
}, 1500); // 1.5 second timeout per relay
}, TIMEOUTS.RELAY_TIMEOUT);
});
});
@ -395,8 +671,211 @@ async function quickRelaySearch( @@ -395,8 +671,211 @@ async function quickRelaySearch(
}
}
console.log(
`Total unique profiles found: ${Object.keys(allProfiles).length}`,
);
console.log(`Total unique profiles found: ${Object.keys(allProfiles).length}`);
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 = { @@ -27,6 +27,9 @@ export const TIMEOUTS = {
/** Cache cleanup interval */
CACHE_CLEANUP: 60000,
/** Timeout for relay search operations */
RELAY_TIMEOUT: 1500, // 1.5 seconds for quick relay searches
} as const;
// Cache duration constants (in milliseconds)
@ -54,6 +57,12 @@ export const SEARCH_LIMITS = { @@ -54,6 +57,12 @@ export const SEARCH_LIMITS = {
/** Limit for second-order search results */
SECOND_ORDER_RESULTS: 100,
/** Maximum results for profile searches */
MAX_PROFILE_RESULTS: 20,
/** Batch size for profile fetching operations */
BATCH_SIZE: 50,
} as const;
// Nostr event kind ranges

3
src/lib/utils/search_types.ts

@ -27,6 +27,9 @@ export interface NostrProfile { @@ -27,6 +27,9 @@ export interface NostrProfile {
website?: string;
lud16?: 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 { @@ -106,5 +106,8 @@ export function createProfileFromEvent(event: NDKEvent, profileData: any): any {
website: profileData.website,
lud16: profileData.lud16,
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
};
}

570
src/lib/utils/subscription_search.ts

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
// deno-lint-ignore-file no-explicit-any
import { ndkInstance } from "../ndk.ts";
import { getMatchingTags, getNpubFromNip05 } from "./nostrUtils.ts";
import { getMatchingTags } from "./nostrUtils.ts";
import { nip19 } from "./nostrUtils.ts";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
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 type {
SearchResult,
@ -15,11 +15,12 @@ import type { @@ -15,11 +15,12 @@ import type {
import {
fieldMatches,
nip05Matches,
COMMON_DOMAINS,
isEmojiReaction,
} from "./search_utils.ts";
import { TIMEOUTS, SEARCH_LIMITS } from "./search_constants.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
const normalizeUrl = (url: string): string => {
@ -55,18 +56,104 @@ export async function searchBySubscription( @@ -55,18 +56,104 @@ export async function searchBySubscription(
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);
if (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);
@ -79,6 +166,9 @@ export async function searchBySubscription( @@ -79,6 +166,9 @@ export async function searchBySubscription(
const searchState = createSearchState();
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
searchState.timeoutId = setTimeout(() => {
console.log("subscription_search: Search timeout reached");
@ -144,26 +234,7 @@ export async function searchBySubscription( @@ -144,26 +234,7 @@ export async function searchBySubscription(
);
searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// AI-NOTE: 2025-01-08 - For profile searches, return immediately when found
// 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)
// Start Phase 2 in background for additional results
searchOtherRelaysInBackground(
searchType,
searchFilter,
@ -178,69 +249,9 @@ export async function searchBySubscription( @@ -178,69 +249,9 @@ export async function searchBySubscription(
"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(
"subscription_search: No results from primary relay, continuing to Phase 2",
);
}
console.log(
"subscription_search: No results from primary relay, continuing to Phase 2",
);
}
} catch (error) {
console.error(
@ -263,11 +274,9 @@ export async function searchBySubscription( @@ -263,11 +274,9 @@ export async function searchBySubscription(
cleanup,
);
// AI-NOTE: 2025-01-08 - Log performance for non-profile searches
if (searchType !== "n") {
const elapsed = Date.now() - startTime;
console.log(`subscription_search: ${searchType} search completed in ${elapsed}ms`);
}
// Log performance for all searches
const elapsed = Date.now() - startTime;
console.log(`subscription_search: ${searchType} search completed in ${elapsed}ms`);
return result;
}
@ -283,9 +292,10 @@ function createSearchState() { @@ -283,9 +292,10 @@ function createSearchState() {
tTagEvents: [] as NDKEvent[],
eventIds: 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,
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( @@ -351,51 +361,14 @@ async function createSearchFilter(
/**
* 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(
normalizedSearchTerm: string,
): Promise<SearchFilter> {
// For npub searches, try to decode the search term first
try {
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
}
// AI-NOTE: 2025-01-24 - Profile search logic is now centralized in profile_search.ts
// This function is kept for compatibility but profile searches should go through searchProfiles function
return {
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE },
subscriptionType: "profile",
@ -480,12 +453,8 @@ function processPrimaryRelayResults( @@ -480,12 +453,8 @@ function processPrimaryRelayResults(
try {
if (searchType === "n") {
processProfileEvent(
event,
subscriptionType,
normalizedSearchTerm,
searchState,
);
// AI-NOTE: 2025-01-24 - Profile processing is now handled by centralized searchProfiles function
// No need to process profile events here as they're handled in profile_search.ts
} else {
processContentEvent(event, searchType, searchState);
}
@ -498,64 +467,11 @@ function processPrimaryRelayResults( @@ -498,64 +467,11 @@ function processPrimaryRelayResults(
console.log(
"subscription_search: Processed events - firstOrder:",
searchState.firstOrderEvents.length,
"profiles:",
searchState.foundProfiles.length,
"tTag:",
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
*/
@ -601,9 +517,8 @@ function hasResults( @@ -601,9 +517,8 @@ function hasResults(
searchState: any,
searchType: SearchSubscriptionType,
): boolean {
if (searchType === "n") {
return searchState.foundProfiles.length > 0;
} else if (searchType === "d") {
// AI-NOTE: 2025-01-24 - Profile searches (n:) are now handled by centralized searchProfiles function
if (searchType === "d") {
return searchState.firstOrderEvents.length > 0;
} else if (searchType === "t") {
return searchState.tTagEvents.length > 0;
@ -619,13 +534,17 @@ function createSearchResult( @@ -619,13 +534,17 @@ function createSearchResult(
searchType: SearchSubscriptionType,
normalizedSearchTerm: string,
): 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 {
events:
searchType === "n"
? searchState.foundProfiles
: searchType === "t"
? searchState.tTagEvents
: searchState.firstOrderEvents,
events,
secondOrder: [],
tTagEvents: [],
eventIds: searchState.eventIds,
@ -649,13 +568,14 @@ function searchOtherRelaysInBackground( @@ -649,13 +568,14 @@ function searchOtherRelaysInBackground(
// 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
const allRelays = Array.from(ndk.pool.relays.values());
const otherRelays = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values())),
new Set(allRelays),
ndk,
);
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
const sub = ndk.subscribe(
@ -675,12 +595,8 @@ function searchOtherRelaysInBackground( @@ -675,12 +595,8 @@ function searchOtherRelaysInBackground(
sub.on("event", (event: NDKEvent) => {
try {
if (searchType === "n") {
processProfileEvent(
event,
searchFilter.subscriptionType,
searchState.normalizedSearchTerm,
searchState,
);
// AI-NOTE: 2025-01-24 - Profile processing is now handled by centralized searchProfiles function
// No need to process profile events here as they're handled in profile_search.ts
} else {
processContentEvent(event, searchType, searchState);
}
@ -713,92 +629,21 @@ function processEoseResults( @@ -713,92 +629,21 @@ function processEoseResults(
searchFilter: SearchFilter,
callbacks?: SearchCallbacks,
): SearchResult {
if (searchType === "n") {
return processProfileEoseResults(searchState, searchFilter, callbacks);
} else if (searchType === "d") {
// AI-NOTE: 2025-01-24 - Profile searches (n:) are now handled by centralized searchProfiles function
if (searchType === "d") {
return processContentEoseResults(searchState, searchType);
} else if (searchType === "t") {
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);
}
/**
* 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,
};
}
// AI-NOTE: 2025-01-24 - processProfileEoseResults function removed as profile searches are now handled by centralized searchProfiles function
/**
* Process content EOSE results
@ -855,6 +700,37 @@ function processTTagEoseResults(searchState: any): SearchResult { @@ -855,6 +700,37 @@ function processTTagEoseResults(searchState: any): SearchResult {
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 {
events: searchState.tTagEvents,
secondOrder: [],
@ -924,38 +800,92 @@ async function performSecondOrderSearchInBackground( @@ -924,38 +800,92 @@ async function performSecondOrderSearchInBackground(
console.log("subscription_search: Using", activeRelays.length, "active relays for second-order search");
// Search for events that mention this pubkey via p-tags
const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging
const pTagEvents = await ndk.fetchEvents(
pTagFilter,
{ closeOnEose: true },
relaySet,
);
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 authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging
const authorEvents = await ndk.fetchEvents(
authorFilter,
{ closeOnEose: true },
relaySet,
);
console.log("subscription_search: Found", authorEvents.size, "events written by", targetPubkey);
// AI-NOTE: 2025-01-24 - Search for events that mention this pubkey in various tags
// Focus on events that are actually about the profile, not just random mentions
const searchFilters = [
{ authors: [targetPubkey], limit: 50 }, // Events written by this pubkey (most relevant)
{ "#p": [targetPubkey], limit: 25 }, // p-tags (mentions) - reduced limit
{ "#q": [targetPubkey], limit: 25 }, // q-tags (quotes) - reduced limit
];
const searchPromises = searchFilters.map(async (filter, index) => {
const filterName = index === 0 ? "author" : index === 1 ? "p-tag" : "q-tag";
try {
const events = await ndk.fetchEvents(filter, { closeOnEose: true }, relaySet);
console.log(`subscription_search: Found ${events.size} events with ${filterName} for ${targetPubkey}`);
return filterUnwantedEvents(Array.from(events));
} catch (error) {
console.warn(`subscription_search: Error searching ${filterName}:`, error);
return [];
}
});
// Filter out unwanted events from both sets
const filteredPTagEvents = filterUnwantedEvents(Array.from(pTagEvents));
const filteredAuthorEvents = filterUnwantedEvents(Array.from(authorEvents));
const searchResults = await Promise.allSettled(searchPromises);
console.log("subscription_search: After filtering unwanted events:", filteredPTagEvents.length, "p-tag events,", filteredAuthorEvents.length, "author events");
// Combine all results, prioritizing author events
for (const result of searchResults) {
if (result.status === "fulfilled") {
allSecondOrderEvents.push(...result.value);
}
}
// Combine both sets of events
allSecondOrderEvents = [...filteredPTagEvents, ...filteredAuthorEvents];
// AI-NOTE: 2025-01-24 - Filter events to ensure they're relevant to the profile
// Remove events that are just random mentions without meaningful content
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;
}
// 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
}
// Keep events with relevant tags but limit them
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") {
// Parallel fetch for #e and #a tag events
const allRelays = Array.from(ndk.pool.relays.values());
const relaySet = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values())),
new Set(allRelays),
ndk,
);
console.debug('subscription_search: Second-order search using ALL relays:',
allRelays.map((r: any) => r.url));
const [eTagEvents, aTagEvents] = await Promise.all([
eventIds.size > 0
? ndk.fetchEvents(

253
src/lib/utils/user_lists.ts

@ -0,0 +1,253 @@ @@ -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"; @@ -13,7 +13,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import EventInput from "$lib/components/EventInput.svelte";
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays, logCurrentRelayConfiguration } from "$lib/ndk";
@ -21,6 +21,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -21,6 +21,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte";
import { checkCommunity } from "$lib/utils/search_utility";
import { parseRepostContent, parseContent } from "$lib/utils/notification_utils";
import { fetchCurrentUserLists, isPubkeyInUserLists } from "$lib/utils/user_lists";
let loading = $state(false);
let error = $state<string | null>(null);
@ -54,6 +55,13 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -54,6 +55,13 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
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) {
event = newEvent;
showSidePanel = true;
@ -69,10 +77,35 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -69,10 +77,35 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
// searchInProgress = false;
// secondOrderSearchMessage = null;
// AI-NOTE: 2025-01-24 - Properly parse profile data for kind 0 events
if (newEvent.kind === 0) {
try {
profile = JSON.parse(newEvent.content);
} catch {
const parsedProfile = parseProfileContent(newEvent);
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;
}
} else {
@ -82,6 +115,20 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -82,6 +115,20 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
// AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author
if (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"; @@ -160,6 +207,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
addresses: Set<string> = new Set(),
searchTypeParam?: string,
searchTermParam?: string,
loading: boolean = false, // AI-NOTE: 2025-01-24 - Add loading parameter for second-order search message logic
) {
searchResults = results;
secondOrderResults = secondOrder;
@ -230,9 +278,31 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -230,9 +278,31 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
const cachePromises = Array.from(uniquePubkeys).map(pubkey => cacheProfileForPubkey(pubkey));
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`);
}
// 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() {
searchType = null;
searchTerm = null;
@ -310,6 +380,8 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -310,6 +380,8 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
website?: string;
lud16?: string;
nip05?: string;
isInUserLists?: boolean;
listKinds?: number[];
} | null {
if (event.kind !== 0 || !event.content) {
return null;
@ -365,6 +437,16 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -365,6 +437,16 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
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) {
loading = val;
searchInProgress =
@ -394,7 +476,9 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -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 };
console.log("Community status updated:", communityStatus);
}
@ -490,7 +574,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -490,7 +574,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
</Heading>
<div class="space-y-4">
{#each searchResults as result, index}
{@const profileData = parseProfileContent(result)}
{@const profileData = (result as any).profileData || parseProfileContent(result)}
<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"
onclick={() => handleEventFound(result)}
@ -504,6 +588,22 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -504,6 +588,22 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span class="text-xs text-gray-600 dark:text-gray-400"
>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]}
<div
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"; @@ -519,7 +619,8 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
/>
</svg>
</div>
{:else}
{/if}
{#if !profileData?.isInUserLists && !(result.pubkey && communityStatus[result.pubkey])}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
@ -531,11 +632,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -531,11 +632,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
{formatEventDate(result)}
</span>
</div>
{#if result.kind === 0 && profileData}
@ -664,7 +761,22 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -664,7 +761,22 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span class="text-xs text-gray-600 dark:text-gray-400"
>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
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"
@ -691,11 +803,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -691,11 +803,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
{formatEventDate(result)}
</span>
</div>
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
@ -825,7 +933,22 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -825,7 +933,22 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span class="text-xs text-gray-600 dark:text-gray-400"
>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
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"
@ -852,11 +975,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -852,11 +975,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<span
class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(
result.created_at * 1000,
).toLocaleDateString()
: "Unknown date"}
{formatEventDate(result)}
</span>
</div>
{#if result.kind === 0 && profileData}
@ -997,7 +1116,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -997,7 +1116,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
{/if}
<div class="min-w-0 overflow-hidden">
<EventDetails {event} {profile} {searchValue} />
<EventDetails {event} {profile} {searchValue} communityStatusMap={communityStatus} />
</div>
<div class="min-w-0 overflow-hidden">
<RelayActions {event} />
@ -1007,7 +1126,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -1007,7 +1126,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<CommentViewer {event} />
</div>
{#if isLoggedIn && userPubkey}
{#if user?.signedIn}
<div class="mt-8 min-w-0 overflow-hidden">
<Heading tag="h3" class="h-leather mb-4 break-words">Add Comment</Heading>
<CommentBox {event} {userRelayPreference} />

6
src/routes/visualize/+page.svelte

@ -17,7 +17,7 @@ @@ -17,7 +17,7 @@
import type { PageData } from './$types';
import { getEventKindColor, getEventKindName } from "$lib/utils/eventColors";
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
// These functions handle the complex logic of finding publications by tags
// and extracting their associated content events
@ -122,8 +122,8 @@ @@ -122,8 +122,8 @@
}
// Get the current user's pubkey
const currentUserPubkey = get(activePubkey);
if (!currentUserPubkey) {
const currentUserPubkey = get(userStore).pubkey;
if (!currentUserPubkey) {
console.warn("No logged-in user, cannot fetch user's follow list");
return [];
}

Loading…
Cancel
Save