Compare commits

..

3 Commits

17 changed files with 685 additions and 1036 deletions
+1
View File
@@ -14,6 +14,7 @@ VITE_PUSH_SERVER=https://nps.flotilla.social/
VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_ASSERTION_RELAYS=nip85.brainstorm.world,nip85.nosfabrica.com,nip85.uid.ovh
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
+24
View File
@@ -0,0 +1,24 @@
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
VITE_PLATFORM_URL=https://app.flotilla.social
VITE_PLATFORM_TERMS=https://flotilla.social/terms
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/logo.png
VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr - for communities."
VITE_PUSH_SERVER=https://nps.flotilla.social/
VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_ASSERTION_RELAYS=nip85.brainstorm.world,nip85.nosfabrica.com,nip85.uid.ovh
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+3 -7
View File
@@ -21,16 +21,12 @@ ENV VITE_BUILD_HASH=${VITE_BUILD_HASH}
ENV NODE_OPTIONS=--max_old_space_size=16384
RUN pnpm run build
RUN pnpm prune --prod
FROM node:20-bookworm-slim
FROM node:20-alpine
WORKDIR /app
# Copy production runtime only
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
# Copy only the built output - no source, no .env, no dev deps
COPY --from=builder /app/build ./build
COPY --from=builder /app/server.js ./server.js
CMD ["node", "server.js"]
CMD ["npx", "serve", "-s", "build"]
+3 -2
View File
@@ -1,3 +1,4 @@
# Flotilla
A discord-like nostr client based on the idea of "relays as groups".
@@ -8,11 +9,11 @@ If you would like to be interoperable with Flotilla, please check out this guide
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples):
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
- `VITE_PLATFORM_URL` - The url where the app will be hosted
- `VITE_PLATFORM_NAME` - The name of the app
- `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file.
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
- `VITE_ASSERTION_RELAYS` - A list of comma-separated relays used to fetch NIP-85 assertion ranks (kind 30382) for fallback trust scores.
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
@@ -31,7 +32,7 @@ To run your own Flotilla, it's as simple as:
```sh
pnpm install
pnpm run build
node server.js
npx serve -s build
```
Or, if you prefer to use a container:
-4
View File
@@ -4,7 +4,6 @@
"private": true,
"scripts": {
"dev": "vite dev",
"start": "node server.js",
"build": "./build.sh",
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
"tauri:dev": "tauri dev",
@@ -61,7 +60,6 @@
"@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0",
"@getalby/sdk": "^5.1.2",
"@hono/node-server": "^1.19.14",
"@noble/curves": "^1.9.7",
"@pomade/core": "^0.2.3",
"@poppanator/sveltekit-svg": "^4.2.1",
@@ -82,14 +80,12 @@
"@welshman/signer": "^0.8.13",
"@welshman/store": "^0.8.13",
"@welshman/util": "^0.8.13",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"daisyui": "^5.5.19",
"date-picker-svelte": "^2.17.0",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1",
"fuse.js": "^7.1.0",
"hono": "^4.12.14",
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
-141
View File
@@ -62,9 +62,6 @@ importers:
'@getalby/sdk':
specifier: ^5.1.2
version: 5.1.2(typescript@5.9.3)
'@hono/node-server':
specifier: ^1.19.14
version: 1.19.14(hono@4.12.14)
'@noble/curves':
specifier: ^1.9.7
version: 1.9.7
@@ -125,9 +122,6 @@ importers:
'@welshman/util':
specifier: ^0.8.13
version: 0.8.13(@noble/curves@1.9.7)(@welshman/lib@0.8.13)(nostr-tools@2.20.0(typescript@5.9.3))
cheerio:
specifier: ^1.2.0
version: 1.2.0
compressorjs-next:
specifier: ^1.1.2
version: 1.1.2
@@ -146,9 +140,6 @@ importers:
fuse.js:
specifier: ^7.1.0
version: 7.1.0
hono:
specifier: ^4.12.14
version: 4.12.14
husky:
specifier: ^9.1.7
version: 9.1.7
@@ -1105,12 +1096,6 @@ packages:
resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==}
engines: {node: '>=14'}
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -2480,13 +2465,6 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
cheerio@1.2.0:
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
engines: {node: '>=20.18.1'}
chevrotain@7.1.1:
resolution: {integrity: sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==}
@@ -2852,9 +2830,6 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
encoding-sniffer@0.2.1:
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
@@ -2866,14 +2841,6 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -3267,10 +3234,6 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
hono@4.12.14:
resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==}
engines: {node: '>=16.9.0'}
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -3278,9 +3241,6 @@ packages:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
@@ -3289,10 +3249,6 @@ packages:
ico-endec@0.1.6:
resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -4059,15 +4015,6 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
parse5-parser-stream@7.1.2:
resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
path-exists@3.0.0:
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
@@ -4509,9 +4456,6 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.1.4:
resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==}
@@ -4947,10 +4891,6 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.25.0:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
engines: {node: '>=20.18.1'}
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'}
@@ -5069,15 +5009,6 @@ packages:
resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==}
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -6297,10 +6228,6 @@ snapshots:
transitivePeerDependencies:
- typescript
'@hono/node-server@1.19.14(hono@4.12.14)':
dependencies:
hono: 4.12.14
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -7715,29 +7642,6 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
cheerio-select@2.1.0:
dependencies:
boolbase: 1.0.0
css-select: 5.2.2
css-what: 6.2.2
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
cheerio@1.2.0:
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.2.2
encoding-sniffer: 0.2.1
htmlparser2: 10.1.0
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
undici: 7.25.0
whatwg-mimetype: 4.0.0
chevrotain@7.1.1:
dependencies:
regexp-to-ast: 0.5.0
@@ -8128,11 +8032,6 @@ snapshots:
emoji-regex@8.0.0: {}
encoding-sniffer@0.2.1:
dependencies:
iconv-lite: 0.6.3
whatwg-encoding: 3.1.1
enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
@@ -8142,10 +8041,6 @@ snapshots:
entities@4.5.0: {}
entities@6.0.1: {}
entities@7.0.1: {}
env-paths@2.2.1: {}
env-paths@3.0.0: {}
@@ -8655,29 +8550,16 @@ snapshots:
he@1.2.0: {}
hono@4.12.14: {}
hosted-git-info@2.8.9: {}
hosted-git-info@4.1.0:
dependencies:
lru-cache: 6.0.0
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
husky@9.1.7: {}
ico-endec@0.1.6: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
idb@7.1.1: {}
idb@8.0.3: {}
@@ -9384,19 +9266,6 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
parse5: 7.3.0
parse5-parser-stream@7.1.2:
dependencies:
parse5: 7.3.0
parse5@7.3.0:
dependencies:
entities: 6.0.1
path-exists@3.0.0: {}
path-exists@4.0.0: {}
@@ -9835,8 +9704,6 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safer-buffer@2.1.2: {}
sax@1.1.4: {}
sax@1.4.4: {}
@@ -10362,8 +10229,6 @@ snapshots:
undici-types@7.16.0: {}
undici@7.25.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0:
@@ -10444,12 +10309,6 @@ snapshots:
dependencies:
sdp: 3.2.1
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
whatwg-mimetype@4.0.0: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
-816
View File
@@ -1,816 +0,0 @@
// @ts-nocheck
import {readFile} from "node:fs/promises"
import {dirname, extname, join} from "node:path"
import {fileURLToPath} from "node:url"
import {load as loadHtml} from "cheerio"
import {serve} from "@hono/node-server"
import {serveStatic} from "@hono/node-server/serve-static"
import {Hono} from "hono"
import {request} from "@welshman/net"
import {
Address,
CLASSIFIED,
EVENT_TIME,
POLL,
ROOM_META,
THREAD,
ZAP_GOAL,
displayPubkey,
getTagValue,
normalizeRelayUrl,
readRoomMeta,
} from "@welshman/util"
const __dirname = dirname(fileURLToPath(import.meta.url))
const buildDir = join(__dirname, "build")
const indexPath = join(buildDir, "index.html")
const RELAY_CACHE_TTL_MS = 5 * 60 * 1000
const NOSTR_CACHE_TTL_MS = 60 * 1000
const RELAY_TIMEOUT_MS = 1500
const NOSTR_TIMEOUT_MS = 1800
const META_TOKENS = {
card: "__FLOTILLA_META_CARD__",
description: "__FLOTILLA_META_DESCRIPTION__",
image: "__FLOTILLA_META_IMAGE__",
site: "__FLOTILLA_META_SITE__",
title: "__FLOTILLA_META_TITLE__",
type: "__FLOTILLA_META_TYPE__",
url: "__FLOTILLA_META_URL__",
}
const staticTitles = new Map([
["/", "Redirecting"],
["/home", "Home"],
["/spaces", "Spaces"],
["/spaces/create", "Create a Space"],
["/chat", "Messages"],
["/join", "Join Space"],
["/people", "Find People"],
["/settings/about", "About"],
["/settings/profile", "Profile Settings"],
["/settings/content", "Content Settings"],
["/settings/privacy", "Privacy Settings"],
["/settings/relays", "Relay Settings"],
["/settings/alerts", "Alert Settings"],
["/settings/wallet", "Wallet Settings"],
])
const spaceSectionTitles = new Map([
["chat", "Space Chat"],
["recent", "Recent Activity"],
["threads", "Threads"],
["classifieds", "Classifieds"],
["calendar", "Calendar"],
["goals", "Goals"],
["polls", "Polls"],
])
const eventRouteKinds = new Map([
["threads", THREAD],
["goals", ZAP_GOAL],
["calendar", EVENT_TIME],
["classifieds", CLASSIFIED],
["polls", POLL],
])
const reservedSingleSegments = new Set([
"home",
"spaces",
"space",
"chat",
"join",
"people",
"settings",
])
const relayInfoCache = new Map()
const roomInfoCache = new Map()
const eventCache = new Map()
const indexHtml = await readFile(indexPath, "utf8").catch(error => {
console.error("Unable to start server: build/index.html is missing. Run `pnpm run build` first.")
throw error
})
const defaults = getHtmlDefaults(indexHtml)
const htmlTemplate = createHtmlTemplate(indexHtml)
const app = new Hono()
const staticFiles = serveStatic({
root: "./build",
rewriteRequestPath: path => path.replace(/^\/+/, ""),
})
app.use("*", staticFiles)
app.get("*", async c => {
const requestUrl = new URL(c.req.url)
if (extname(requestUrl.pathname)) {
return c.text("Not Found", 404)
}
const origin = getRequestOrigin(c.req.raw, requestUrl)
const meta = await buildRouteMeta(requestUrl, origin)
const html = renderHtml(htmlTemplate, meta)
c.header("Cache-Control", "no-cache")
return c.html(html)
})
app.notFound(c => c.text("Not Found", 404))
app.onError((error, c) => {
console.error(error)
return c.text("Internal Server Error", 500)
})
const port = Number.parseInt(process.env.PORT || "3000", 10)
const host = process.env.HOST || "0.0.0.0"
serve({fetch: app.fetch, hostname: host, port})
console.log(`Flotilla server listening on http://${host}:${port}`)
async function buildRouteMeta(requestUrl, origin) {
const absoluteDefaultImage = toAbsoluteHttpUrl(defaults.image, origin)
if (!absoluteDefaultImage) {
throw new Error(`Default twitter:image must resolve to an absolute URL. Found: ${defaults.image}`)
}
const meta = {
card: "summary",
description: defaults.description,
image: absoluteDefaultImage,
site: defaults.site,
title: defaults.title,
type: "website",
url: requestUrl.href,
}
const route = parseRoute(requestUrl.pathname)
if (route.kind === "join") {
return await buildJoinMeta(meta, requestUrl, origin)
}
if (route.kind === "static") {
meta.title = route.title
return meta
}
if (route.kind === "chat") {
meta.title = getChatTitle(route.chat)
return meta
}
if (route.kind === "bech32") {
meta.title = "Opening Link"
return meta
}
if (!route.relay) {
return meta
}
const relayUrl = normalizeRelayParam(route.relay)
const relayInfo = relayUrl ? await loadRelayInfo(relayUrl) : undefined
const relayName = relayInfo?.name || (relayUrl ? getRelayDisplay(relayUrl) : "Space")
const relayHttpUrl = relayUrl ? toRelayHttpUrl(relayUrl) : undefined
if (relayInfo?.icon) {
meta.image = relayInfo.icon
}
if (relayInfo?.description) {
meta.description = relayInfo.description
}
if (route.kind === "space") {
meta.title = relayName
return meta
}
if (route.kind === "space-section") {
meta.title = composeSpaceTitle(relayName, route.sectionTitle)
return meta
}
if (route.kind === "room") {
const roomInfo = relayUrl ? await loadRoomInfo(relayUrl, route.h) : undefined
const roomName = roomInfo?.name || route.h
meta.title = composeSpaceTitle(relayName, roomName)
meta.description = roomInfo?.about || meta.description
const roomImage = roomInfo?.picture
? toAbsoluteHttpUrl(roomInfo.picture, relayHttpUrl || origin)
: undefined
if (roomImage) {
meta.image = roomImage
}
return meta
}
if (route.kind === "event") {
const event = relayUrl
? await loadEventForRoute(relayUrl, route.section, route.identifier)
: undefined
const eventTitle = getEventTitle(route.section, event)
meta.title = composeSpaceTitle(relayName, eventTitle)
meta.description = getEventDescription(route.section, event, meta.description)
const eventImage = getTagValue("image", event?.tags || [])
const absoluteEventImage = eventImage
? toAbsoluteHttpUrl(eventImage, relayHttpUrl || origin)
: undefined
if (absoluteEventImage) {
meta.image = absoluteEventImage
meta.card = "summary_large_image"
}
return meta
}
return meta
}
function parseRoute(pathname) {
const normalizedPath = normalizePathname(pathname)
if (normalizedPath === "/join") {
return {kind: "join"}
}
if (staticTitles.has(normalizedPath)) {
return {kind: "static", title: staticTitles.get(normalizedPath)}
}
const segments = getPathSegments(normalizedPath)
if (segments.length === 2 && segments[0] === "chat") {
return {chat: segments[1], kind: "chat"}
}
if (segments.length === 1 && !reservedSingleSegments.has(segments[0])) {
return {bech32: segments[0], kind: "bech32"}
}
if ((segments[0] === "spaces" || segments[0] === "space") && segments.length >= 2) {
const relay = segments[1]
if (segments.length === 2) {
return {kind: "space", relay}
}
const section = segments[2]
if (segments.length === 3) {
if (spaceSectionTitles.has(section)) {
return {
kind: "space-section",
relay,
section,
sectionTitle: spaceSectionTitles.get(section),
}
}
return {h: section, kind: "room", relay}
}
if (segments.length === 4 && eventRouteKinds.has(section)) {
return {
identifier: segments[3],
kind: "event",
relay,
section,
}
}
}
return {kind: "unknown"}
}
async function buildJoinMeta(meta, requestUrl, origin) {
const relayUrl = parseInviteRelay(requestUrl)
if (!relayUrl) {
meta.title = staticTitles.get("/join")
return meta
}
const relayInfo = await loadRelayInfo(relayUrl)
const relayDisplay = relayInfo?.name || getRelayDisplay(relayUrl)
meta.title = `Invitation to join ${relayDisplay}`
meta.description = relayInfo?.description || `Join this Flotilla space on ${relayDisplay}.`
meta.image = relayInfo?.icon || meta.image
meta.url = requestUrl.href
meta.site = defaults.site
return meta
}
function getChatTitle(chat) {
if (!chat) {
return "Chat"
}
const peers = chat
.split(",")
.map(part => part.trim())
.filter(Boolean)
if (peers.length === 1) {
return `Chat with ${displayPubkey(peers[0])}`
}
if (peers.length > 1) {
return `Group chat (${peers.length})`
}
return "Chat"
}
function getEventTitle(section, event) {
if (section === "threads") {
return getTagValue("title", event?.tags || []) || "Thread"
}
if (section === "calendar") {
return getTagValue("title", event?.tags || []) || "Event"
}
if (section === "classifieds") {
return getTagValue("title", event?.tags || []) || "Listing"
}
if (section === "goals") {
return event?.content?.trim() || getTagValue("summary", event?.tags || []) || "Goal"
}
if (section === "polls") {
return getTagValue("title", event?.tags || []) || "Poll"
}
return "Event"
}
function getEventDescription(section, event, fallback) {
const summary =
getTagValue("summary", event?.tags || []) || getTagValue("description", event?.tags || [])
if (summary) {
return clip(summary, 220)
}
if (event?.content?.trim()) {
return clip(event.content.trim(), 220)
}
if (section === "threads") {
return "Read this thread in Flotilla."
}
if (section === "goals") {
return "Track this goal in Flotilla."
}
if (section === "calendar") {
return "View this calendar event in Flotilla."
}
if (section === "classifieds") {
return "Browse this listing in Flotilla."
}
if (section === "polls") {
return "Take this poll in Flotilla."
}
return fallback
}
function composeSpaceTitle(spaceName, leafTitle) {
const cleanedSpace = spaceName?.trim()
const cleanedLeaf = leafTitle?.trim()
if (cleanedSpace && cleanedLeaf) {
return `${cleanedSpace} / ${cleanedLeaf}`
}
return cleanedLeaf || cleanedSpace || defaults.title
}
async function loadRelayInfo(relayUrl) {
const cached = getCachedValue(relayInfoCache, relayUrl)
if (cached) {
return cached
}
const relayHttpUrl = toRelayHttpUrl(relayUrl)
if (!relayHttpUrl) {
setCachedValue(relayInfoCache, relayUrl, undefined, RELAY_CACHE_TTL_MS)
return undefined
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS)
let value
try {
const response = await fetch(relayHttpUrl, {
headers: {Accept: "application/nostr+json"},
signal: controller.signal,
})
if (response.ok) {
const json = await response.json()
const name = typeof json.name === "string" ? json.name.trim() : ""
const description = typeof json.description === "string" ? json.description.trim() : ""
const icon = typeof json.icon === "string" ? toAbsoluteHttpUrl(json.icon, relayHttpUrl) : undefined
value = {
description: description || undefined,
icon,
name: name || undefined,
}
}
} catch {
value = undefined
} finally {
clearTimeout(timeout)
}
setCachedValue(relayInfoCache, relayUrl, value, RELAY_CACHE_TTL_MS)
return value
}
async function loadRoomInfo(relayUrl, h) {
const cacheKey = `${relayUrl}|${h}`
const cached = getCachedValue(roomInfoCache, cacheKey)
if (cached !== undefined) {
return cached
}
const events = await requestEvents(relayUrl, [{"#d": [h], kinds: [ROOM_META], limit: 20}])
const roomMetas = []
for (const event of events) {
try {
const roomMeta = readRoomMeta(event)
if (roomMeta.h === h) {
roomMetas.push(roomMeta)
}
} catch {
// Ignore malformed room metadata.
}
}
const latest = roomMetas.sort((a, b) => b.event.created_at - a.event.created_at)[0]
const roomInfo = latest
? {
about: latest.about,
name: latest.name,
picture: latest.picture,
}
: undefined
setCachedValue(roomInfoCache, cacheKey, roomInfo, NOSTR_CACHE_TTL_MS)
return roomInfo
}
async function loadEventForRoute(relayUrl, section, identifier) {
const kind = eventRouteKinds.get(section)
if (!kind || !identifier) {
return undefined
}
const cacheKey = `${relayUrl}|${section}|${identifier}`
const cached = getCachedValue(eventCache, cacheKey)
if (cached !== undefined) {
return cached
}
const filters = getEventFilters(kind, identifier)
const events = filters.length > 0 ? await requestEvents(relayUrl, filters) : []
const event = events[0]
setCachedValue(eventCache, cacheKey, event, NOSTR_CACHE_TTL_MS)
return event
}
function getEventFilters(kind, identifier) {
if (kind === EVENT_TIME || kind === CLASSIFIED) {
try {
const address = Address.from(identifier)
return [
{
"#d": [address.identifier],
authors: [address.pubkey],
kinds: [address.kind],
limit: 1,
},
]
} catch {
return [{ids: [identifier], kinds: [kind], limit: 1}]
}
}
return [{ids: [identifier], kinds: [kind], limit: 1}]
}
async function requestEvents(relayUrl, filters) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), NOSTR_TIMEOUT_MS)
try {
return await request({
autoClose: true,
filters,
relays: [relayUrl],
signal: controller.signal,
})
} catch {
return []
} finally {
clearTimeout(timeout)
}
}
function createHtmlTemplate(html) {
const $ = loadHtml(html)
upsertTitle($, META_TOKENS.title)
upsertMetaTag($, "name", "description", META_TOKENS.description)
upsertMetaTag($, "name", "og:url", META_TOKENS.url)
upsertMetaTag($, "name", "og:type", META_TOKENS.type)
upsertMetaTag($, "name", "og:title", META_TOKENS.title)
upsertMetaTag($, "name", "og:description", META_TOKENS.description)
upsertMetaTag($, "name", "twitter:card", META_TOKENS.card)
upsertMetaTag($, "name", "twitter:site", META_TOKENS.site)
upsertMetaTag($, "name", "twitter:title", META_TOKENS.title)
upsertMetaTag($, "name", "twitter:description", META_TOKENS.description)
upsertMetaTag($, "name", "twitter:image", META_TOKENS.image)
upsertMetaTag($, "property", "og:url", META_TOKENS.url)
upsertMetaTag($, "property", "og:type", META_TOKENS.type)
upsertMetaTag($, "property", "og:title", META_TOKENS.title)
upsertMetaTag($, "property", "og:description", META_TOKENS.description)
upsertMetaTag($, "property", "og:image", META_TOKENS.image)
return $.html()
}
function renderHtml(template, meta) {
return template
.replaceAll(META_TOKENS.card, escapeHtml(meta.card))
.replaceAll(META_TOKENS.description, escapeHtml(meta.description))
.replaceAll(META_TOKENS.image, escapeHtml(meta.image))
.replaceAll(META_TOKENS.site, escapeHtml(meta.site))
.replaceAll(META_TOKENS.title, escapeHtml(meta.title))
.replaceAll(META_TOKENS.type, escapeHtml(meta.type))
.replaceAll(META_TOKENS.url, escapeHtml(meta.url))
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
function upsertTitle($, value) {
let titleTag = $("head > title").first()
if (titleTag.length === 0) {
$("head").prepend("<title></title>")
titleTag = $("head > title").first()
}
titleTag.text(value)
}
function upsertMetaTag($, attribute, key, content) {
const selector = `meta[${attribute}="${key}"]`
let metaTag = $(selector).first()
if (metaTag.length === 0) {
metaTag = $("<meta>").attr(attribute, key)
$("head").append(metaTag)
}
metaTag.attr("content", content)
}
function getHtmlDefaults(html) {
const $ = loadHtml(html)
return {
description: readRequiredMetaContent($, "og:description"),
image: readRequiredMetaContent($, "twitter:image"),
site: readRequiredMetaContent($, "twitter:site"),
title: readRequiredMetaContent($, "og:title"),
}
}
function readRequiredMetaContent($, key) {
const content = readMetaContent($, key)
if (!content) {
throw new Error(`Missing required meta tag ${key} in build/index.html. Ensure it exists in src/app.html.`)
}
return content
}
function readMetaContent($, key) {
const byName = $(`meta[name="${key}"]`).attr("content")
if (typeof byName === "string" && byName.trim()) {
return byName.trim()
}
const byProperty = $(`meta[property="${key}"]`).attr("content")
return typeof byProperty === "string" && byProperty.trim() ? byProperty.trim() : undefined
}
function parseInviteRelay(requestUrl) {
const relay = requestUrl.searchParams.get("r") || requestUrl.searchParams.get("relay")
if (!relay) {
return undefined
}
return normalizeRelayParam(relay)
}
function normalizeRelayParam(value) {
const decoded = value.trim()
if (!decoded) {
return undefined
}
const hasProtocol = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(decoded)
const withProtocol = hasProtocol ? decoded : `wss://${decoded}`
try {
const normalized = normalizeRelayUrl(withProtocol)
if (normalized.startsWith("ws://") || normalized.startsWith("wss://")) {
return normalized.replace(/\/+$/, "")
}
} catch {
// Ignore malformed relay URLs.
}
return undefined
}
function toRelayHttpUrl(relayUrl) {
if (relayUrl.startsWith("wss://")) {
return `https://${relayUrl.slice(6)}`
}
if (relayUrl.startsWith("ws://")) {
return `http://${relayUrl.slice(5)}`
}
return undefined
}
function getRelayDisplay(relayUrl) {
const relayHttpUrl = toRelayHttpUrl(relayUrl)
if (!relayHttpUrl) {
return relayUrl
}
try {
return new URL(relayHttpUrl).host
} catch {
return relayUrl
}
}
function toAbsoluteHttpUrl(value, baseUrl) {
try {
const parsed = new URL(value, baseUrl)
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.href
}
} catch {
// Ignore malformed URLs.
}
return undefined
}
function getRequestOrigin(req, requestUrl) {
const protocol = firstHeaderValue(req.headers.get("x-forwarded-proto")) || requestUrl.protocol.slice(0, -1)
const host =
firstHeaderValue(req.headers.get("x-forwarded-host")) ||
req.headers.get("host") ||
requestUrl.host ||
"localhost"
return `${protocol}://${host}`
}
function firstHeaderValue(value) {
if (typeof value === "string") {
return value.split(",")[0].trim()
}
return undefined
}
function normalizePathname(pathname) {
const cleanPath = pathname.replace(/\/+/g, "/")
if (cleanPath === "/") {
return "/"
}
return cleanPath.replace(/\/+$/, "") || "/"
}
function getPathSegments(pathname) {
const normalized = normalizePathname(pathname)
if (normalized === "/") {
return []
}
return normalized
.slice(1)
.split("/")
.map(decodeSegment)
}
function decodeSegment(value) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function clip(text, maxLength) {
if (text.length <= maxLength) {
return text
}
return `${text.slice(0, maxLength - 1).trimEnd()}`
}
function getCachedValue(cache, key) {
const cached = cache.get(key)
if (!cached) {
return undefined
}
if (cached.expiresAt <= Date.now()) {
cache.delete(key)
return undefined
}
return cached.value
}
function setCachedValue(cache, key, value, ttlMs) {
cache.set(key, {expiresAt: Date.now() + ttlMs, value})
}
+2 -3
View File
@@ -42,7 +42,7 @@
let popover: Instance | undefined = $state()
</script>
<div class="join items-center rounded-full">
<Button class="join rounded-full">
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
<Icon icon={Bolt} size={4} />
@@ -52,7 +52,6 @@
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
<Tippy
class="flex"
bind:popover
component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}}
@@ -61,4 +60,4 @@
<Icon icon={MenuDots} size={4} />
</Button>
</Tippy>
</div>
</Button>
+1 -1
View File
@@ -90,7 +90,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input type="email" bind:value={email} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
+1 -1
View File
@@ -61,7 +61,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input type="email" bind:value={email} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
+1 -4
View File
@@ -120,7 +120,7 @@
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={Letter} />
<input type="email" bind:value={email} />
<input bind:value={email} />
</label>
{/snippet}
</FieldInline>
@@ -134,9 +134,6 @@
<input type="password" bind:value={password} />
</label>
{/snippet}
{#snippet info()}
Must be at least 12 characters long.
{/snippet}
</FieldInline>
</ModalBody>
<ModalFooter>
+2 -22
View File
@@ -25,16 +25,6 @@
const {url} = $props()
const authError = deriveRelayAuthError(url)
let networkError = $state(false)
const isExplicitAuthError = $derived(
$authError &&
!(
$authError.toLowerCase().includes("failed") ||
$authError.toLowerCase().includes("timeout") ||
$authError.toLowerCase().includes("network")
),
)
const isGenericError = $derived(networkError || ($authError && !isExplicitAuthError))
const back = () => history.back()
const copyInvite = () => clip(invite)
@@ -80,14 +70,8 @@
])
claim = getTagValue("claim", event?.tags || []) || ""
} catch (err) {
} catch {
claim = ""
if (
(err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError")) ||
!navigator.onLine
) {
networkError = true
}
} finally {
loading = false
}
@@ -108,11 +92,7 @@
<p class="center">
<Spinner {loading}>Requesting an invite link...</Spinner>
</p>
{:else if isGenericError}
<p class="center text-center">
Unable to reach the relay. Please check your connection and try again.
</p>
{:else if isExplicitAuthError}
{:else if $authError}
<p class="center">Oops! It looks like you're not a member of this relay.</p>
{:else}
<div class="flex flex-col items-center gap-6">
+71 -15
View File
@@ -1,21 +1,35 @@
<style>
.wot-background {
fill: transparent;
stroke: var(--color-base-content);
opacity: 30%;
stroke: var(--base-300);
opacity: 1;
}
.wot-highlight {
fill: transparent;
stroke-width: 1.5;
stroke-dasharray: 100 100;
stroke-linecap: round;
transform-origin: center;
transition:
stroke-dashoffset 160ms ease,
stroke 160ms ease,
stroke-width 160ms ease;
}
.wot-score {
font-size: 8px;
font-weight: 800;
fill: var(--base-content);
paint-order: stroke;
stroke: var(--base-100);
stroke-width: 1.5px;
letter-spacing: -0.04em;
}
</style>
<script lang="ts">
import {onMount} from "svelte"
import {clamp} from "@welshman/lib"
import {pubkey, getFollows, deriveUserWotScore} from "@welshman/app"
import {getPubkeyRank} from "@app/util/wot/getPubkeyRank"
interface Props {
pubkey: string
@@ -24,27 +38,69 @@
const {pubkey: target}: Props = $props()
const max = 100
const radius = 6
const center = radius + 1
const size = 22
const radius = 8.25
const center = size / 2
const circumference = 2 * Math.PI * radius
const score = deriveUserWotScore(target)
const active = $derived(getFollows($pubkey!).includes(target))
const normalizedScore = $derived(clamp([0, max], $score) / max)
const dashOffset = $derived(100 - 44 * normalizedScore)
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
const stroke = $derived(active ? "var(--color-primary)" : "var(--color-base-content)")
let score = $state(50)
onMount(() => {
let cancelled = false
getPubkeyRank(target)
.then(rank => {
if (!cancelled) {
score = rank ?? 50
}
})
.catch(() => {
if (!cancelled) {
score = 50
}
})
return () => {
cancelled = true
}
})
const normalizedScore = $derived(clamp([0, max], score) / max)
const dashOffset = $derived(circumference * (1 - normalizedScore))
const style = $derived(`transform: rotate(-90deg)`)
const strokeWidth = $derived(2.2 + normalizedScore * 1.5)
const stroke = $derived(
score >= 90
? "var(--success)"
: score >= 75
? "var(--info)"
: score >= 50
? "var(--warning)"
: "var(--error)",
)
</script>
<div class="relative h-[14px] w-[14px]">
<svg height="14" width="14" class="absolute">
<div class="relative h-[22px] w-[22px] shrink-0">
<svg height={size} width={size} class="absolute">
<circle class="wot-background" cx={center} cy={center} r={radius} />
<circle
cx={center}
cy={center}
r={radius}
class="wot-highlight"
fill="none"
stroke-width={strokeWidth}
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
{style}
{stroke} />
<text
x={center}
y={center + 0.15}
text-anchor="middle"
dominant-baseline="middle"
class="wot-score">
{Math.round(score)}
</text>
</svg>
</div>
-11
View File
@@ -18,7 +18,6 @@ import {
uniq,
indexBy,
partition,
shuffle,
parseJson,
memoize,
addToMapKey,
@@ -139,7 +138,6 @@ import {
tracker,
createSearch,
userMuteList,
userFollowList,
ensurePlaintext,
makeOutboxLoader,
appContext,
@@ -205,8 +203,6 @@ export const POMADE_SIGNERS = fromCsv(import.meta.env.VITE_POMADE_SIGNERS)
export const DEFAULT_BLOSSOM_SERVERS = fromCsv(import.meta.env.VITE_DEFAULT_BLOSSOM_SERVERS)
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL
@@ -257,13 +253,6 @@ export const entityLink = (entity: string) => `https://coracle.social/${entity}`
export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
entityLink(nip19.nprofileEncode({pubkey, relays}))
export const bootstrapPubkeys = derived(userFollowList, $userFollowList => {
const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollowList)))
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
})
export const deriveEvent = makeDeriveEvent({
repository,
includeDeleted: true,
+522
View File
@@ -0,0 +1,522 @@
import {request} from "@welshman/net"
import {clamp} from "@welshman/lib"
import {nip19} from "nostr-tools"
import {getProfile} from "@welshman/app"
import type {Filter, TrustedEvent} from "@welshman/util"
const DEFAULT_EXTENDED_SEARCH_RELAYS = [
import.meta.env.VITE_DEFAULT_SEARCH_RELAYS,
"relay.noswhere.com,search.nos.today,nostr.wine",
]
.filter(Boolean)
.join(",")
const DEFAULT_SCORE = 50
const SEARCH_LIMIT = 40
const MAX_PROFILE_TERMS = 3
const MIN_PROFILE_TERM_LENGTH = 3
const REQUEST_THRESHOLD = 0.5
const PROFILE_LOOKUP_TIMEOUT_MS = 6_000
const RELAY_SEARCH_TIMEOUT_MS = 6_000
const NIP11_TIMEOUT_MS = 4_000
const toRelayUrl = (url: string) => {
const trimmed = url.trim()
if (!trimmed) return ""
if (trimmed.startsWith("http://")) return trimmed.replace(/^http:/, "ws:")
if (trimmed.startsWith("https://")) return trimmed.replace(/^https:/, "wss:")
if (!/^[a-z]+:\/\//i.test(trimmed)) return `wss://${trimmed}`
return trimmed
}
const toNip11Url = (url: string) => {
const normalized = toRelayUrl(url)
if (normalized.startsWith("wss://")) return normalized.replace(/^wss:/, "https:")
if (normalized.startsWith("ws://")) return normalized.replace(/^ws:/, "http:")
return normalized
}
const EXTENDED_SEARCH_RELAYS = String(
import.meta.env.VITE_EXTENDED_SEARCH_RELAYS || DEFAULT_EXTENDED_SEARCH_RELAYS,
)
.split(",")
.map(toRelayUrl)
.filter(Boolean)
.filter((url, index, urls) => urls.indexOf(url) === index)
type Nip11RelayInfo = {
supportsNip50: boolean
nip50Extensions: string[]
}
type RelaySearchInfo = {
relay: string
extensions: string[]
}
type ScoreResult = {
score: number
index: number
relayHits: number
activeRelays: number
interleavedCount: number
}
type TimedRequestOptions = {
relays: string[]
filters: Filter[]
timeout?: number
}
const rankCache = new Map<string, number>()
const pendingRankRequests = new Map<string, Promise<number>>()
const relayInfoCache = new Map<string, Promise<Nip11RelayInfo>>()
const toStringArray = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value
.filter(item => typeof item === "string" || typeof item === "number")
.map(item => String(item))
}
if (typeof value === "string") {
return [value]
}
if (value && typeof value === "object") {
return Object.keys(value)
}
return []
}
const isHexPubkey = (value: string) => /^[0-9a-f]{64}$/i.test(value)
const normalizePubkey = (value: string) => {
const normalized = value.trim().toLowerCase()
if (!normalized) {
return ""
}
if (isHexPubkey(normalized)) {
return normalized
}
try {
const decoded = nip19.decode(normalized)
if (decoded.type === "npub" && typeof decoded.data === "string" && isHexPubkey(decoded.data)) {
return decoded.data.toLowerCase()
}
} catch {
// Ignore decode errors and return an empty key for invalid values.
}
return ""
}
const requestWithTimeout = async ({
relays,
filters,
timeout = RELAY_SEARCH_TIMEOUT_MS,
}: TimedRequestOptions): Promise<TrustedEvent[]> => {
if (relays.length === 0) {
return []
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
return await request({
relays,
autoClose: true,
threshold: REQUEST_THRESHOLD,
signal: controller.signal,
filters,
})
} catch {
return []
} finally {
clearTimeout(timeoutId)
}
}
const normalizeSearchTerm = (value: unknown): string => {
if (typeof value !== "string") {
return ""
}
return value.trim().replace(/\s+/g, " ")
}
const dedupeProfileTerms = (terms: string[]) => {
const seen = new Set<string>()
return terms.filter(term => {
if (term.length < MIN_PROFILE_TERM_LENGTH) {
return false
}
const key = term.toLowerCase()
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
const getTermsFromProfileRecord = (profile: Record<string, unknown>) => {
const terms = [
normalizeSearchTerm(profile.name),
normalizeSearchTerm(profile.display_name),
normalizeSearchTerm(profile.nip05),
]
const nip05 = normalizeSearchTerm(profile.nip05)
if (nip05.includes("@")) {
terms.push(normalizeSearchTerm(nip05.split("@")[0]))
}
return dedupeProfileTerms(terms).slice(0, MAX_PROFILE_TERMS)
}
const parseSupportedNips = (nip11: Record<string, unknown>) =>
[...toStringArray(nip11.supported_nips), ...toStringArray(nip11.supportedNips)]
.map(nip => nip.trim())
.filter(Boolean)
.filter((nip, index, nips) => nips.indexOf(nip) === index)
const parseNip50Extensions = (nip11: Record<string, unknown>) => {
const extensions = [
...toStringArray(nip11.nip50),
...toStringArray(nip11.nip_50),
...toStringArray(nip11["nip-50"]),
]
if (nip11.extensions && typeof nip11.extensions === "object") {
const extensionObject = nip11.extensions as Record<string, unknown>
extensions.push(...toStringArray(extensionObject.nip50))
extensions.push(...toStringArray(extensionObject.nip_50))
extensions.push(...toStringArray(extensionObject["nip-50"]))
}
return extensions
.map(extension => extension.trim())
.filter(Boolean)
.filter((extension, index, items) => items.indexOf(extension) === index)
}
const fetchRelayInfo = async (relay: string): Promise<Nip11RelayInfo> => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), NIP11_TIMEOUT_MS)
try {
const response = await fetch(toNip11Url(relay), {
headers: {Accept: "application/nostr+json"},
signal: controller.signal,
})
if (!response.ok) {
return {supportsNip50: false, nip50Extensions: []}
}
const nip11 = (await response.json()) as Record<string, unknown>
const supportedNips = parseSupportedNips(nip11)
const nip50Extensions = parseNip50Extensions(nip11)
return {
supportsNip50: supportedNips.includes("50") || nip50Extensions.length > 0,
nip50Extensions,
}
} catch {
return {supportsNip50: false, nip50Extensions: []}
} finally {
clearTimeout(timeoutId)
}
}
const getRelayInfo = (relay: string) => {
const pending = relayInfoCache.get(relay)
if (pending) {
return pending
}
const requestPromise = fetchRelayInfo(relay)
relayInfoCache.set(relay, requestPromise)
return requestPromise
}
const getNip50Relays = async (): Promise<RelaySearchInfo[]> => {
if (EXTENDED_SEARCH_RELAYS.length === 0) {
return []
}
const relayInfo = await Promise.all(
EXTENDED_SEARCH_RELAYS.map(async relay => {
const info = await getRelayInfo(relay)
if (!info.supportsNip50) {
return
}
return {relay, extensions: info.nip50Extensions}
}),
)
const supportedRelays = relayInfo.filter(
(info): info is RelaySearchInfo => Boolean(info && info.extensions) && Boolean(info?.relay),
)
if (supportedRelays.length > 0) {
return supportedRelays
}
// Some relays block cross-origin NIP-11 fetches in browser contexts.
// If that happens, still run NIP-50 requests against configured relays.
return EXTENDED_SEARCH_RELAYS.map(relay => ({relay, extensions: []}))
}
const dedupeByPubkey = (events: TrustedEvent[]) => {
const seen = new Set<string>()
const deduped: TrustedEvent[] = []
for (const event of events) {
if (seen.has(event.pubkey)) {
continue
}
seen.add(event.pubkey)
deduped.push(event)
}
return deduped
}
const interleaveByPubkey = (eventLists: TrustedEvent[][]) => {
const offsets = new Array(eventLists.length).fill(0)
const seen = new Set<string>()
const interleaved: TrustedEvent[] = []
let added = true
while (added) {
added = false
for (let index = 0; index < eventLists.length; index += 1) {
const events = eventLists[index]
while (offsets[index] < events.length) {
const event = events[offsets[index]]
offsets[index] += 1
if (seen.has(event.pubkey)) {
continue
}
seen.add(event.pubkey)
interleaved.push(event)
added = true
break
}
}
}
return interleaved
}
const getSearchQuery = (pubkey: string, extensions: string[]) => {
const hasExactPhraseMatch = extensions.some(extension =>
extension.toLowerCase().includes("exact-phrase"),
)
if (hasExactPhraseMatch) {
return `"${pubkey}"`
}
return pubkey
}
const getProfileSearchTerms = async (pubkey: string, relays: string[]) => {
const localProfile = getProfile(pubkey) as Record<string, unknown> | undefined
const localTerms = localProfile ? getTermsFromProfileRecord(localProfile) : []
if (localTerms.length >= MAX_PROFILE_TERMS || relays.length === 0) {
return localTerms.slice(0, MAX_PROFILE_TERMS)
}
const relayResults = await Promise.all(
relays.map(relay =>
requestWithTimeout({
relays: [relay],
timeout: PROFILE_LOOKUP_TIMEOUT_MS,
filters: [{kinds: [0], authors: [pubkey], limit: 1}],
}),
),
)
const newestFirst = relayResults.flat().sort((a, b) => b.created_at - a.created_at)
for (const event of newestFirst) {
try {
const profile = JSON.parse(event.content || "{}") as Record<string, unknown>
const mergedTerms = dedupeProfileTerms([...localTerms, ...getTermsFromProfileRecord(profile)])
if (mergedTerms.length > 0) {
return mergedTerms.slice(0, MAX_PROFILE_TERMS)
}
} catch {
continue
}
}
return localTerms.slice(0, MAX_PROFILE_TERMS)
}
const getSearchTerms = (pubkey: string, extensions: string[], profileTerms: string[]) => {
const terms = [...profileTerms, getSearchQuery(pubkey, extensions)]
try {
terms.push(nip19.npubEncode(pubkey))
} catch {
// If pubkey is malformed, keep using the raw search term only.
}
return terms.filter((term, index, items) => Boolean(term) && items.indexOf(term) === index)
}
const fetchRelayResults = async (
relay: string,
pubkey: string,
extensions: string[],
profileTerms: string[],
): Promise<TrustedEvent[]> => {
const terms = getSearchTerms(pubkey, extensions, profileTerms)
const results = await Promise.all(
terms.map(async term => {
const filters: Filter[] = [{kinds: [0], search: term, limit: SEARCH_LIMIT}]
return requestWithTimeout({
relays: [relay],
filters,
})
}),
)
return dedupeByPubkey(results.flat())
}
const getScoreFromResults = (pubkey: string, relayResults: TrustedEvent[][]): ScoreResult => {
if (relayResults.length === 0) {
return {
score: DEFAULT_SCORE,
index: -1,
relayHits: 0,
activeRelays: 0,
interleavedCount: 0,
}
}
const activeRelays = relayResults.filter(results => results.length > 0).length
const interleaved = interleaveByPubkey(relayResults)
if (activeRelays === 0 || interleaved.length === 0) {
return {
score: DEFAULT_SCORE,
index: -1,
relayHits: 0,
activeRelays,
interleavedCount: 0,
}
}
const index = interleaved.findIndex(event => event.pubkey === pubkey)
const relayHits = relayResults.filter(results =>
results.some(event => event.pubkey === pubkey),
).length
const coverage = relayHits / Math.max(1, activeRelays)
const position = index === -1 ? 0 : 1 - index / Math.max(1, interleaved.length - 1)
const weightedScore = (coverage * 0.55 + position * 0.45) * 100
return {
score: clamp([0, 100], Math.round(weightedScore)),
index,
relayHits,
activeRelays,
interleavedCount: interleaved.length,
}
}
const fetchPubkeyRank = async (inputPubkey: string): Promise<number> => {
const pubkey = normalizePubkey(inputPubkey)
if (!pubkey) {
return DEFAULT_SCORE
}
const relays = await getNip50Relays()
if (relays.length === 0) {
return DEFAULT_SCORE
}
const profileTerms = await getProfileSearchTerms(
pubkey,
relays.map(({relay}) => relay),
)
const relayResults = await Promise.all(
relays.map(({relay, extensions}) => fetchRelayResults(relay, pubkey, extensions, profileTerms)),
)
const scoreResult = getScoreFromResults(pubkey, relayResults)
console.log(`[Score Calc] Pubkey: ${pubkey} → Score: ${scoreResult.score}`)
return scoreResult.score
}
export const getPubkeyRank = (pubkey: string): Promise<number | null> => {
const normalizedPubkey = normalizePubkey(pubkey)
if (!normalizedPubkey) {
return Promise.resolve(DEFAULT_SCORE)
}
const cached = rankCache.get(normalizedPubkey)
if (cached !== undefined) {
return Promise.resolve(cached)
}
const pending = pendingRankRequests.get(normalizedPubkey)
if (pending) {
return pending
}
const requestPromise = fetchPubkeyRank(normalizedPubkey).then(rank => {
rankCache.set(normalizedPubkey, rank)
pendingRankRequests.delete(normalizedPubkey)
return rank
})
pendingRankRequests.set(normalizedPubkey, requestPromise)
return requestPromise
}
-1
View File
@@ -31,7 +31,6 @@
<svelte:document onmousemove={onMouseMove} />
<Tippy
class="flex"
bind:popover
component={EmojiPicker}
props={{onClick}}
+54 -8
View File
@@ -2,25 +2,71 @@
import {onMount} from "svelte"
import {debounce} from "throttle-debounce"
import {createScroller, isMobile} from "@lib/html"
import {profileSearch} from "@welshman/app"
import {displayProfileByPubkey, profileSearch} from "@welshman/app"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
import ContentSearch from "@lib/components/ContentSearch.svelte"
import PeopleItem from "@app/components/PeopleItem.svelte"
import {bootstrapPubkeys} from "@app/core/state"
import {getPubkeyRank} from "@app/util/wot/getPubkeyRank"
const FALLBACK_RANK = 50
let term = $state("")
let limit = $state(10)
let pubkeys = $state($bootstrapPubkeys)
let pubkeys = $state<string[]>([])
let element: Element | undefined = $state()
let requestId = 0
const rankByPubkey = new Map<string, number>()
const sortPubkeys = (items: string[]) => {
const indexed = items.map((pubkey, index) => ({
pubkey,
index,
rank: rankByPubkey.get(pubkey) ?? FALLBACK_RANK,
name: displayProfileByPubkey(pubkey).toLowerCase(),
}))
indexed.sort((a, b) => {
if (b.rank !== a.rank) {
return b.rank - a.rank
}
const byName = a.name.localeCompare(b.name)
if (byName !== 0) {
return byName
}
return a.index - b.index
})
return indexed.map(item => item.pubkey)
}
const rankPubkeys = async (items: string[]) => {
const currentRequestId = ++requestId
pubkeys = sortPubkeys(items)
await Promise.all(
items.map(async pubkey => {
const rank = (await getPubkeyRank(pubkey)) ?? FALLBACK_RANK
if (currentRequestId !== requestId) {
return
}
rankByPubkey.set(pubkey, rank)
pubkeys = sortPubkeys(items)
}),
)
}
const search = debounce(200, (term: string) => {
if (term) {
pubkeys = $profileSearch.searchValues(term)
} else {
pubkeys = $bootstrapPubkeys
}
const searchTerm = term.trim()
void rankPubkeys($profileSearch.searchValues(searchTerm))
})
$effect(() => search(term))