Fix some ui bugs

This commit is contained in:
Jon Staab
2026-06-11 15:26:06 -07:00
parent 129697fccb
commit 287e599753
31 changed files with 1038 additions and 749 deletions
+2
View File
@@ -12,8 +12,10 @@
"@noble/curves": "^1.9.7",
"@noble/curves-v2": "npm:@noble/curves@^2.2.0",
"@noble/hashes": "^2.2.0",
"@welshman/app": "0.8.16",
"@welshman/lib": "^0.8.16",
"@welshman/net": "0.8.16",
"@welshman/router": "0.8.16",
"@welshman/signer": "0.8.16",
"@welshman/store": "0.8.16",
"@welshman/util": "^0.8.16",
+430
View File
@@ -17,12 +17,18 @@ importers:
'@noble/hashes':
specifier: ^2.2.0
version: 2.2.0
'@welshman/app':
specifier: 0.8.16
version: 0.8.16(24b081ba5fe14e8b9a696e60e9ee7ed8)
'@welshman/lib':
specifier: ^0.8.16
version: 0.8.16
'@welshman/net':
specifier: 0.8.16
version: 0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0)
'@welshman/router':
specifier: 0.8.16
version: 0.8.16(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))
'@welshman/signer':
specifier: 0.8.16
version: 0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.6))(nostr-tools@2.23.5(typescript@6.0.3))
@@ -153,6 +159,45 @@ packages:
'@capacitor/core@7.6.6':
resolution: {integrity: sha512-TCxNTpi9bWlM31n8hkLKGrugDGcgJpSqafpDoEEn/Hj6KWxjxes7m0ASfrZ2pOmsqMNGjE6bYGZr905CueVAIw==}
'@cbor-extract/cbor-extract-darwin-arm64@2.2.2':
resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==}
cpu: [arm64]
os: [darwin]
'@cbor-extract/cbor-extract-darwin-x64@2.2.2':
resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==}
cpu: [x64]
os: [darwin]
'@cbor-extract/cbor-extract-linux-arm64@2.2.2':
resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==}
cpu: [arm64]
os: [linux]
'@cbor-extract/cbor-extract-linux-arm@2.2.2':
resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==}
cpu: [arm]
os: [linux]
'@cbor-extract/cbor-extract-linux-x64@2.2.2':
resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==}
cpu: [x64]
os: [linux]
'@cbor-extract/cbor-extract-win32-x64@2.2.2':
resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==}
cpu: [x64]
os: [win32]
'@cmdcode/buff@2.2.5':
resolution: {integrity: sha512-+nc3QDoJ+MU/fp+YkX6WuEjJrXLF6ME+eVX1sj5a+MfBKO9LWb4R9Y2zH6APBrySd7nFr48ozscAui7SKvLmXg==}
'@cmdcode/frost@1.1.4':
resolution: {integrity: sha512-GLbiFdpHKTR576vlComrWyrdexR7j2tezbJmJQTfFGOR18xb2yM7E6PEKJWirDGrh1C/pLv/uhwgoSp5HLLfdw==}
'@cmdcode/nostr-p2p@2.0.11':
resolution: {integrity: sha512-hZpWYqRPdvXoaG5LaP2/dR1ObloQxPUYHAA1japQveGdhPbXWiS5aKPxOyUXqgjHDShWeXQ3giOTg6SsCJwnYA==}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -171,6 +216,9 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@frostr/bifrost@1.0.7':
resolution: {integrity: sha512-9PO8s8ra7Cf94HqsF0sArRkLLFYqDyGfRKUOflTWMGgaDvSWIksNA8PckcXvy5/G6u4RtAkTAqki47+ga+7yow==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -193,6 +241,10 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
'@noble/ciphers@2.1.1':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'}
@@ -224,6 +276,55 @@ packages:
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
'@peculiar/asn1-cms@2.7.0':
resolution: {integrity: sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==}
'@peculiar/asn1-csr@2.7.0':
resolution: {integrity: sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==}
'@peculiar/asn1-ecc@2.7.0':
resolution: {integrity: sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==}
'@peculiar/asn1-pfx@2.7.0':
resolution: {integrity: sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==}
'@peculiar/asn1-pkcs8@2.7.0':
resolution: {integrity: sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==}
'@peculiar/asn1-pkcs9@2.7.0':
resolution: {integrity: sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==}
'@peculiar/asn1-rsa@2.7.0':
resolution: {integrity: sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==}
'@peculiar/asn1-schema@2.7.0':
resolution: {integrity: sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==}
'@peculiar/asn1-x509-attr@2.7.0':
resolution: {integrity: sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==}
'@peculiar/asn1-x509@2.7.0':
resolution: {integrity: sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==}
'@peculiar/utils@2.0.3':
resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
'@peculiar/x509@1.14.3':
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@pomade/core@0.2.5':
resolution: {integrity: sha512-kyiDaN03zV1/LHy7B52HV+rVr5wi1DOWRUE21+A9TdHpF7+BVuY7w5SCrDw/Dm6qDNcwnuS/hWj9uBsRXJAC1g==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@frostr/bifrost': ^1.0.7
'@noble/hashes': ^2.0.1
'@welshman/lib': ^0.8.0-pre.1
'@welshman/net': ^0.8.0-pre.1
'@welshman/signer': ^0.8.0-pre.1
'@welshman/util': ^0.8.0-pre.1
nostr-tools: ^2.19.3
'@rolldown/binding-android-arm64@1.0.3':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -494,6 +595,31 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@vbyte/buff@1.1.0':
resolution: {integrity: sha512-uDGTsS37Vg9JghKpMEBYZLAwn2saY0y0swLlK3+OSnQ8xe17mHCo7/92gkzmgpBUvhhTW2/oSxDlYw193lwJIA==}
'@welshman/app@0.8.16':
resolution: {integrity: sha512-TjF/9IkHKMTVR+1fZ6mJl07gz89X3g7NnTGxFathCP4jyeYxb6EYlUyKeO3qQH24rpP8ok4NvAjhehO757OxMw==}
peerDependencies:
'@pomade/core': ^0.2.1
'@welshman/feeds': 0.8.16
'@welshman/lib': 0.8.16
'@welshman/net': 0.8.16
'@welshman/router': 0.8.16
'@welshman/signer': 0.8.16
'@welshman/store': 0.8.16
'@welshman/util': 0.8.16
svelte: ^4.0.0 || ^5.0.0
'@welshman/feeds@0.8.16':
resolution: {integrity: sha512-cj6c9LUqzkWtES2iCTj9x3qgvg1xSe6s59w1B2osLKtm0X8RK+PWdE0qt7gT7APy1cFPbX9hxKoMjYTUiTW6tw==}
peerDependencies:
'@welshman/lib': 0.8.16
'@welshman/net': 0.8.16
'@welshman/router': 0.8.16
'@welshman/signer': 0.8.16
'@welshman/util': 0.8.16
'@welshman/lib@0.8.16':
resolution: {integrity: sha512-g9r6NgGFaxQGvPRJkw6ff2aiBgWwqUSNCYXG0QezJh6DOYBE/QyXJwqxdea9d3y7e6YiBQ1zNRJWhgJAm073JQ==}
engines: {node: '>=12.0.0'}
@@ -504,6 +630,13 @@ packages:
'@welshman/lib': 0.8.16
'@welshman/util': 0.8.16
'@welshman/router@0.8.16':
resolution: {integrity: sha512-/ybAllWVGUSCVXCcBa9VILzErhj0fsm+ZGWtBiW1nYSv6eQDHaD1fgNREiFLsmaVrK3nUwkAEd0FcvCMtTm0NQ==}
peerDependencies:
'@welshman/lib': 0.8.16
'@welshman/net': 0.8.16
'@welshman/util': 0.8.16
'@welshman/signer@0.8.16':
resolution: {integrity: sha512-iQMksWe/TIiXbHdnfnK+q3l4eLgBo2Wlw8rfDnb8hK4cC4VlbciOHEi+p1HIIxu/CftJZ4P15BnFueOEb90rsw==}
peerDependencies:
@@ -545,6 +678,10 @@ packages:
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
engines: {node: '>= 0.4'}
asn1js@3.0.10:
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
engines: {node: '>=12.0.0'}
autoprefixer@10.5.0:
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
engines: {node: ^10 || ^12 || >=14}
@@ -583,6 +720,13 @@ packages:
caniuse-lite@1.0.30001797:
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
cbor-extract@2.2.2:
resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==}
hasBin: true
cbor-x@1.6.4:
resolution: {integrity: sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -669,6 +813,10 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fuse.js@7.4.2:
resolution: {integrity: sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==}
engines: {node: '>=10'}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -676,6 +824,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
hash-wasm@4.12.0:
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
html-entities@2.3.3:
resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
@@ -815,6 +966,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
node-gyp-build-optional-packages@5.1.1:
resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==}
hasBin: true
node-releases@2.0.47:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'}
@@ -859,6 +1014,16 @@ packages:
resolution: {integrity: sha512-lDdP5VzJDwVdWFDOUHKtkvWGxnsKt1o7Q9Sla19MMX03Lzzp4Pxwxs+TndcFZaNiSchYSDgSnjCsm7olV5JV3g==}
hasBin: true
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.5:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -906,13 +1071,28 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
throttle-debounce@5.0.2:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
trava@1.2.1:
resolution: {integrity: sha512-2dt4qJEVtYIwCoihoO+HfVLN+uid7ibtKnZ4pjLF4YqyDOwGuP0uTGLTUnvNkAp92qh3YmgRv4wojxnj5yBPAQ==}
engines: {node: '>=6.0.0', npm: '>=4.0.0'}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsyringe@4.10.0:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
@@ -1009,6 +1189,12 @@ packages:
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
'@babel/code-frame@7.29.7':
@@ -1126,6 +1312,45 @@ snapshots:
dependencies:
tslib: 2.8.1
'@cbor-extract/cbor-extract-darwin-arm64@2.2.2':
optional: true
'@cbor-extract/cbor-extract-darwin-x64@2.2.2':
optional: true
'@cbor-extract/cbor-extract-linux-arm64@2.2.2':
optional: true
'@cbor-extract/cbor-extract-linux-arm@2.2.2':
optional: true
'@cbor-extract/cbor-extract-linux-x64@2.2.2':
optional: true
'@cbor-extract/cbor-extract-win32-x64@2.2.2':
optional: true
'@cmdcode/buff@2.2.5':
dependencies:
'@noble/hashes': 1.8.0
'@scure/base': 1.2.6
'@cmdcode/frost@1.1.4':
dependencies:
'@noble/curves': 2.2.0
'@noble/hashes': 2.2.0
'@vbyte/buff': 1.1.0
'@cmdcode/nostr-p2p@2.0.11(typescript@6.0.3)':
dependencies:
'@cmdcode/buff': 2.2.5
'@noble/ciphers': 1.3.0
'@noble/curves': 1.9.7
nostr-tools: 2.23.5(typescript@6.0.3)
zod: 3.25.76
transitivePeerDependencies:
- typescript
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -1153,6 +1378,17 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
'@frostr/bifrost@1.0.7(typescript@6.0.3)':
dependencies:
'@cmdcode/buff': 2.2.5
'@cmdcode/frost': 1.1.4
'@cmdcode/nostr-p2p': 2.0.11(typescript@6.0.3)
'@noble/ciphers': 1.3.0
'@noble/curves': 1.9.7
zod: 3.25.76
transitivePeerDependencies:
- typescript
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1179,6 +1415,8 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@noble/ciphers@1.3.0': {}
'@noble/ciphers@2.1.1': {}
'@noble/curves@1.9.7':
@@ -1201,6 +1439,114 @@ snapshots:
'@oxc-project/types@0.133.0': {}
'@peculiar/asn1-cms@2.7.0':
dependencies:
'@peculiar/asn1-schema': 2.7.0
'@peculiar/asn1-x509': 2.7.0
'@peculiar/asn1-x509-attr': 2.7.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-csr@2.7.0':
dependencies:
'@peculiar/asn1-schema': 2.7.0
'@peculiar/asn1-x509': 2.7.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-ecc@2.7.0':
dependencies:
'@peculiar/asn1-schema': 2.7.0
'@peculiar/asn1-x509': 2.7.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-pfx@2.7.0':
dependencies:
'@peculiar/asn1-cms': 2.7.0
'@peculiar/asn1-pkcs8': 2.7.0
'@peculiar/asn1-rsa': 2.7.0
'@peculiar/asn1-schema': 2.7.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.7.0':
dependencies:
'@peculiar/asn1-schema': 2.7.0
'@peculiar/asn1-x509': 2.7.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.7.0':
dependencies:
'@peculiar/asn1-cms': 2.7.0
'@peculiar/asn1-pfx': 2.7.0
'@peculiar/asn1-pkcs8': 2.7.0
'@peculiar/asn1-schema': 2.7.0
'@peculiar/asn1-x509': 2.7.0
'@peculiar/asn1-x509-attr': 2.7.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-rsa@2.7.0':
dependencies:
'@peculiar/asn1-schema': 2.7.0
'@peculiar/asn1-x509': 2.7.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-schema@2.7.0':
dependencies:
'@peculiar/utils': 2.0.3
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.7.0':
dependencies:
'@peculiar/asn1-schema': 2.7.0
'@peculiar/asn1-x509': 2.7.0
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/asn1-x509@2.7.0':
dependencies:
'@peculiar/asn1-schema': 2.7.0
'@peculiar/utils': 2.0.3
asn1js: 3.0.10
tslib: 2.8.1
'@peculiar/utils@2.0.3':
dependencies:
tslib: 2.8.1
'@peculiar/x509@1.14.3':
dependencies:
'@peculiar/asn1-cms': 2.7.0
'@peculiar/asn1-csr': 2.7.0
'@peculiar/asn1-ecc': 2.7.0
'@peculiar/asn1-pkcs9': 2.7.0
'@peculiar/asn1-rsa': 2.7.0
'@peculiar/asn1-schema': 2.7.0
'@peculiar/asn1-x509': 2.7.0
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.10.0
'@pomade/core@0.2.5(@frostr/bifrost@1.0.7(typescript@6.0.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.6))(nostr-tools@2.23.5(typescript@6.0.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(nostr-tools@2.23.5(typescript@6.0.3))':
dependencies:
'@frostr/bifrost': 1.0.7(typescript@6.0.3)
'@noble/hashes': 2.2.0
'@peculiar/x509': 1.14.3
'@welshman/lib': 0.8.16
'@welshman/net': 0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0)
'@welshman/signer': 0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.6))(nostr-tools@2.23.5(typescript@6.0.3))
'@welshman/util': 0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3))
cbor-x: 1.6.4
hash-wasm: 4.12.0
nostr-tools: 2.23.5(typescript@6.0.3)
zod: 4.4.3
'@rolldown/binding-android-arm64@1.0.3':
optional: true
@@ -1402,6 +1748,31 @@ snapshots:
dependencies:
'@types/node': 25.9.2
'@vbyte/buff@1.1.0': {}
'@welshman/app@0.8.16(24b081ba5fe14e8b9a696e60e9ee7ed8)':
dependencies:
'@pomade/core': 0.2.5(@frostr/bifrost@1.0.7(typescript@6.0.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.6))(nostr-tools@2.23.5(typescript@6.0.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(nostr-tools@2.23.5(typescript@6.0.3))
'@welshman/feeds': 0.8.16(de5f4638d0c169f9df3bf1d44638788d)
'@welshman/lib': 0.8.16
'@welshman/net': 0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0)
'@welshman/router': 0.8.16(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))
'@welshman/signer': 0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.6))(nostr-tools@2.23.5(typescript@6.0.3))
'@welshman/store': 0.8.16(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(svelte@5.56.3)
'@welshman/util': 0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3))
fuse.js: 7.4.2
svelte: 5.56.3
throttle-debounce: 5.0.2
'@welshman/feeds@0.8.16(de5f4638d0c169f9df3bf1d44638788d)':
dependencies:
'@welshman/lib': 0.8.16
'@welshman/net': 0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0)
'@welshman/router': 0.8.16(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))
'@welshman/signer': 0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.6))(nostr-tools@2.23.5(typescript@6.0.3))
'@welshman/util': 0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3))
trava: 1.2.1
'@welshman/lib@0.8.16':
dependencies:
'@scure/base': 1.2.6
@@ -1417,6 +1788,12 @@ snapshots:
transitivePeerDependencies:
- ws
'@welshman/router@0.8.16(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))':
dependencies:
'@welshman/lib': 0.8.16
'@welshman/net': 0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0)
'@welshman/util': 0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3))
'@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@6.0.3)))(nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.6))(nostr-tools@2.23.5(typescript@6.0.3))':
dependencies:
'@noble/curves': 1.9.7
@@ -1458,6 +1835,12 @@ snapshots:
aria-query@5.3.1: {}
asn1js@3.0.10:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.5
tslib: 2.8.1
autoprefixer@10.5.0(postcss@8.5.15):
dependencies:
browserslist: 4.28.2
@@ -1497,6 +1880,22 @@ snapshots:
caniuse-lite@1.0.30001797: {}
cbor-extract@2.2.2:
dependencies:
node-gyp-build-optional-packages: 5.1.1
optionalDependencies:
'@cbor-extract/cbor-extract-darwin-arm64': 2.2.2
'@cbor-extract/cbor-extract-darwin-x64': 2.2.2
'@cbor-extract/cbor-extract-linux-arm': 2.2.2
'@cbor-extract/cbor-extract-linux-arm64': 2.2.2
'@cbor-extract/cbor-extract-linux-x64': 2.2.2
'@cbor-extract/cbor-extract-win32-x64': 2.2.2
optional: true
cbor-x@1.6.4:
optionalDependencies:
cbor-extract: 2.2.2
clsx@2.1.1: {}
convert-source-map@2.0.0: {}
@@ -1555,10 +1954,14 @@ snapshots:
fsevents@2.3.3:
optional: true
fuse.js@7.4.2: {}
gensync@1.0.0-beta.2: {}
graceful-fs@4.2.11: {}
hash-wasm@4.12.0: {}
html-entities@2.3.3: {}
idb@8.0.3: {}
@@ -1654,6 +2057,11 @@ snapshots:
nanoid@3.3.12: {}
node-gyp-build-optional-packages@5.1.1:
dependencies:
detect-libc: 2.1.2
optional: true
node-releases@2.0.47: {}
nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.6):
@@ -1703,6 +2111,14 @@ snapshots:
nouislider: 15.8.1
vanilla-calendar-pro: 3.1.0
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.5: {}
reflect-metadata@0.2.2: {}
rolldown@1.0.3:
dependencies:
'@oxc-project/types': 0.133.0
@@ -1778,13 +2194,23 @@ snapshots:
tapable@2.3.3: {}
throttle-debounce@5.0.2: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
trava@1.2.1: {}
tslib@1.14.1: {}
tslib@2.8.1: {}
tsyringe@4.10.0:
dependencies:
tslib: 1.14.1
typescript@6.0.3: {}
undici-types@7.24.6: {}
@@ -1831,3 +2257,7 @@ snapshots:
yallist@3.1.1: {}
zimmerframe@1.1.4: {}
zod@3.25.76: {}
zod@4.4.3: {}
+4 -2
View File
@@ -1,11 +1,13 @@
import { Show } from "solid-js"
import { account } from "./store"
import { pubkey } from "@welshman/app"
import { useReadable } from "./lib/stores"
import Login from "./Login"
import Layout from "./Layout"
export default function App() {
const pk = useReadable(pubkey)
return (
<Show when={account()} fallback={<Login />}>
<Show when={pk()} fallback={<Login />}>
<Layout />
</Show>
)
+6 -6
View File
@@ -1,5 +1,5 @@
import { createSignal, createEffect, Show } from "solid-js"
import { account, logout, view, setView, activeQuorum } from "./store"
import { logout, view, setView, activeQuorum } from "./store"
import QuorumList from "./components/QuorumList"
import QuorumDetail from "./components/QuorumDetail"
import QuorumChat from "./components/QuorumChat"
@@ -8,14 +8,14 @@ import { ProposeQuorum } from "./components/forms/DkgForms"
import { ProposeResharing } from "./components/forms/ResharingForms"
import { ProposeSign } from "./components/forms/SigningForms"
import Avatar from "./components/Avatar"
import { getDisplayName } from "./lib/profiles"
import { useActivePubkey, useProfileEvent } from "./hooks"
import { displayProfile } from "@welshman/util"
import { useActivePubkey, useProfile } from "./hooks"
function SidebarContent(props: { onNew: () => void }) {
const pubkey = useActivePubkey()
const shortKey = () => `${pubkey().slice(0, 8)}${pubkey().slice(-4)}`
const profileEvent = useProfileEvent(pubkey)
const displayName = () => getDisplayName(profileEvent(), shortKey())
const profile = useProfile(pubkey)
const name = () => displayProfile(profile(), shortKey())
return (
<div class="flex flex-col h-full">
@@ -43,7 +43,7 @@ function SidebarContent(props: { onNew: () => void }) {
onClick={() => setView("account")}
>
<Avatar pubkey={pubkey()} />
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{displayName()}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{name()}</p>
</button>
<button
class="p-1.5 rounded-md text-gray-400 hover:text-gray-600 dark:text-neutral-500 dark:hover:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 transition-colors"
+5 -8
View File
@@ -1,7 +1,7 @@
import { createSignal, Show } from "solid-js"
import toast from "solid-toast"
import { getNip07, Nip01Signer, Nip07Signer } from "@welshman/signer"
import { login } from "./store"
import { getNip07, Nip07Signer } from "@welshman/signer"
import { loginWithNip01, loginWithNip07 } from "@welshman/app"
type Tab = "extension" | "nsec" | "nip46"
@@ -59,9 +59,8 @@ export default function Login() {
setLoading(true)
try {
if (!getNip07()) { throw new Error("Extension not found") }
const signer = new Nip07Signer()
const pubkey = await signer.getPubkey()
login({ method: "nip07", pubkey, signer })
const pubkey = await new Nip07Signer().getPubkey()
loginWithNip07(pubkey)
} catch (e: any) {
toast.error(e?.message ?? "Extension not found")
} finally {
@@ -82,9 +81,7 @@ export default function Login() {
} else {
throw new Error("Invalid key")
}
const signer = new Nip01Signer(secret)
const pubkey = await signer.getPubkey()
login({ method: "nip01", pubkey, secret, signer })
loginWithNip01(secret)
} catch (e: any) {
toast.error(e?.message ?? "Invalid key")
} finally {
+21
View File
@@ -0,0 +1,21 @@
import "@welshman/app" // side effects: wires pool → repository/tracker/router
import { shouldUnwrap } from "@welshman/app"
import { routerContext } from "@welshman/router"
import { hydrateRepository, persistRepository } from "./storage"
const parseRelays = (v?: string): string[] =>
(v ?? "").split(",").map(s => s.trim()).filter(Boolean)
const INDEXER_RELAYS = parseRelays(import.meta.env.VITE_INDEXER_RELAYS)
const SEARCH_RELAYS = parseRelays(import.meta.env.VITE_SEARCH_RELAYS)
routerContext.getIndexerRelays = () => INDEXER_RELAYS
routerContext.getSearchRelays = () => SEARCH_RELAYS
routerContext.getDefaultRelays = () => INDEXER_RELAYS
// Opt into NIP-59 gift-wrap unwrapping so inbox rumors are decrypted and stored automatically.
shouldUnwrap.set(true)
// Repository persistence: register the flush listener, then hydrate from disk.
persistRepository()
void hydrateRepository()
+27 -34
View File
@@ -1,43 +1,22 @@
import { createSignal, For, Show } from "solid-js"
import { now } from "@welshman/lib"
import { publish, repository } from "../nostr"
import { account } from "../store"
import { INDEXER_RELAYS } from "../lib/relays"
import { getDisplayName } from "../lib/profiles"
import { useActivePubkey, useProfileEvent, useMessagingRelays, useOutboxRelays } from "../hooks"
import { createSignal, createMemo, For, Show } from "solid-js"
import { getRelaysFromList, displayProfile } from "@welshman/util"
import { userMessagingRelayList, addMessagingRelay, removeMessagingRelay, waitForThunkError } from "@welshman/app"
import { useReadable } from "../lib/stores"
import { useActivePubkey, useProfile } from "../hooks"
import Avatar from "./Avatar"
export default function AccountPage() {
const pubkey = useActivePubkey()
const profileEvent = useProfileEvent(pubkey)
const messagingRelays = useMessagingRelays(pubkey)
const outboxRelays = useOutboxRelays(pubkey)
const displayName = () => getDisplayName(profileEvent(), "")
const profile = useProfile(pubkey)
const name = () => displayProfile(profile(), "")
const messagingList = useReadable(userMessagingRelayList)
const messagingRelays = createMemo(() => getRelaysFromList(messagingList()))
const [newRelay, setNewRelay] = createSignal("")
const [publishing, setPublishing] = createSignal(false)
const [copied, setCopied] = createSignal(false)
const [addError, setAddError] = createSignal("")
async function publishRelays(updated: string[]): Promise<void> {
const acc = account()
if (!acc) return
setPublishing(true)
try {
const signed = await acc.signer.sign({
kind: 10050,
content: "",
tags: updated.map(r => ["relay", r]),
created_at: now(),
})
const targets = outboxRelays().length ? outboxRelays() : INDEXER_RELAYS
await publish(targets, signed)
repository.publish(signed)
} finally {
setPublishing(false)
}
}
async function addRelay() {
setAddError("")
const relay = newRelay().trim()
@@ -51,11 +30,25 @@ export default function AccountPage() {
return
}
setNewRelay("")
await publishRelays([...messagingRelays(), relay])
setPublishing(true)
try {
// Force-loads the current 10050, appends ["relay", url], publishes to the
// user's write relays plus fallbacks, with an optimistic repository insert
const error = await waitForThunkError(await addMessagingRelay(relay))
if (error) setAddError(error)
} finally {
setPublishing(false)
}
}
async function removeRelay(relay: string) {
await publishRelays(messagingRelays().filter(r => r !== relay))
setPublishing(true)
try {
const error = await waitForThunkError(await removeMessagingRelay(relay))
if (error) setAddError(error)
} finally {
setPublishing(false)
}
}
async function copyPubkey() {
@@ -70,9 +63,9 @@ export default function AccountPage() {
<div class="flex items-center gap-4">
<Avatar pubkey={pubkey()} size="size-16" />
<div class="flex flex-col gap-0.5 min-w-0">
<Show when={displayName()}>
<Show when={name()}>
<p class="text-lg font-semibold text-gray-900 dark:text-white truncate">
{displayName()}
{name()}
</p>
</Show>
<div class="flex items-center gap-1.5">
+7 -9
View File
@@ -1,22 +1,20 @@
import { createSignal, Show, createEffect } from "solid-js"
import { getDisplayName, getProfilePicture } from "../lib/profiles"
import { useProfileEvent } from "../hooks"
import { displayProfile } from "@welshman/util"
import { useProfile } from "../hooks"
export default function Avatar(props: { pubkey: string; size?: string }) {
const [imgError, setImgError] = createSignal(false)
const event = useProfileEvent(() => props.pubkey)
const picture = () => (imgError() ? undefined : getProfilePicture(event()))
const profile = useProfile(() => props.pubkey)
const picture = () => (imgError() ? undefined : (profile()?.picture || undefined))
createEffect(() => {
if (getProfilePicture(event())) { setImgError(false) }
if (profile()?.picture) { setImgError(false) }
})
const sizeClass = () => props.size ?? "size-8"
const initial = () => {
const name = getDisplayName(event(), props.pubkey.slice(0, 2))
return (name?.[0] ?? "?").toUpperCase()
}
const initial = () =>
(displayProfile(profile(), props.pubkey.slice(0, 2))[0] ?? "?").toUpperCase()
return (
<Show
+26 -43
View File
@@ -1,8 +1,9 @@
import { createSignal, createEffect, For, Show, onMount, onCleanup } from "solid-js"
import { Pubkey, fromNostrURI } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
import { fetchProfile, searchProfiles, getDisplayName } from "../lib/profiles"
import { useProfileEvent } from "../hooks"
import { createSignal, createEffect, createMemo, For, Show, onMount } from "solid-js"
import { Pubkey, fromNostrURI, displayProfile } from "@welshman/util"
import type { PublishedProfile } from "@welshman/util"
import { loadProfile, searchProfiles, profileSearch } from "@welshman/app"
import { useReadable } from "../lib/stores"
import { useProfileDisplay } from "../hooks"
import Avatar from "./Avatar"
interface Props {
@@ -34,12 +35,12 @@ function ProfileTag(props: {
locked?: boolean
onRemove: () => void
}) {
const event = useProfileEvent(() => props.pubkey)
const name = useProfileDisplay(() => props.pubkey)
return (
<span class="inline-flex items-center gap-1.5 pl-1 pr-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-neutral-700 text-sm">
<Avatar pubkey={props.pubkey} size="size-5" />
<span class="text-xs text-gray-800 dark:text-neutral-200 max-w-24 truncate">
{getDisplayName(event(), props.pubkey.slice(0, 8) + "…")}
{name()}
</span>
<Show when={!props.locked}>
<button
@@ -56,20 +57,19 @@ function ProfileTag(props: {
export default function PubkeyInput(props: Props) {
const [query, setQuery] = createSignal("")
const [results, setResults] = createSignal<TrustedEvent[]>([])
const [open, setOpen] = createSignal(false)
const search = useReadable(profileSearch)
onMount(() => {
for (const pk of props.value) { fetchProfile(pk) }
for (const pk of props.value) { loadProfile(pk) }
})
function addPubkey(pubkey: string): void {
if (!props.value.includes(pubkey)) {
props.onChange([...props.value, pubkey])
fetchProfile(pubkey)
loadProfile(pubkey)
}
setQuery("")
setResults([])
setOpen(false)
}
@@ -79,30 +79,14 @@ export default function PubkeyInput(props: Props) {
}
createEffect(() => {
const q = query()
setResults([])
const q = query().trim()
if (q && !parsePubkey(q)) { searchProfiles(q) }
})
if (!q.trim() || parsePubkey(q)) {
setOpen(false)
return
}
let cancel: (() => void) | undefined
const timer = setTimeout(() => {
setOpen(true)
cancel = searchProfiles(q, (event) => {
setResults(r => {
const without = r.filter(e => e.pubkey !== event.pubkey)
return [...without, event]
})
})
}, 300)
onCleanup(() => {
clearTimeout(timer)
cancel?.()
})
const results = createMemo<PublishedProfile[]>(() => {
const q = query().trim()
if (!q || parsePubkey(q)) { return [] }
return search().searchOptions(q).filter(r => !props.value.includes(r.event.pubkey))
})
function handlePaste(e: ClipboardEvent): void {
@@ -120,7 +104,6 @@ export default function PubkeyInput(props: Props) {
const pubkey = parsePubkey(query())
if (pubkey) { addPubkey(pubkey) }
} else if (e.key === "Escape") {
setResults([])
setOpen(false)
}
}
@@ -147,30 +130,30 @@ export default function PubkeyInput(props: Props) {
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={props.placeholder ?? "Search by name, or paste npub / nprofile / hex pubkey"}
value={query()}
onInput={e => setQuery(e.currentTarget.value)}
onInput={e => { setQuery(e.currentTarget.value); setOpen(true) }}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => { if (results().length > 0) { setOpen(true) } }}
onBlur={() => setTimeout(() => setOpen(false), 150)}
/>
<Show when={open() && results().filter(e => !props.value.includes(e.pubkey)).length > 0}>
<Show when={open() && results().length > 0}>
<div class="absolute z-20 top-full left-0 right-0 mt-1 bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-lg shadow-lg overflow-hidden max-h-52 overflow-y-auto">
<For each={results().filter(e => !props.value.includes(e.pubkey))}>
{(event) => (
<For each={results()}>
{(r) => (
<button
type="button"
class="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors text-left"
onMouseDown={e => e.preventDefault()}
onClick={() => addPubkey(event.pubkey)}
onClick={() => addPubkey(r.event.pubkey)}
>
<Avatar pubkey={event.pubkey} size="size-7" />
<Avatar pubkey={r.event.pubkey} size="size-7" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{getDisplayName(event, event.pubkey.slice(0, 8) + "…")}
{displayProfile(r, r.event.pubkey.slice(0, 8) + "…")}
</p>
<p class="text-xs text-gray-400 dark:text-neutral-500 font-mono">
{event.pubkey.slice(0, 12)}
{r.event.pubkey.slice(0, 12)}
</p>
</div>
</button>
+30 -7
View File
@@ -1,5 +1,6 @@
import { Show, Switch, Match } from "solid-js"
import { activeQuorum, view, setTab } from "../store"
import { useProfileDisplay } from "../hooks"
import QuorumLog from "./tabs/QuorumLog"
import QuorumMembers from "./tabs/QuorumMembers"
import QuorumChat from "./QuorumChat"
@@ -12,7 +13,17 @@ type Props = {
const TABS = ["log", "members", "chat"] as const
type Tab = (typeof TABS)[number]
const badgeClass: Record<string, string> = {
complete: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
sending: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
failed: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
pending: "bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300",
}
export default function QuorumDetail(props: Props) {
const quorumName = useProfileDisplay(() => activeQuorum()?.quorumPubkey ?? "")
const title = () => (activeQuorum()?.quorumPubkey ? quorumName() : "New quorum")
const complete = () => Boolean(activeQuorum()?.complete)
return (
<Show
when={activeQuorum()}
@@ -24,23 +35,35 @@ export default function QuorumDetail(props: Props) {
>
<div class="flex flex-col h-full">
<div class="px-4 py-3 border-b border-gray-200 dark:border-neutral-700 flex items-start justify-between">
<div>
<div class="text-xs font-medium text-gray-500 dark:text-neutral-400 uppercase tracking-wide mb-1">
Quorum
<div class="min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-500 dark:text-neutral-400 uppercase tracking-wide">
Quorum
</span>
<span class={`px-2 py-0.5 rounded-full text-[11px] font-medium ${badgeClass[activeQuorum()!.status]}`}>
{activeQuorum()!.statusLabel}
</span>
</div>
<div class="font-mono text-sm text-gray-900 dark:text-white break-all">
{activeQuorum()!.quorumPubkey}
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{title()}
</div>
<div class="font-mono text-xs text-gray-400 dark:text-neutral-500 break-all">
{activeQuorum()!.quorumPubkey ?? activeQuorum()!.inviteId}
</div>
</div>
<div class="flex items-center gap-2 ml-4 shrink-0">
<button
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!complete()}
title={complete() ? "" : "Available once the quorum is established"}
onClick={props.onProposeSign}
>
Request Signature
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!complete()}
title={complete() ? "" : "Available once the quorum is established"}
onClick={props.onProposeResharing}
>
Rotate Keys
+42 -26
View File
@@ -1,11 +1,49 @@
import { For } from "solid-js"
import { quora, view, openQuorum } from "../store"
import type { QuorumRecord } from "../models"
import { displayedQuora, view, openQuorum } from "../store"
import { useProfileDisplay } from "../hooks"
import type { DisplayedQuorum } from "../models"
type Props = {
onNew?: () => void
}
const dotClass: Record<DisplayedQuorum["status"], string> = {
complete: "bg-green-500",
sending: "bg-amber-400",
failed: "bg-red-500",
pending: "bg-gray-400 dark:bg-neutral-500",
}
function QuorumListItem(props: { quorum: DisplayedQuorum }) {
const name = useProfileDisplay(() => props.quorum.quorumPubkey ?? "")
const label = () => (props.quorum.quorumPubkey ? name() : "New quorum")
const isActive = () => {
const v = view()
return typeof v === "object" && v.type === "quorum" &&
(v.id === props.quorum.id || v.id === props.quorum.inviteId)
}
return (
<button
class={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
isActive()
? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300"
: "text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700"
}`}
onClick={() => openQuorum(props.quorum.id)}
>
<div class="font-medium text-sm truncate">
{label()}
</div>
<div class="flex items-center gap-1.5 text-xs opacity-60">
<span class={`inline-block size-1.5 rounded-full shrink-0 ${dotClass[props.quorum.status]}`} />
<span class="truncate">
{props.quorum.threshold}-of-{props.quorum.members.length} · {props.quorum.statusLabel}
</span>
</div>
</button>
)
}
export default function QuorumList(props: Props) {
return (
<nav class="flex flex-col h-full">
@@ -22,36 +60,14 @@ export default function QuorumList(props: Props) {
</div>
<div class="flex-1 overflow-y-auto px-2">
<For
each={quora}
each={displayedQuora()}
fallback={
<p class="text-xs text-gray-400 dark:text-neutral-500 px-3 py-2">
No quora yet
</p>
}
>
{(q: QuorumRecord) => {
const isActive = () => {
const v = view()
return typeof v === "object" && v.type === "quorum" && v.id === q.quorumPubkey
}
return (
<button
class={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
isActive()
? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300"
: "text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700"
}`}
onClick={() => openQuorum(q.quorumPubkey)}
>
<div class="font-medium text-sm">
{q.quorumPubkey.slice(0, 8)}...
</div>
<div class="text-xs opacity-60">
{q.threshold}-of-{q.members.length}
</div>
</button>
)
}}
{(q) => <QuorumListItem quorum={q} />}
</For>
</div>
</nav>
+11 -10
View File
@@ -1,7 +1,8 @@
import { createSignal, Show } from "solid-js"
import toast from "solid-toast"
import type { DkgSession } from "../../models"
import PubkeyInput from "../PubkeyInput"
import { validateMessagingRelays } from "../../lib/relays"
import { createQuorum } from "../../quorum"
import { useActivePubkey, useAutoThreshold } from "../../hooks"
const inputClass =
@@ -37,16 +38,16 @@ export function ProposeQuorum(props: { onClose: () => void }) {
setError("")
setSubmitting(true)
const relayError = await validateMessagingRelays(pubkeys)
setSubmitting(false)
if (relayError) {
setError(relayError)
return
try {
// Optimistic: the quorum is created and shown immediately, regardless of
// whether members have messaging relays or the invite reaches anyone.
await createQuorum({ members: pubkeys, threshold: t })
props.onClose()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create quorum")
} finally {
setSubmitting(false)
}
console.log("ProposeQuorum", { pubkeys, threshold: t })
props.onClose()
}
return (
+3 -3
View File
@@ -2,7 +2,7 @@ import { createSignal, Show } from "solid-js"
import type { ResharingSession } from "../../models"
import PubkeyInput from "../PubkeyInput"
import { validateMessagingRelays } from "../../lib/relays"
import { useAutoThreshold } from "../../hooks"
import { useAutoThreshold, useProfileDisplay } from "../../hooks"
export function ProposeResharing(props: { quorumPubkey: string; onClose: () => void }) {
const [newMembers, setNewMembers] = createSignal<string[]>([])
@@ -100,7 +100,7 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
}
export function ResharingInviteResponse(props: { session: ResharingSession; onClose: () => void }) {
const truncated = props.session.quorumPubkey.slice(0, 8) + "..." + props.session.quorumPubkey.slice(-8)
const quorumName = useProfileDisplay(() => props.session.quorumPubkey)
function handleParticipate() {
console.log("ResharingInviteResponse: participate", props.session.proposalId)
@@ -118,7 +118,7 @@ export function ResharingInviteResponse(props: { session: ResharingSession; onCl
<div class="space-y-2 text-sm text-gray-700 dark:text-neutral-300">
<div>
<span class="font-medium">Quorum: </span>
<span class="font-mono">{truncated}</span>
<span>{quorumName()}</span>
</div>
<div>
<span class="font-medium">New member count: </span>
+2 -2
View File
@@ -1,6 +1,6 @@
import { createSignal } from "solid-js"
import type { SigningSession } from "../../models"
import { quora } from "../../store"
import { displayedQuora } from "../../store"
import { validateMessagingRelays } from "../../lib/relays"
export function ProposeSign(props: { quorumPubkey: string; onClose: () => void }) {
@@ -9,7 +9,7 @@ export function ProposeSign(props: { quorumPubkey: string; onClose: () => void }
const [submitting, setSubmitting] = createSignal(false)
const memberPubkeys = () =>
quora.find(q => q.quorumPubkey === props.quorumPubkey)?.members.map(m => m.pubkey) ?? []
displayedQuora().find(q => q.quorumPubkey === props.quorumPubkey)?.members.map(m => m.pubkey) ?? []
async function handleSubmit() {
if (!message().trim()) {
+15 -7
View File
@@ -1,7 +1,9 @@
import { createSignal, onMount, For } from "solid-js"
import { createSignal, createEffect, onCleanup, For } from "solid-js"
import type { TrustedEvent, Filter } from "@welshman/util"
import { repository } from "@welshman/app"
import { deriveEvents } from "@welshman/store"
import { activeQuorum } from "../../store"
import { getEventsByTag } from "../../storage"
import type { TrustedEvent } from "@welshman/util"
import { PROTOCOL_KINDS } from "../../protocol"
function kindLabel(kind: number): string {
if (kind === 7050) { return "DKG Invite" }
@@ -31,11 +33,17 @@ function relativeTime(createdAt: number): string {
export default function QuorumLog() {
const [events, setEvents] = createSignal<TrustedEvent[]>([])
onMount(async () => {
createEffect(() => {
const quorum = activeQuorum()
if (!quorum) { return }
const fetched = await getEventsByTag("p", quorum.quorumPubkey)
setEvents(fetched.slice().sort((a, b) => b.created_at - a.created_at))
if (!quorum) { setEvents([]); return }
// Completed quora tag events with their pubkey; a pending invite is referenced by
// its id (the invite event itself, plus round-1/decline events that "e"-tag it).
const filters: Filter[] = quorum.quorumPubkey
? [{ kinds: PROTOCOL_KINDS, "#quorum": [quorum.quorumPubkey] }]
: [{ kinds: PROTOCOL_KINDS, "#e": [quorum.inviteId] }, { ids: [quorum.inviteId] }]
const store = deriveEvents({ repository, filters })
onCleanup(store.subscribe(evs =>
setEvents(() => [...evs].sort((a, b) => b.created_at - a.created_at))))
})
return (
+29 -14
View File
@@ -1,7 +1,31 @@
import { createSignal, Show, For } from "solid-js"
import { activeQuorum } from "../../store"
import { useProfileDisplay } from "../../hooks"
import type { QuorumMember } from "../../protocol"
function MemberRow(props: { member: QuorumMember; copied: boolean; onCopy: () => void }) {
const name = useProfileDisplay(() => props.member.pubkey)
return (
<div class="flex items-center gap-3 px-3 py-2 rounded-lg border border-gray-200 dark:border-neutral-600 bg-white dark:bg-neutral-800">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center text-sm font-medium">
{props.member.index}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{name()}</p>
<p class="text-xs font-mono text-gray-400 dark:text-neutral-500 truncate">
{props.member.pubkey.slice(0, 12)}{props.member.pubkey.slice(-8)}
</p>
</div>
<button
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={props.onCopy}
>
{props.copied ? "Copied!" : "Copy"}
</button>
</div>
)
}
export default function QuorumMembers() {
const [copiedIndex, setCopiedIndex] = createSignal<number | null>(null)
@@ -24,20 +48,11 @@ export default function QuorumMembers() {
<div class="space-y-2">
<For each={quorum().members}>
{(member) => (
<div class="flex items-center gap-3 px-3 py-2 rounded-lg border border-gray-200 dark:border-neutral-600 bg-white dark:bg-neutral-800">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center text-sm font-medium">
{member.index}
</div>
<span class="flex-1 text-sm font-mono text-gray-700 dark:text-neutral-300 truncate">
{member.pubkey.slice(0, 12)}...{member.pubkey.slice(-8)}
</span>
<button
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={() => copyPubkey(member)}
>
{copiedIndex() === member.index ? "Copied!" : "Copy"}
</button>
</div>
<MemberRow
member={member}
copied={copiedIndex() === member.index}
onCopy={() => copyPubkey(member)}
/>
)}
</For>
</div>
+19 -32
View File
@@ -1,12 +1,11 @@
import { createSignal, createEffect, onCleanup } from "solid-js"
import type { TrustedEvent } from "@welshman/util"
import type { RepositoryUpdate } from "@welshman/net"
import { account } from "./store"
import { repository } from "./nostr"
import { parseRelayList, parseOutboxRelays } from "./lib/relays"
import type { PublishedProfile } from "@welshman/util"
import { pubkey, deriveProfile, deriveProfileDisplay } from "@welshman/app"
import { useReadable } from "./lib/stores"
export function useActivePubkey(): () => string {
return () => account()?.pubkey ?? ""
const pk = useReadable(pubkey)
return () => pk() ?? ""
}
export function useAutoThreshold(members: () => string[]): [() => number, (n: number) => void] {
@@ -15,34 +14,22 @@ export function useAutoThreshold(members: () => string[]): [() => number, (n: nu
return [threshold, setThreshold]
}
function useReplaceable(kind: number, pubkey: () => string): () => TrustedEvent | undefined {
const [event, setEvent] = createSignal<TrustedEvent | undefined>()
export function useProfile(pubkeyFn: () => string): () => PublishedProfile | undefined {
const [profile, setProfile] = createSignal<PublishedProfile | undefined>()
createEffect(() => {
const pk = pubkey()
if (!pk) { setEvent(undefined); return }
const query = () => setEvent(repository.getEvent(`${kind}:${pk}:`))
const onUpdate = (update: RepositoryUpdate) => {
if (update.added.some(e => e.kind === kind && e.pubkey === pk) || update.removed.size > 0) {
query()
}
}
query()
repository.on("update", onUpdate)
onCleanup(() => repository.off("update", onUpdate))
const pk = pubkeyFn()
if (!pk) { setProfile(undefined); return }
onCleanup(deriveProfile(pk).subscribe(p => setProfile(() => p)))
})
return event
return profile
}
export function useProfileEvent(pubkey: () => string): () => TrustedEvent | undefined {
return useReplaceable(0, pubkey)
}
export function useMessagingRelays(pubkey: () => string): () => string[] {
const event = useReplaceable(10050, pubkey)
return () => { const e = event(); return e ? parseRelayList(e) : [] }
}
export function useOutboxRelays(pubkey: () => string): () => string[] {
const event = useReplaceable(10002, pubkey)
return () => { const e = event(); return e ? parseOutboxRelays(e) : [] }
export function useProfileDisplay(pubkeyFn: () => string): () => string {
const [name, setName] = createSignal("")
createEffect(() => {
const pk = pubkeyFn()
if (!pk) { setName(""); return }
onCleanup(deriveProfileDisplay(pk).subscribe(n => setName(() => n)))
})
return name
}
+1
View File
@@ -1,3 +1,4 @@
import "./boot"
import { render } from "solid-js/web";
import { Toaster } from "solid-toast";
import App from "./App";
-45
View File
@@ -1,45 +0,0 @@
import { readProfile, displayProfile } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
import { request, load } from "@welshman/net"
import { repository } from "../nostr"
import { SEARCH_RELAYS, loadOutboxRelays } from "./relays"
export function getDisplayName(event: TrustedEvent | undefined, fallback: string): string {
if (!event) return fallback
return displayProfile(readProfile(event), fallback)
}
export function getProfilePicture(event: TrustedEvent | undefined): string | undefined {
return event ? readProfile(event).picture || undefined : undefined
}
export function fetchProfile(pubkey: string): void {
if (!pubkey) return
loadOutboxRelays(pubkey).then(relays => {
if (!relays.length) return
load({
relays,
filters: [{ kinds: [0], authors: [pubkey], limit: 1 }],
onEvent: e => repository.publish(e),
})
})
}
export function searchProfiles(query: string, onResult: (event: TrustedEvent) => void): () => void {
if (!query.trim() || !SEARCH_RELAYS.length) return () => {}
const ctrl = new AbortController()
const timeout = setTimeout(() => ctrl.abort(), 4000)
request({
relays: SEARCH_RELAYS,
filters: [{ kinds: [0], search: query, limit: 10 }],
signal: ctrl.signal,
onEvent: e => {
repository.publish(e)
onResult(e)
},
})
return () => { clearTimeout(timeout); ctrl.abort() }
}
+8 -90
View File
@@ -1,95 +1,13 @@
import { request, load } from "@welshman/net"
import { sortEventsDesc } from "@welshman/util"
import type { TrustedEvent, Filter } from "@welshman/util"
import { repository } from "../nostr"
import { getRelaysFromList } from "@welshman/util"
import { loadMessagingRelayList, getMessagingRelayList, loadProfile, displayProfileByPubkey } from "@welshman/app"
const RELAY_TIMEOUT_MS = 10_000
export const SEARCH_RELAYS: string[] = (import.meta.env.VITE_SEARCH_RELAYS ?? "")
.split(",")
.map((r: string) => r.trim())
.filter(Boolean)
export const INDEXER_RELAYS: string[] = (import.meta.env.VITE_INDEXER_RELAYS ?? "")
.split(",")
.map((r: string) => r.trim())
.filter(Boolean)
export function parseRelayList(event: TrustedEvent): string[] {
return event.tags.filter(t => t[0] === "relay").map(t => t[1]).filter(Boolean)
}
export function parseOutboxRelays(event: TrustedEvent): string[] {
return event.tags
.filter(t => t[0] === "r" && (!t[2] || t[2] === "write"))
.map(t => t[1])
.filter(Boolean)
}
// One-shot load: newest matching event across the given relays, or undefined.
async function loadNewest(relays: string[], filter: Filter): Promise<TrustedEvent | undefined> {
try {
const events = await request({
relays,
filters: [filter],
autoClose: true,
signal: AbortSignal.timeout(RELAY_TIMEOUT_MS),
onEvent: e => repository.publish(e),
})
return sortEventsDesc(events)[0]
} catch {
// Match the previous behavior: relay failures resolve to "no event found"
return undefined
}
}
// Fetch kind 10002 from the indexer for a single pubkey and return its write relays.
export async function loadOutboxRelays(pubkey: string): Promise<string[]> {
if (!INDEXER_RELAYS.length) return []
const event = await loadNewest(INDEXER_RELAYS, { kinds: [10002], authors: [pubkey], limit: 1 })
return event ? parseOutboxRelays(event) : []
}
// Batch-fetch kind 10002 for multiple pubkeys (fire-and-forget).
export function loadRelayLists(pubkeys: string[]): void {
if (!pubkeys.length || !INDEXER_RELAYS.length) return
load({
relays: INDEXER_RELAYS,
filters: [{ kinds: [10002], authors: pubkeys }],
onEvent: e => repository.publish(e),
})
}
// Batch-fetch kind 10050 for multiple pubkeys via their outbox relays (fire-and-forget).
export function loadMessagingLists(pubkeys: string[]): void {
if (!pubkeys.length) return
for (const pubkey of pubkeys) {
loadOutboxRelays(pubkey).then(relays => {
if (!relays.length) return
load({
relays,
filters: [{ kinds: [10050], authors: [pubkey], limit: 1 }],
onEvent: e => repository.publish(e),
})
})
}
}
// Validate that all pubkeys have a published kind 10050 with at least one relay.
/** Ensure every pubkey has published a kind-10050 messaging relay list with at least one relay. */
export async function validateMessagingRelays(pubkeys: string[]): Promise<string | null> {
if (!pubkeys.length) return null
if (!INDEXER_RELAYS.length) {
return "No indexer relays configured — cannot verify member messaging relay lists"
}
const results = await Promise.all(pubkeys.map(async pubkey => {
const relays = await loadOutboxRelays(pubkey)
if (!relays.length) return false
const event = await loadNewest(relays, { kinds: [10050], authors: [pubkey], limit: 1 })
return !!event && parseRelayList(event).length > 0
}))
const missing = pubkeys.filter((_, i) => !results[i])
await Promise.all(pubkeys.map(pk => loadMessagingRelayList(pk)))
const missing = pubkeys.filter(pk => getRelaysFromList(getMessagingRelayList(pk)).length === 0)
if (!missing.length) return null
return `No messaging relays found for: ${missing.map(pk => pk.slice(0, 8) + "…").join(", ")}`
// Prefer the profile name (falling back to a short npub) over a raw pubkey.
await Promise.all(missing.map(pk => loadProfile(pk)))
return `No messaging relays found for: ${missing.map(pk => displayProfileByPubkey(pk)).join(", ")}`
}
+15
View File
@@ -0,0 +1,15 @@
import { createSignal, onCleanup } from "solid-js"
import type { Readable, Writable } from "svelte/store"
/** Bridge a welshman/Svelte readable store into a Solid accessor. */
export function useReadable<T>(store: Readable<T>): () => T {
let initial!: T
store.subscribe(v => { initial = v })() // subscribe fires synchronously, then unsubscribe
const [value, setValue] = createSignal<T>(initial)
onCleanup(store.subscribe(v => setValue(() => v)))
return value
}
export function useWritable<T>(store: Writable<T>): [() => T, (value: T) => void] {
return [useReadable(store), store.set]
}
+48 -32
View File
@@ -1,4 +1,4 @@
import type { ISigner } from "@welshman/signer"
import { pubkey, signer, nip44EncryptToSelf } from "@welshman/app"
import type { Hex, QuorumMember } from "./protocol"
// ── Quorum & shard records ────────────────────────────────────────────────────
@@ -23,6 +23,34 @@ export type ShardRecord = {
encryptedShard: string
}
// ── Display model ─────────────────────────────────────────────────────────────
export type QuorumStatusKind = "complete" | "sending" | "failed" | "pending"
/**
* A quorum as shown in the UI — either a completed quorum or a still-pending DKG
* invite. Derived from protocol events in the repository, so it appears as soon as
* the invite is created (optimistically) and persists across reloads, even if the
* invite was never delivered to any relay.
*/
export type DisplayedQuorum = {
/** Stable id used for selection: the quorum pubkey once complete, else the invite id */
id: string
/** Set once DKG completes */
quorumPubkey?: Hex
/** The kind 7050 invite id (always set; the origin of the quorum) */
inviteId: Hex
members: QuorumMember[]
threshold: number
complete: boolean
status: QuorumStatusKind
statusLabel: string
/** Members who have broadcast round 1 (the creator always counts) */
joined: number
declined: number
createdAt: number
}
// ── Session helpers ───────────────────────────────────────────────────────────
/** Proof-of-knowledge from a round-1 message */
@@ -98,44 +126,32 @@ export type SigningSession = {
}
// ── Shard / polynomial encryption ────────────────────────────────────────────
// Shards and polynomials are self-encrypted: NIP-44 encrypt(myPubkey, ...).
// The signer derives the conversation key from (myPrivkey, myPubkey), which is
// unique to the key and opaque to any other party.
// Shards and polynomials are self-encrypted via the active session's signer:
// NIP-44 encrypt(myPubkey, ...). The signer derives the conversation key from
// (myPrivkey, myPubkey), which is unique to the key and opaque to any other
// party. The signer store is typed non-optional but is undefined at runtime
// when there is no active session, hence the guards.
export async function encryptShard(
shard: bigint,
myPubkey: Hex,
signer: ISigner,
): Promise<string> {
if (!signer.nip44) { throw new Error("Signer does not support NIP-44") }
return signer.nip44.encrypt(myPubkey, shard.toString(16))
export async function encryptShard(shard: bigint): Promise<string> {
return nip44EncryptToSelf(shard.toString(16))
}
export async function decryptShard(
encryptedShard: string,
myPubkey: Hex,
signer: ISigner,
): Promise<bigint> {
if (!signer.nip44) { throw new Error("Signer does not support NIP-44") }
const hex = await signer.nip44.decrypt(myPubkey, encryptedShard)
export async function decryptShard(encryptedShard: string): Promise<bigint> {
const $pubkey = pubkey.get()
const $signer = signer.get()
if (!$pubkey || !$signer) { throw new Error("Cannot decrypt without an active signer") }
const hex = await $signer.nip44.decrypt($pubkey, encryptedShard)
return BigInt("0x" + hex)
}
export async function encryptPoly(
poly: bigint[],
myPubkey: Hex,
signer: ISigner,
): Promise<string> {
if (!signer.nip44) { throw new Error("Signer does not support NIP-44") }
return signer.nip44.encrypt(myPubkey, JSON.stringify(poly.map(c => c.toString(16))))
export async function encryptPoly(poly: bigint[]): Promise<string> {
return nip44EncryptToSelf(JSON.stringify(poly.map(c => c.toString(16))))
}
export async function decryptPoly(
encryptedPoly: string,
myPubkey: Hex,
signer: ISigner,
): Promise<bigint[]> {
if (!signer.nip44) { throw new Error("Signer does not support NIP-44") }
const json = await signer.nip44.decrypt(myPubkey, encryptedPoly)
export async function decryptPoly(encryptedPoly: string): Promise<bigint[]> {
const $pubkey = pubkey.get()
const $signer = signer.get()
if (!$pubkey || !$signer) { throw new Error("Cannot decrypt without an active signer") }
const json = await $signer.nip44.decrypt($pubkey, encryptedPoly)
return (JSON.parse(json) as string[]).map(h => BigInt("0x" + h))
}
+7 -54
View File
@@ -1,65 +1,18 @@
import { Repository, request, publish as publishToRelays } from "@welshman/net"
import type { TrustedEvent, SignedEvent } from "@welshman/util"
import { unwrap } from "@welshman/signer"
import type { ISigner } from "@welshman/signer"
import { demoteOldEvents, storeEvent } from "./storage"
export const repository = Repository.get()
// Maintain storage health on startup (fire-and-forget)
demoteOldEvents()
import { request } from "@welshman/net"
type Subscription = { unsubscribe(): void }
export const MESSAGING_RELAYS: string[] = []
const PROTOCOL_KIND_MIN = 7050
const PROTOCOL_KIND_MAX = 7061
export function subscribeInbox(
relays: string[],
pubkey: string,
signer: ISigner,
): Subscription {
/**
* Subscribe to the user's NIP-59 inbox (kind 1059 gift wraps tagged with the app topic).
* With shouldUnwrap enabled in boot.ts, incoming wraps are auto-unwrapped and the inner
* protocol rumor is stored in the repository — no manual unwrap/persist needed here.
*/
export function subscribeInbox(relays: string[], pubkey: string): Subscription {
const ctrl = new AbortController()
request({
relays,
filters: [{ kinds: [1059], "#p": [pubkey], "#t": ["b7ed"] }],
signal: ctrl.signal,
onEvent: async (wrap: TrustedEvent) => {
// Gift wraps are properly signed and verified by the pipeline — add to the
// repository as-is.
repository.publish(wrap)
let rumor
try {
// NIP-59 unwrap: decrypts seal → rumor, verifies seal.pubkey === rumor.pubkey.
// Welshman caches results (and failures) by wrap id, so each wrap is
// decrypted at most once per page lifetime.
rumor = await unwrap(signer, wrap as SignedEvent)
} catch {
return
}
if (rumor.kind < PROTOCOL_KIND_MIN || rumor.kind > PROTOCOL_KIND_MAX) return
// Persist the rumor for log display and session rebuilding.
// Rumors are unsigned (no sig); store only the plain rumor fields.
storeEvent({
id: rumor.id,
pubkey: rumor.pubkey,
created_at: rumor.created_at,
kind: rumor.kind,
tags: rumor.tags,
content: rumor.content,
})
},
})
return { unsubscribe: () => ctrl.abort() }
}
export async function publish(relays: string[], event: TrustedEvent): Promise<void> {
await publishToRelays({ relays, event: event as SignedEvent })
}
+7
View File
@@ -10,6 +10,13 @@ const G = schnorr.Point.BASE
const ZERO = schnorr.Point.ZERO
const Q = schnorr.Point.Fn.ORDER
export const PROTOCOL_KIND_MIN = 7050
export const PROTOCOL_KIND_MAX = 7061
export const PROTOCOL_KINDS: number[] =
Array.from({ length: PROTOCOL_KIND_MAX - PROTOCOL_KIND_MIN + 1 }, (_, i) => PROTOCOL_KIND_MIN + i)
export const isProtocolKind = (kind: number): boolean =>
kind >= PROTOCOL_KIND_MIN && kind <= PROTOCOL_KIND_MAX
// ─── Types ───────────────────────────────────────────────────────────────────
export type Hex = string
+61
View File
@@ -0,0 +1,61 @@
import {
pubkey,
publishThunk,
getCompleteThunkUrls,
getFailedThunkUrls,
} from "@welshman/app"
import { Router } from "@welshman/router"
import { createInvite } from "./protocol"
import { openQuorum, setDelivery } from "./store"
/**
* Create a quorum by publishing a kind 7050 DKG invite.
*
* This is optimistic: publishThunk writes the invite to the repository synchronously,
* so the quorum shows up in the list immediately — whether or not any relay accepts it.
* Delivery success/failure is reflected in the quorum's status, never blocks creation.
*
* Returns the invite id (the quorum's identifier until DKG completes).
*/
export async function createQuorum(opts: {
members: string[]
threshold: number
message?: string
}): Promise<string> {
const pk = pubkey.get()
if (!pk) { throw new Error("You must be logged in to create a quorum") }
// The creator is always a member of their own quorum.
const members = Array.from(new Set([pk, ...opts.members]))
const invite = createInvite(pk, members, opts.threshold, opts.message ?? "")
const inviteId = invite.id
// Show it (and select it) right away, before the network round-trip.
setDelivery(inviteId, "sending")
openQuorum(inviteId)
const relays = Router.get().FromUser().getUrls()
const thunk = publishThunk({ event: invite, relays })
// Resolve the delivery indicator without blocking the UI. The invite is already in
// the repository, so the quorum stays visible no matter how this settles.
let settled = false
const finish = (status: "saved" | "failed") => {
if (settled) { return }
settled = true
setDelivery(inviteId, status)
}
if (relays.length === 0) {
finish("saved") // stored locally; there are no relays to deliver to
} else {
// "saved" once any relay accepts it; "failed" only if every relay rejects it
// (which also covers a signing failure, where all relays are marked failed).
thunk.subscribe(t => {
if (getCompleteThunkUrls(t).length > 0) { finish("saved") }
else if (getFailedThunkUrls(t).length >= relays.length) { finish("failed") }
})
}
return inviteId
}
+41
View File
@@ -0,0 +1,41 @@
import { openDB } from "idb"
import { on, batch } from "@welshman/lib"
import { verifiedSymbol } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
import type { RepositoryUpdate } from "@welshman/net"
import { repository } from "@welshman/app"
import { isProtocolKind } from "./protocol"
const DB_NAME = "nq"
const STORE = "events"
const dbPromise = openDB(DB_NAME, 1, {
upgrade(db) {
db.createObjectStore(STORE, { keyPath: "id" })
},
})
/** Load persisted protocol events into the repository at startup. */
export async function hydrateRepository(): Promise<void> {
const db = await dbPromise
const stored: TrustedEvent[] = await db.getAll(STORE)
for (const e of stored) e[verifiedSymbol] = true
repository.load(stored)
}
/** Flush repository changes (protocol kinds only) to IndexedDB. Call once at startup. */
export function persistRepository(): void {
on(repository, "update", batch(3000, async (updates: RepositoryUpdate[]) => {
const db = await dbPromise
const tx = db.transaction(STORE, "readwrite")
for (const { added, removed } of updates) {
for (const e of added) {
if (isProtocolKind(e.kind)) tx.store.put(e)
}
for (const id of removed) {
tx.store.delete(id)
}
}
await tx.done
}))
}
-10
View File
@@ -1,10 +0,0 @@
import type { TrustedEvent } from "@welshman/util"
export interface EventAdapter {
put(event: TrustedEvent): Promise<void>
get(id: string): Promise<TrustedEvent | undefined>
getAll(): Promise<TrustedEvent[]>
getByKind(kind: number): Promise<TrustedEvent[]>
getByTag(name: string, value: string): Promise<TrustedEvent[]>
delete(id: string): Promise<void>
}
-61
View File
@@ -1,61 +0,0 @@
import { openDB } from "idb"
import type { IDBPDatabase, DBSchema } from "idb"
import type { TrustedEvent } from "@welshman/util"
import type { EventAdapter } from "../adapter"
interface NQDBSchema extends DBSchema {
events: {
key: string
value: TrustedEvent
indexes: {
"by-kind": number
}
}
}
let dbPromise: Promise<IDBPDatabase<NQDBSchema>> | null = null
function getDB(): Promise<IDBPDatabase<NQDBSchema>> {
if (!dbPromise) {
dbPromise = openDB<NQDBSchema>("nq", 1, {
upgrade(db) {
const store = db.createObjectStore("events", { keyPath: "id" })
store.createIndex("by-kind", "kind")
},
})
}
return dbPromise
}
export const indexedDBAdapter: EventAdapter = {
async put(event) {
const db = await getDB()
await db.put("events", event)
},
async get(id) {
const db = await getDB()
return db.get("events", id)
},
async getAll() {
const db = await getDB()
return db.getAll("events")
},
async getByKind(kind) {
const db = await getDB()
return db.getAllFromIndex("events", "by-kind", kind)
},
async getByTag(name, value) {
const db = await getDB()
const all = await db.getAll("events")
return all.filter(ev => ev.tags.some(t => t[0] === name && t[1] === value))
},
async delete(id) {
const db = await getDB()
await db.delete("events", id)
},
}
-72
View File
@@ -1,72 +0,0 @@
import type { TrustedEvent } from "@welshman/util"
import type { EventAdapter } from "../adapter"
const PREFIX_EV = "nq:ev:"
const PREFIX_KI = "nq:ki:"
function readEvent(id: string): TrustedEvent | undefined {
const raw = localStorage.getItem(PREFIX_EV + id)
return raw ? JSON.parse(raw) as TrustedEvent : undefined
}
function addToKindIndex(kind: number, id: string): void {
const key = PREFIX_KI + kind
const ids: string[] = JSON.parse(localStorage.getItem(key) ?? "[]")
if (!ids.includes(id)) {
ids.push(id)
localStorage.setItem(key, JSON.stringify(ids))
}
}
function removeFromKindIndex(kind: number, id: string): void {
const key = PREFIX_KI + kind
const ids: string[] = JSON.parse(localStorage.getItem(key) ?? "[]")
const filtered = ids.filter(x => x !== id)
localStorage.setItem(key, JSON.stringify(filtered))
}
export const localStorageAdapter: EventAdapter = {
async put(event) {
const existing = readEvent(event.id)
if (!existing) {
addToKindIndex(event.kind, event.id)
}
localStorage.setItem(PREFIX_EV + event.id, JSON.stringify(event))
},
async get(id) {
return readEvent(id)
},
async getAll() {
const events: TrustedEvent[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith(PREFIX_EV)) {
const raw = localStorage.getItem(key)
if (raw) {
events.push(JSON.parse(raw) as TrustedEvent)
}
}
}
return events
},
async getByKind(kind) {
const ids: string[] = JSON.parse(localStorage.getItem(PREFIX_KI + kind) ?? "[]")
return ids.map(id => readEvent(id)).filter(Boolean) as TrustedEvent[]
},
async getByTag(name, value) {
const all = await this.getAll()
return all.filter(ev => ev.tags.some(t => t[0] === name && t[1] === value))
},
async delete(id) {
const event = readEvent(id)
if (event) {
removeFromKindIndex(event.kind, id)
localStorage.removeItem(PREFIX_EV + id)
}
},
}
-85
View File
@@ -1,85 +0,0 @@
import type { TrustedEvent } from "@welshman/util"
import { now } from "@welshman/lib"
import { localStorageAdapter } from "./adapters/localstorage"
import { indexedDBAdapter } from "./adapters/indexeddb"
export type { EventAdapter } from "./adapter"
// Only protocol inner events are worth persisting — all other kinds (profiles,
// relay lists, etc.) can be re-fetched from relays on demand.
const PROTOCOL_KIND_MIN = 7050
const PROTOCOL_KIND_MAX = 7061
// Signing-related kinds are demoted to localStorage after 30 days.
// All other protocol events stay in IndexedDB indefinitely.
const SIGNING_KINDS = new Set([7058, 7059, 7060])
const THIRTY_DAYS_S = 30 * 24 * 60 * 60
function isOld(event: TrustedEvent): boolean {
return now() - event.created_at > THIRTY_DAYS_S
}
function isLowPriority(event: TrustedEvent): boolean {
return SIGNING_KINDS.has(event.kind) && isOld(event)
}
function isHighPriority(event: TrustedEvent): boolean {
return event.kind >= PROTOCOL_KIND_MIN && event.kind <= PROTOCOL_KIND_MAX
}
export async function storeEvent(event: TrustedEvent): Promise<void> {
if (isLowPriority(event)) {
await localStorageAdapter.put(event)
} else if (isHighPriority(event)) {
await indexedDBAdapter.put(event)
}
}
export async function getEvent(id: string): Promise<TrustedEvent | undefined> {
return (await indexedDBAdapter.get(id)) ?? (await localStorageAdapter.get(id))
}
export async function getEventsByKind(kind: number): Promise<TrustedEvent[]> {
const high = await indexedDBAdapter.getByKind(kind)
if (SIGNING_KINDS.has(kind)) {
const low = await localStorageAdapter.getByKind(kind)
return [...high, ...low]
}
return high
}
export async function getEventsByTag(name: string, value: string): Promise<TrustedEvent[]> {
const [high, low] = await Promise.all([
indexedDBAdapter.getByTag(name, value),
localStorageAdapter.getByTag(name, value),
])
return [...high, ...low]
}
export async function deleteEvent(id: string): Promise<void> {
await Promise.all([
indexedDBAdapter.delete(id),
localStorageAdapter.delete(id),
])
}
export async function getAllEvents(): Promise<TrustedEvent[]> {
const [high, low] = await Promise.all([
indexedDBAdapter.getAll(),
localStorageAdapter.getAll(),
])
return [...high, ...low]
}
/** Move signing events older than 30 days from IndexedDB to localStorage. Call at startup. */
export async function demoteOldEvents(): Promise<void> {
for (const kind of SIGNING_KINDS) {
const events = await indexedDBAdapter.getByKind(kind)
for (const event of events) {
if (isOld(event)) {
await localStorageAdapter.put(event)
await indexedDBAdapter.delete(event.id)
}
}
}
}
+171 -97
View File
@@ -1,74 +1,37 @@
import { createSignal, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { parseJson } from "@welshman/lib"
import { Nip01Signer, Nip07Signer } from "@welshman/signer"
import type { ISigner } from "@welshman/signer"
import { toast } from "solid-toast"
import { subscribeInbox, MESSAGING_RELAYS } from "./nostr"
import type { QuorumRecord, DkgSession, ResharingSession, SigningSession } from "./models"
import { loadRelayLists, loadMessagingLists } from "./lib/relays"
import { fetchProfile } from "./lib/profiles"
// Shape of the object returned by subscribeInbox()
type Subscription = { unsubscribe(): void }
import { getRelaysFromList, getTagValue, getTagValues } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
import { deriveEvents } from "@welshman/store"
import {
pubkey,
repository,
dropSession,
userMessagingRelayList,
loadProfile,
loadRelayList,
loadMessagingRelayList,
} from "@welshman/app"
import { subscribeInbox } from "./nostr"
import { assignIndices } from "./protocol"
import type {
QuorumRecord,
DisplayedQuorum,
QuorumStatusKind,
DkgSession,
ResharingSession,
SigningSession,
} from "./models"
export type View = "inbox" | "account" | { type: "quorum"; id: string; tab: "log" | "members" | "chat" }
export type Account = { method: "nip07" | "nip01"; pubkey: string; secret?: string; signer: ISigner }
type SavedAccount = { method: "nip07" | "nip01"; pubkey: string; secret?: string }
const ACCOUNTS_KEY = "nq:accounts"
const ACTIVE_KEY = "nq:active"
function buildSigner(saved: SavedAccount): ISigner | null {
if (saved.method === "nip07") {
return new Nip07Signer()
}
if (saved.method === "nip01" && saved.secret) {
return new Nip01Signer(saved.secret)
}
return null
}
function persistAccounts(): void {
localStorage.setItem(
ACCOUNTS_KEY,
JSON.stringify(accounts.map(({ method, pubkey, secret }) => ({ method, pubkey, secret }))),
)
}
// Restore persisted accounts
let accounts: Account[] = []
const savedAccounts = parseJson(localStorage.getItem(ACCOUNTS_KEY) ?? "")
if (Array.isArray(savedAccounts)) {
for (const saved of savedAccounts as SavedAccount[]) {
if (!saved || typeof saved.pubkey !== "string") { continue }
try {
const signer = buildSigner(saved)
if (signer) {
accounts.push({ method: saved.method, pubkey: saved.pubkey, secret: saved.secret, signer })
}
} catch {}
}
}
// Restore active account
const savedActivePubkey = localStorage.getItem(ACTIVE_KEY)
const initialAccount = accounts.find(a => a.pubkey === savedActivePubkey) ?? null
export const [account, _setAccount] = createSignal<Account | null>(initialAccount)
export function login(acc: Account) {
accounts = [...accounts.filter(a => a.pubkey !== acc.pubkey), acc]
persistAccounts()
localStorage.setItem(ACTIVE_KEY, acc.pubkey)
_setAccount(acc)
}
export function logout() {
localStorage.removeItem(ACTIVE_KEY)
_setAccount(null)
export function logout(): void {
// dropSession removes the session and its cached signer entirely; the sync()
// wiring in boot() persists the removal. Re-login goes through the Login
// screen, which creates a fresh session.
const pk = pubkey.get()
if (pk) { dropSession(pk) }
}
export const [view, setView] = createSignal<View>("inbox")
@@ -81,58 +44,169 @@ export function setTab(tab: "log" | "members" | "chat"): void {
setView(v => (typeof v === "object" && v.type === "quorum") ? { ...v, tab } : v)
}
// Completed quorum records (carry the secret share) — populated when a DKG finishes.
export const [quora, setQuora] = createStore<QuorumRecord[]>([])
export const [dkgSessions, setDkgSessions] = createStore<DkgSession[]>([])
export const [resharingSessions, setResharingSessions] = createStore<ResharingSession[]>([])
export const [signingSessions, setSigningSessions] = createStore<SigningSession[]>([])
export const activeQuorum = createMemo<QuorumRecord | undefined>(() => {
// ── Optimistic delivery status, keyed by invite id ────────────────────────────
// Set by createQuorum so the list can show "Sending…" / "Delivery failed" before
// (and regardless of) any relay accepting the invite.
const [delivery, setDeliveryStore] = createStore<Record<string, "sending" | "saved" | "failed">>({})
export function setDelivery(inviteId: string, status: "sending" | "saved" | "failed"): void {
setDeliveryStore(inviteId, status)
}
// ── Repository-derived protocol events ────────────────────────────────────────
// Quora are derived from protocol events in the repository: an invite (7050) is the
// origin, round-1 broadcasts (7051) and declines (7061) track participation, and a
// completion (7053) carries the resulting quorum pubkey. Because publishThunk writes
// optimistically to the repository, a freshly created quorum appears here instantly
// and survives reloads (storage persists protocol kinds) — even if never delivered.
const [me, setMe] = createSignal("")
const [inviteEvents, setInviteEvents] = createSignal<TrustedEvent[]>([])
const [round1Events, setRound1Events] = createSignal<TrustedEvent[]>([])
const [declineEvents, setDeclineEvents] = createSignal<TrustedEvent[]>([])
const [completeEvents, setCompleteEvents] = createSignal<TrustedEvent[]>([])
deriveEvents({ repository, filters: [{ kinds: [7050] }] }).subscribe(setInviteEvents)
deriveEvents({ repository, filters: [{ kinds: [7051] }] }).subscribe(setRound1Events)
deriveEvents({ repository, filters: [{ kinds: [7061] }] }).subscribe(setDeclineEvents)
deriveEvents({ repository, filters: [{ kinds: [7053] }] }).subscribe(setCompleteEvents)
function authorsByInviteId(events: TrustedEvent[]): Map<string, Set<string>> {
const m = new Map<string, Set<string>>()
for (const e of events) {
const id = getTagValue("e", e.tags)
if (!id) { continue }
if (!m.has(id)) { m.set(id, new Set()) }
m.get(id)!.add(e.pubkey)
}
return m
}
function quorumPubkeyByInviteId(events: TrustedEvent[]): Map<string, string> {
const m = new Map<string, string>()
for (const e of events) {
const id = getTagValue("e", e.tags)
const quorumPubkey = getTagValue("quorum", e.tags)
if (id && quorumPubkey) { m.set(id, quorumPubkey) }
}
return m
}
/** All quora visible to the user — completed and pending — with live status. */
export const displayedQuora = createMemo<DisplayedQuorum[]>(() => {
const mine = me()
if (!mine) { return [] }
const completed = quorumPubkeyByInviteId(completeEvents())
const joinedByInvite = authorsByInviteId(round1Events())
const declinedByInvite = authorsByInviteId(declineEvents())
const list = inviteEvents()
.filter(inv => inv.pubkey === mine || getTagValues("member", inv.tags).includes(mine))
.map(inv => {
const members = getTagValues("member", inv.tags)
const threshold = Number(getTagValue("threshold", inv.tags) ?? "1") || 1
const quorumPubkey = completed.get(inv.id)
const complete = Boolean(quorumPubkey)
// The creator has implicitly joined; others count once they broadcast round 1.
const joined = new Set([inv.pubkey, ...(joinedByInvite.get(inv.id) ?? [])])
const declined = declinedByInvite.get(inv.id)?.size ?? 0
const deliveryStatus = delivery[inv.id]
let status: QuorumStatusKind
let statusLabel: string
if (complete) {
status = "complete"
statusLabel = "Complete"
} else if (deliveryStatus === "sending") {
status = "sending"
statusLabel = "Sending…"
} else if (deliveryStatus === "failed") {
status = "failed"
statusLabel = "Delivery failed"
} else {
status = "pending"
statusLabel = `${joined.size}/${members.length} joined` + (declined ? ` · ${declined} declined` : "")
}
return {
id: quorumPubkey ?? inv.id,
quorumPubkey,
inviteId: inv.id,
members: assignIndices(members),
threshold,
complete,
status,
statusLabel,
joined: joined.size,
declined,
createdAt: inv.created_at,
} satisfies DisplayedQuorum
})
return list.sort((a, b) => b.createdAt - a.createdAt)
})
export const activeQuorum = createMemo<DisplayedQuorum | undefined>(() => {
const v = view()
if (typeof v === "object" && v.type === "quorum") {
return quora.find(q => q.quorumPubkey === v.id)
return displayedQuora().find(q => q.id === v.id || q.inviteId === v.id)
}
return undefined
})
// Inbox subscription — one active subscription at a time
let inboxSub: Subscription | null = null
createEffect(() => {
const acc = account()
// Tear down any existing subscription first
if (inboxSub) {
inboxSub.unsubscribe()
inboxSub = null
}
if (!acc) {
return
}
// ── Inbox subscription ────────────────────────────────────────────────────────
// One active subscription, restarted when the active pubkey or the user's
// kind-10050 relay list changes. Subscribing to userMessagingRelayList makes it
// auto-load the 10050 (resolving the 10002 via the indexer first) whenever the
// pubkey changes. These are welshman stores, not Solid signals, so they are
// consumed with plain .subscribe — alive for the app lifetime.
let inboxSub: { unsubscribe(): void } | null = null
let inboxPk: string | undefined
let inboxRelays: string[] = []
function restartInbox(): void {
inboxSub?.unsubscribe()
inboxSub = null
if (!inboxPk || !inboxRelays.length) { return }
try {
inboxSub = subscribeInbox(MESSAGING_RELAYS, acc.pubkey, acc.signer)
inboxSub = subscribeInbox(inboxRelays, inboxPk)
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to subscribe to inbox")
}
}
pubkey.subscribe($pk => {
setMe($pk ?? "")
if ($pk === inboxPk) { return }
inboxPk = $pk
if ($pk) {
// Eager prefetch for the current user (cached/deduped by @welshman/app)
loadProfile($pk)
loadRelayList($pk)
}
restartInbox()
})
// Eagerly fetch relay lists and profile for the current user
createEffect(() => {
const pk = account()?.pubkey
if (pk) {
loadRelayLists([pk])
loadMessagingLists([pk])
fetchProfile(pk)
}
userMessagingRelayList.subscribe($list => {
const relays = getRelaysFromList($list)
if (JSON.stringify(relays) === JSON.stringify(inboxRelays)) { return }
inboxRelays = relays
restartInbox()
})
// Eagerly fetch relay lists and profiles for all quorum members
// Eagerly fetch relay lists and profiles for every member of every visible quorum,
// so their names render in the list and detail views.
createEffect(() => {
const pubkeys = quora.flatMap(q => q.members.map(m => m.pubkey))
if (pubkeys.length > 0) {
loadRelayLists(pubkeys)
loadMessagingLists(pubkeys)
for (const pk of pubkeys) { fetchProfile(pk) }
const pubkeys = new Set(displayedQuora().flatMap(q => q.members.map(m => m.pubkey)))
for (const pk of pubkeys) {
loadRelayList(pk)
loadMessagingRelayList(pk)
loadProfile(pk)
}
})