Browse Source

implement nip-66

add default-ON option to always add three random public relays, when publishing
add relay-type info to the relay selection box
run a cronjob for relay monitoring on the server
imwald
Silberengel 2 months ago
parent
commit
36c298b118
  1. 15
      Dockerfile
  2. 31
      docker-compose.prod.yml
  3. 9
      docker-entrypoint.sh
  4. 30
      docs/NIP66-MONITOR-SECURITY.md
  5. 12
      nip66-cron/Dockerfile
  6. 191
      nip66-cron/index.mjs
  7. 141
      nip66-cron/package-lock.json
  8. 14
      nip66-cron/package.json
  9. 18
      src/components/PostEditor/PostRelaySelector.tsx
  10. 6
      src/components/RelayIcon/index.tsx
  11. 132
      src/components/RelayInfo/index.tsx
  12. 28
      src/constants.ts
  13. 29
      src/i18n/locales/de.ts
  14. 29
      src/i18n/locales/en.ts
  15. 35
      src/main.tsx
  16. 23
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  17. 20
      src/pages/secondary/GeneralSettingsPage/index.tsx
  18. 17
      src/providers/UserPreferencesProvider.tsx
  19. 56
      src/services/client.service.ts
  20. 106
      src/services/indexed-db.service.ts
  21. 13
      src/services/local-storage.service.ts
  22. 107
      src/services/nip66-monitor.ts
  23. 231
      src/services/nip66.service.ts
  24. 70
      src/services/relay-info.service.ts
  25. 96
      src/services/relay-selection.service.ts
  26. 18
      src/types/index.d.ts

15
Dockerfile

@ -17,8 +17,12 @@ RUN npm run build @@ -17,8 +17,12 @@ RUN npm run build
# Step 2: Final container with Nginx and embedded config
FROM nginx:alpine
RUN apk add --no-cache jq
# Copy only the generated static files
COPY --from=builder /app/dist /usr/share/nginx/html
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Embed Nginx configuration directly
RUN printf "server {\n\
@ -34,10 +38,10 @@ RUN printf "server {\n\ @@ -34,10 +38,10 @@ RUN printf "server {\n\
}\n\
\n\
location / {\n\
# For scrapers, always serve index.html so they see static og/twitter meta tags\n\
if (\$is_scraper = 1) {\n\
rewrite ^ /index.html last;\n\
}\n\
# For scrapers, serve index.html so they see static og/twitter meta tags (skip if already requesting index.html to avoid redirect loop)\n\
set \$rewrite_scraper \$is_scraper;\n\
if (\$uri = /index.html) { set \$rewrite_scraper 0; }\n\
if (\$rewrite_scraper = 1) { rewrite ^ /index.html last; }\n\
try_files \$uri \$uri/ /index.html;\n\
}\n\
\n\
@ -56,4 +60,5 @@ RUN printf "server {\n\ @@ -56,4 +60,5 @@ RUN printf "server {\n\
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# Entrypoint writes /config.json (e.g. NIP66_MONITOR_NPUB for relay info page) then starts nginx
ENTRYPOINT ["/docker-entrypoint.sh"]

31
docker-compose.prod.yml

@ -1,5 +1,9 @@ @@ -1,5 +1,9 @@
# Minimal compose for running the published image (e.g. on remote server).
# Usage: docker compose -f docker-compose.prod.yml up -d
#
# NIP-66 monitor: set NIP66_MONITOR_NSEC (and optionally NIP66_MONITOR_NPUB) in the host env or .env.
# - Cron (jumble-nip66-monitor) uses NIP66_MONITOR_NSEC to publish 30166/10166; nsec never goes to the client.
# - Set NIP66_MONITOR_NPUB (npub1... derived from the same key) so the relay info page shows the monitor's avatar and handle in the NIP-66 liveliness section.
services:
jumble:
@ -8,7 +12,9 @@ services: @@ -8,7 +12,9 @@ services:
ports:
- "8089:80"
restart: unless-stopped
# Cap container impact: log size + optional memory limit
# Do NOT pass NIP66_MONITOR_NSEC to the web app; only npub is needed for the relay info page.
environment:
- NIP66_MONITOR_NPUB=${NIP66_MONITOR_NPUB}
logging:
driver: json-file
options:
@ -18,3 +24,26 @@ services: @@ -18,3 +24,26 @@ services:
resources:
limits:
memory: 512M
# NIP-66 relay monitor cron: publishes 30166 (relay discovery) and 10166 (announcement).
# Starts and stops with the app. Requires NIP66_MONITOR_NSEC to do anything.
jumble-nip66-monitor:
build:
context: ./nip66-cron
dockerfile: Dockerfile
container_name: imwald-jumble-nip66-monitor
restart: unless-stopped
environment:
- NIP66_MONITOR_NSEC=${NIP66_MONITOR_NSEC}
# Optional: RELAYS_TO_MONITOR=wss://relay1,wss://relay2 (default: built-in list)
# Optional: PUBLISH_RELAYS=wss://... (default: built-in list)
# Optional: INTERVAL_MS=3600000 (default: 1 hour between 30166 runs)
logging:
driver: json-file
options:
max-size: "5m"
max-file: "2"
deploy:
resources:
limits:
memory: 128M

9
docker-entrypoint.sh

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
#!/bin/sh
# Runtime config for the SPA. NIP-66 monitor runs in a separate cron container; nsec is never sent to the client.
# Optional: NIP66_MONITOR_NPUB (npub of the monitor) can be exposed so the relay info page shows who runs the monitor.
if [ -n "$NIP66_MONITOR_NPUB" ]; then
echo "$NIP66_MONITOR_NPUB" | jq -n -R '{NIP66_MONITOR_NPUB: .}' > /usr/share/nginx/html/config.json
else
echo '{}' > /usr/share/nginx/html/config.json
fi
exec nginx -g "daemon off;"

30
docs/NIP66-MONITOR-SECURITY.md

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
# NIP-66 monitor – security audit (nsec handling)
## Summary
The monitor **nsec** (`NIP66_MONITOR_NSEC`) is used only in the **nip66-cron** container. It is **never** sent to the web app container, written to config.json, or exposed to the client.
## Where the nsec may exist
| Location | Allowed? | Notes |
|----------|----------|--------|
| **Host env** (e.g. `.env`) | ✅ | Operator sets it; not in repo. |
| **jumble-nip66-monitor container env** | ✅ | Only service that needs it. |
| **jumble container env** | ❌ | Removed: nsec is not passed to the web app. |
| **config.json** (served to browser) | ❌ | Entrypoint writes only `NIP66_MONITOR_NPUB` or `{}`; never nsec. |
| **Frontend (Window.__RUNTIME_CONFIG__)** | ❌ | Type and fetch only include `NIP66_MONITOR_NPUB`. |
| **Vite / build** | ❌ | No `VITE_NIP66_*` or nsec in bundle. |
## Checks performed
1. **docker-entrypoint.sh** – Writes config.json only from `NIP66_MONITOR_NPUB`; does not read or write `NIP66_MONITOR_NSEC`.
2. **docker-compose.prod.yml**`NIP66_MONITOR_NSEC` is set only on the **jumble-nip66-monitor** service; **jumble** has only `NIP66_MONITOR_NPUB`.
3. **main.tsx** – Fetches config and types only `NIP66_MONITOR_NPUB`; no nsec in `Window.__RUNTIME_CONFIG__`.
4. **nip66-monitor.ts** (frontend) – Stub only; `getMonitorSecretKey()` always returns `null`; no env or config read for nsec.
5. **nip66-cron/index.mjs** – Reads nsec from `process.env.NIP66_MONITOR_NSEC` only; never logs it or passes it to `log()`; comment added to never log or expose it.
6. **RelayInfo / RelayLivelinessSection** – Use only `window.__RUNTIME_CONFIG__.NIP66_MONITOR_NPUB` (npub) for display.
## Recommendation
- Keep **NIP66_MONITOR_NSEC** only in the host env and in the **jumble-nip66-monitor** service.
- Do not add nsec to the jumble service env, config.json, or any client-exposed config.

12
nip66-cron/Dockerfile

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
# NIP-66 monitor cron: runs alongside the app, keeps nsec on server only.
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY index.mjs ./
ENV NODE_ENV=production
CMD ["node", "index.mjs"]

191
nip66-cron/index.mjs

@ -0,0 +1,191 @@ @@ -0,0 +1,191 @@
/**
* NIP-66 relay monitor cron. Runs on the server; nsec stays in env, never sent to client.
* - On startup: publish kind 10166 (monitor announcement) once.
* - Every INTERVAL_MS: for each relay in RELAYS_TO_MONITOR, fetch NIP-11, build & publish 30166.
*
* Env:
* NIP66_MONITOR_NSEC - required; nsec for signing 30166/10166
* RELAYS_TO_MONITOR - optional; comma-separated wss:// URLs. Default: built-in list.
* PUBLISH_RELAYS - optional; comma-separated relays to publish to. Default: built-in list.
* INTERVAL_MS - optional; ms between full monitor runs (default 3600000 = 1h)
*/
import { finalizeEvent, nip19 } from 'nostr-tools'
import WebSocket from 'ws'
const RELAY_DISCOVERY_KIND = 30166
const RELAY_MONITOR_ANNOUNCEMENT_KIND = 10166
const DEFAULT_RELAYS_TO_MONITOR = [
'wss://theforest.nostr1.com',
'wss://orly-relay.imwald.eu',
'wss://nostr.land',
'wss://thecitadel.nostr1.com',
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol'
]
const DEFAULT_PUBLISH_RELAYS = [
'wss://theforest.nostr1.com',
'wss://thecitadel.nostr1.com',
'wss://relay.damus.io',
'wss://relay.nostr.watch'
]
const INTERVAL_MS = Number(process.env.INTERVAL_MS) || 3600000 // 1 hour
function log (msg, data = {}) {
const ts = new Date().toISOString()
console.log(ts, '[nip66-cron]', msg, Object.keys(data).length ? JSON.stringify(data) : '')
}
function normalizeRelayUrl (url) {
try {
const u = url.replace(/^ws:\/\//, 'wss://')
const p = new URL(u.startsWith('wss://') ? u : `wss://${u}`)
p.pathname = p.pathname.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
return p.toString()
} catch {
return url
}
}
/** Returns decoded secret key from env. Never log or expose process.env.NIP66_MONITOR_NSEC. */
function getSecretKey () {
const raw = process.env.NIP66_MONITOR_NSEC
if (!raw || typeof raw !== 'string') return null
try {
const { type, data } = nip19.decode(raw)
if (type !== 'nsec') return null
return data
} catch {
return null
}
}
async function fetchNip11 (relayUrl) {
const httpUrl = relayUrl.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://')
try {
const res = await fetch(httpUrl, { headers: { Accept: 'application/nostr+json' } })
if (!res.ok) return null
return await res.json()
} catch (err) {
log('NIP-11 fetch failed', { url: relayUrl, err: err.message })
return null
}
}
function build30166 (relayUrl, nip11, sk) {
const d = normalizeRelayUrl(relayUrl)
const tags = [['d', d]]
const nips = nip11?.supported_nips
if (Array.isArray(nips)) {
for (const n of nips) tags.push(['N', String(n)])
}
const lim = nip11?.limitation
tags.push(['R', lim?.auth_required ? 'auth' : '!auth'])
tags.push(['R', lim?.payment_required ? 'payment' : '!payment'])
const draft = {
kind: RELAY_DISCOVERY_KIND,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags
}
return finalizeEvent(draft, sk)
}
function build10166 (sk) {
const draft = {
kind: RELAY_MONITOR_ANNOUNCEMENT_KIND,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [['frequency', '3600'], ['c', 'nip11'], ['c', 'ws']]
}
return finalizeEvent(draft, sk)
}
function parseListEnv (envVar, defaultList) {
const raw = process.env[envVar]
if (!raw || typeof raw !== 'string') return defaultList
return raw.split(',').map(s => s.trim()).filter(Boolean)
}
async function publishEvent (relayUrls, event) {
const msg = JSON.stringify(['EVENT', event])
let ok = 0
const conns = []
for (const url of relayUrls) {
try {
const ws = new WebSocket(url, { handshakeTimeout: 8000 })
await new Promise((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
setTimeout(() => reject(new Error('open timeout')), 10000)
})
conns.push(ws)
ws.send(msg)
await new Promise((resolve) => {
const onResp = (data) => {
try {
const j = JSON.parse(data.toString())
if (j[0] === 'OK' && j[1] === event.id) {
ok++
if (j[2] === true) { /* accepted */ } else { log('Relay rejected event', { url, reason: j[2] }) }
}
} finally {
resolve()
}
}
ws.once('message', onResp)
setTimeout(resolve, 3000)
})
} catch (err) {
log('Publish relay error', { url, err: err.message })
}
}
for (const ws of conns) {
try { ws.close() } catch (_) {}
}
return ok
}
async function run10166 (sk, publishRelays) {
const event = build10166(sk)
log('Publishing 10166 (monitor announcement)')
const count = await publishEvent(publishRelays, event)
log('Published 10166', { successCount: count })
}
async function run30166Round (sk, relaysToMonitor, publishRelays) {
for (const relayUrl of relaysToMonitor) {
const nip11 = await fetchNip11(relayUrl)
if (!nip11) continue
const event = build30166(relayUrl, nip11, sk)
const count = await publishEvent(publishRelays, event)
log('Published 30166', { url: relayUrl, successCount: count })
}
}
async function main () {
const sk = getSecretKey()
if (!sk) {
log('No NIP66_MONITOR_NSEC set; exiting')
process.exit(0)
}
log('NIP-66 monitor cron started (nsec configured)')
const relaysToMonitor = parseListEnv('RELAYS_TO_MONITOR', DEFAULT_RELAYS_TO_MONITOR)
const publishRelays = parseListEnv('PUBLISH_RELAYS', DEFAULT_PUBLISH_RELAYS)
await run10166(sk, publishRelays)
const run = () => run30166Round(sk, relaysToMonitor, publishRelays)
await run()
setInterval(run, INTERVAL_MS)
}
main().catch((err) => {
console.error('[nip66-cron]', err)
process.exit(1)
})

141
nip66-cron/package-lock.json generated

@ -0,0 +1,141 @@ @@ -0,0 +1,141 @@
{
"name": "jumble-nip66-cron",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jumble-nip66-cron",
"version": "1.0.0",
"dependencies": {
"nostr-tools": "^2.17.0",
"ws": "^8.18.0"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools": {
"version": "2.23.3",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.3.tgz",
"integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
"@scure/bip32": "2.0.1",
"@scure/bip39": "2.0.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

14
nip66-cron/package.json

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
{
"name": "jumble-nip66-cron",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "NIP-66 relay monitor cron: publishes 30166/10166 from server, nsec never exposed to client",
"scripts": {
"start": "node index.mjs"
},
"dependencies": {
"nostr-tools": "^2.17.0",
"ws": "^8.18.0"
}
}

18
src/components/PostEditor/PostRelaySelector.tsx

@ -11,7 +11,7 @@ import { NostrEvent } from 'nostr-tools' @@ -11,7 +11,7 @@ import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import relaySelectionService from '@/services/relay-selection.service'
import relaySelectionService, { type RelaySourceType } from '@/services/relay-selection.service'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
@ -42,6 +42,7 @@ export default function PostRelaySelector({ @@ -42,6 +42,7 @@ export default function PostRelaySelector({
const { pubkey, relayList } = useNostr()
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
const [selectableRelays, setSelectableRelays] = useState<string[]>([])
const [relayTypes, setRelayTypes] = useState<Record<string, RelaySourceType>>({})
const [description, setDescription] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [hasManualSelection, setHasManualSelection] = useState(false)
@ -133,6 +134,7 @@ export default function PostRelaySelector({ @@ -133,6 +134,7 @@ export default function PostRelaySelector({
const selectableRelaysChanged = newSelectableCount !== previousSelectableCount
setSelectableRelays(result.selectableRelays)
setRelayTypes(result.relayTypes ?? {})
setPreviousSelectableCount(newSelectableCount)
// Only update selected relays if:
@ -225,6 +227,7 @@ export default function PostRelaySelector({ @@ -225,6 +227,7 @@ export default function PostRelaySelector({
const selectableRelaysChanged = newSelectableCount !== previousSelectableCount
setSelectableRelays(result.selectableRelays)
setRelayTypes(result.relayTypes ?? {})
setPreviousSelectableCount(newSelectableCount)
// Only update selected relays if:
@ -326,17 +329,24 @@ export default function PostRelaySelector({ @@ -326,17 +329,24 @@ export default function PostRelaySelector({
return sortedRelays.map((url) => {
const isChecked = selectedRelayUrls.includes(url)
const sourceType = relayTypes[url]
const typeLabel = sourceType ? t(`relayType_${sourceType}`) : ''
return (
<div
key={url}
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer touch-manipulation"
onClick={() => handleRelayCheckedChange(!isChecked, url)}
>
<div className="flex items-center justify-center w-4 h-4 border border-border rounded">
<div className="flex items-center justify-center w-4 h-4 border border-border rounded shrink-0">
{isChecked && <Check className="w-3 h-3" />}
</div>
<RelayIcon url={url} className="w-4 h-4" />
<span className="text-sm flex-1 truncate">{simplifyUrl(url)}</span>
<RelayIcon url={url} className="w-4 h-4 shrink-0" />
<span className="text-sm flex-1 truncate min-w-0">{simplifyUrl(url)}</span>
{typeLabel && (
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{typeLabel}
</span>
)}
</div>
)
})

6
src/components/RelayIcon/index.tsx

@ -15,8 +15,10 @@ export default function RelayIcon({ @@ -15,8 +15,10 @@ export default function RelayIcon({
}) {
const { relayInfo } = useFetchRelayInfo(url)
const iconUrl = useMemo(() => {
if (relayInfo?.icon && typeof relayInfo.icon === 'string' && relayInfo.icon.startsWith('http')) {
return relayInfo.icon
const raw = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined
// Only use HTTP(S) URLs for images; reject ws(s):// (e.g. some relays return relay URL as icon)
if (raw && (raw.startsWith('https://') || raw.startsWith('http://'))) {
return raw
}
if (!url) return undefined
try {

132
src/components/RelayInfo/index.tsx

@ -3,10 +3,12 @@ import { Button } from '@/components/ui/button' @@ -3,10 +3,12 @@ import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { useFetchRelayInfo } from '@/hooks'
import { normalizeHttpUrl } from '@/lib/url'
import client from '@/services/client.service'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, GitBranch, Link, Mail, SquareCode } from 'lucide-react'
import { useState } from 'react'
import { nip66Service } from '@/services/nip66.service'
import { Check, Copy, GitBranch, Link, Mail, SquareCode, Activity } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import PostEditor from '../PostEditor'
@ -15,12 +17,31 @@ import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu' @@ -15,12 +17,31 @@ import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import RelayReviewsPreview from './RelayReviewsPreview'
import type { TNip66RelayDiscovery } from '@/types'
export default function RelayInfo({ url, className }: { url: string; className?: string }) {
const { t } = useTranslation()
const { checkLogin } = useNostr()
const { relayInfo, isFetching } = useFetchRelayInfo(url)
const [open, setOpen] = useState(false)
const [discovery, setDiscovery] = useState<TNip66RelayDiscovery | undefined>(() => nip66Service.getDiscovery(url))
useEffect(() => {
setDiscovery(nip66Service.getDiscovery(url))
let cancelled = false
nip66Service.getDiscoveryCached(url).then((cached) => {
if (!cancelled && cached) setDiscovery(cached)
})
nip66Service.isDiscoveryStaleForRelay(url).then((stale) => {
if (cancelled) return
if (stale) {
client.fetchNip66DiscoveryForRelay(url).then(() => {
if (!cancelled) setDiscovery(nip66Service.getDiscovery(url))
})
}
})
return () => { cancelled = true }
}, [url])
if (isFetching || !relayInfo) {
return null
@ -102,9 +123,21 @@ export default function RelayInfo({ url, className }: { url: string; className?: @@ -102,9 +123,21 @@ export default function RelayInfo({ url, className }: { url: string; className?:
</div>
</div>
)}
{typeof window !== 'undefined' && window.__RUNTIME_CONFIG__?.NIP66_MONITOR_NPUB && (
<div className="space-y-2 w-fit">
<div className="text-sm font-semibold text-muted-foreground">{t('Relay monitor (NIP-66)')}</div>
<div className="flex gap-2 items-center">
<UserAvatar userId={window.__RUNTIME_CONFIG__.NIP66_MONITOR_NPUB} size="small" />
<Username userId={window.__RUNTIME_CONFIG__.NIP66_MONITOR_NPUB} className="font-semibold text-nowrap" />
</div>
</div>
)}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
{discovery && (
<RelayLivelinessSection discovery={discovery} />
)}
<Button
variant="secondary"
className="w-full"
@ -124,6 +157,101 @@ function formatSoftware(software: string) { @@ -124,6 +157,101 @@ function formatSoftware(software: string) {
return parts[parts.length - 1]
}
function RelayLivelinessSection({ discovery }: { discovery: TNip66RelayDiscovery }) {
const { t } = useTranslation()
const req = discovery.requirements
const hasRtt =
discovery.rttOpenMs != null || discovery.rttReadMs != null || discovery.rttWriteMs != null
const hasMeta = !!(discovery.networkType ?? discovery.relayType ?? (discovery.topics?.length ?? 0) > 0)
const lastReported = useMemo(
() => (discovery.created_at ? new Date(discovery.created_at * 1000).toLocaleString() : null),
[discovery.created_at]
)
return (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
<Activity className="h-4 w-4" />
{t('Relay liveliness (NIP-66)')}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={req.auth === true ? 'secondary' : 'default'}>
{req.auth === true ? t('Auth required') : t('Public (no auth)')}
</Badge>
<Badge variant={req.payment === true ? 'secondary' : 'default'}>
{req.payment === true ? t('Payment required') : t('No payment')}
</Badge>
{req.writes !== undefined && (
<Badge variant="outline">
{req.writes ? t('Writes required') : t('Writes open')}
</Badge>
)}
{req.pow !== undefined && (
<Badge variant="outline">
{req.pow ? t('PoW required') : t('No PoW')}
</Badge>
)}
</div>
{hasRtt && (
<div className="flex flex-wrap gap-4 text-sm">
{discovery.rttOpenMs != null && (
<span>
{t('RTT open')}: {discovery.rttOpenMs} ms
</span>
)}
{discovery.rttReadMs != null && (
<span>
{t('RTT read')}: {discovery.rttReadMs} ms
</span>
)}
{discovery.rttWriteMs != null && (
<span>
{t('RTT write')}: {discovery.rttWriteMs} ms
</span>
)}
</div>
)}
{discovery.supportedNips?.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">{t('Supported NIPs (from monitor)')}</div>
<div className="flex flex-wrap gap-1">
{discovery.supportedNips.slice().sort((a, b) => a - b).map((nip) => (
<Badge key={nip} variant="outline" className="text-xs">
NIP-{nip}
</Badge>
))}
</div>
</div>
)}
{hasMeta && (
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
{discovery.networkType && <span>{t('Network')}: {discovery.networkType}</span>}
{discovery.relayType && <span>{t('Type')}: {discovery.relayType}</span>}
{discovery.topics?.length ? (
<span>{t('Topics')}: {discovery.topics.join(', ')}</span>
) : null}
</div>
)}
{lastReported && (
<div className="text-xs text-muted-foreground">
{t('Last reported by monitor')}: {lastReported}
</div>
)}
{(() => {
const monitorUserId = discovery.monitorPubkey ?? (typeof window !== 'undefined' ? window.__RUNTIME_CONFIG__?.NIP66_MONITOR_NPUB : undefined)
return monitorUserId ? (
<div className="space-y-1 pt-1 border-t border-border/50">
<div className="text-xs font-medium text-muted-foreground">{t('Relay monitor (NIP-66)')}</div>
<div className="flex gap-2 items-center">
<UserAvatar userId={monitorUserId} size="small" />
<Username userId={monitorUserId} className="font-semibold text-nowrap" />
</div>
</div>
) : null
})()}
</div>
)
}
function RelayControls({ url }: { url: string }) {
const [copiedUrl, setCopiedUrl] = useState(false)
const [copiedShareableUrl, setCopiedShareableUrl] = useState(false)

28
src/constants.ts

@ -60,6 +60,7 @@ export const StorageKey = { @@ -60,6 +60,7 @@ export const StorageKey = {
GLOBAL_QUIET_MODE: 'globalQuietMode',
SHOW_RSS_FEED: 'showRssFeed',
PANE_MODE: 'paneMode',
ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
@ -88,6 +89,27 @@ export const BIG_RELAY_URLS = [ @@ -88,6 +89,27 @@ export const BIG_RELAY_URLS = [
'wss://thecitadel.nostr1.com',
]
/** Relays to query for NIP-66 relay monitoring events (30166), in addition to BIG_RELAY_URLS. */
export const NIP66_DISCOVERY_RELAY_URLS = [
'wss://thecitadel.nostr1.com',
'wss://relay.nostr.watch',
'wss://relaypag.es'
]
/**
* Known public (no auth, open write) relays for censorship-resilience: when the user opts in,
* we add 3 random relays from this list to every publish. Curated list of lively public relays.
*/
export const PUBLIC_LIVELY_RELAY_URLS = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol',
'wss://thecitadel.nostr1.com',
'wss://relay.lumina.rocks',
'wss://nostr.mom',
'wss://freelay.sovbit.host'
]
// Relay with bookstr composite index support
export const BOOKSTR_RELAY_URLS = [
'wss://orly-relay.imwald.eu'
@ -181,7 +203,11 @@ export const ExtendedKind = { @@ -181,7 +203,11 @@ export const ExtendedKind = {
PAYMENT_INFO: 10133,
FOLLOW_PACK: 39089,
/** NIP-94 File Metadata (e.g. GIFs) */
FILE_METADATA: 1063
FILE_METADATA: 1063,
/** NIP-66 Relay discovery (relay characteristics from NIP-11 or probing) */
RELAY_DISCOVERY: 30166,
/** NIP-66 Relay monitor announcement (intent to publish 30166 at a frequency) */
RELAY_MONITOR_ANNOUNCEMENT: 10166
}
export const SUPPORTED_KINDS = [

29
src/i18n/locales/de.ts

@ -188,6 +188,24 @@ export default { @@ -188,6 +188,24 @@ export default {
'Explore more': 'Mehr entdecken',
'Payment page': 'Zahlungsseite',
'Supported NIPs': 'Unterstützte NIPs',
'Relay liveliness (NIP-66)': 'Relay-Liveness (NIP-66)',
'Relay monitor (NIP-66)': 'Relay-Monitor (NIP-66)',
'Auth required': 'Auth erforderlich',
'Public (no auth)': 'Öffentlich (keine Auth)',
'Payment required': 'Zahlung erforderlich',
'No payment': 'Keine Zahlung',
'Writes required': 'Schreiben erforderlich',
'Writes open': 'Schreiben offen',
'PoW required': 'PoW erforderlich',
'No PoW': 'Kein PoW',
'RTT open': 'RTT open',
'RTT read': 'RTT read',
'RTT write': 'RTT write',
'Supported NIPs (from monitor)': 'Unterstützte NIPs (vom Monitor)',
'Last reported by monitor': 'Zuletzt vom Monitor gemeldet',
'Network': 'Netzwerk',
'Type': 'Typ',
'Topics': 'Themen',
'Open in a': 'Öffnen in {{a}}',
'Cannot handle event of kind k': 'Ereignis des Typs {{k}} kann nicht verarbeitet werden',
'Sorry! The note cannot be found 😔': 'Entschuldigung! Die Notiz wurde nicht gefunden 😔',
@ -242,6 +260,17 @@ export default { @@ -242,6 +260,17 @@ export default {
Autoplay: 'Automatische Wiedergabe',
'Enable video autoplay on this device':
'Aktiviere die automatische Video-Wiedergabe auf diesem Gerät',
'Add 3 random relays to every publish': 'Relay-Liste mit 3 zufälligen öffentlichen Relays vorfüllen',
'Add 3 random relays to every publish description':
'Zeigt 3 zufällige öffentliche Relays in der Publish-Relay-Liste (nicht angehakt), damit du sie optional für mehr Zensur-Resilienz auswählen kannst.',
'relayType_local': 'Lokal',
'relayType_relay_list': 'Relay-Liste',
'relayType_client_default': 'Client-Standard',
'relayType_open_from': 'Aktueller Feed',
'relayType_favorite': 'Favorit',
'relayType_relay_set': 'Relay-Set',
'relayType_contextual': 'Antwort/PN',
'relayType_randomly_selected': 'Zufällig (optional)',
'Paste or drop media files to upload':
'Füge Medien-Dateien ein oder ziehe sie hierher, um sie hochzuladen',
Preview: 'Vorschau',

29
src/i18n/locales/en.ts

@ -192,6 +192,24 @@ export default { @@ -192,6 +192,24 @@ export default {
'Explore more': 'Explore more',
'Payment page': 'Payment page',
'Supported NIPs': 'Supported NIPs',
'Relay liveliness (NIP-66)': 'Relay liveliness (NIP-66)',
'Relay monitor (NIP-66)': 'Relay monitor (NIP-66)',
'Auth required': 'Auth required',
'Public (no auth)': 'Public (no auth)',
'Payment required': 'Payment required',
'No payment': 'No payment',
'Writes required': 'Writes required',
'Writes open': 'Writes open',
'PoW required': 'PoW required',
'No PoW': 'No PoW',
'RTT open': 'RTT open',
'RTT read': 'RTT read',
'RTT write': 'RTT write',
'Supported NIPs (from monitor)': 'Supported NIPs (from monitor)',
'Last reported by monitor': 'Last reported by monitor',
'Network': 'Network',
'Type': 'Type',
'Topics': 'Topics',
'Open in a': 'Open in {{a}}',
'Cannot handle event of kind k': 'Cannot handle event of kind {{k}}',
'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔',
@ -253,6 +271,17 @@ export default { @@ -253,6 +271,17 @@ export default {
General: 'General',
Autoplay: 'Autoplay',
'Enable video autoplay on this device': 'Enable video autoplay on this device',
'Add 3 random relays to every publish': 'Preload relay list with 3 random public relays',
'Add 3 random relays to every publish description':
'Show 3 random public relays in the publish relay list (unchecked) so you can optionally include them for censorship resilience.',
'relayType_local': 'Local',
'relayType_relay_list': 'Relay list',
'relayType_client_default': 'Client default',
'relayType_open_from': 'Current feed',
'relayType_favorite': 'Favorite',
'relayType_relay_set': 'Relay set',
'relayType_contextual': 'Reply/PM',
'relayType_randomly_selected': 'Random (optional)',
'Paste or drop media files to upload': 'Paste or drop media files to upload',
Preview: 'Preview',
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?':

35
src/main.tsx

@ -9,6 +9,13 @@ import { StrictMode } from 'react' @@ -9,6 +9,13 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import { ErrorBoundary } from './components/ErrorBoundary.tsx'
import { publishMonitorAnnouncementOnce } from './services/nip66-monitor'
declare global {
interface Window {
__RUNTIME_CONFIG__?: { NIP66_MONITOR_NPUB?: string }
}
}
const setVh = () => {
document.documentElement.style.setProperty('--vh', `${window.innerHeight}px`)
@ -17,10 +24,24 @@ window.addEventListener('resize', setVh) @@ -17,10 +24,24 @@ window.addEventListener('resize', setVh)
window.addEventListener('orientationchange', setVh)
setVh()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>
)
async function bootstrap() {
try {
const r = await fetch('/config.json')
if (r.ok) {
const config = (await r.json()) as { NIP66_MONITOR_NPUB?: string }
window.__RUNTIME_CONFIG__ = config
}
} catch {
window.__RUNTIME_CONFIG__ = {}
}
publishMonitorAnnouncementOnce()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>
)
}
bootstrap()

23
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -8,7 +8,6 @@ import { Switch } from '@/components/ui/switch' @@ -8,7 +8,6 @@ import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Checkbox } from '@/components/ui/checkbox'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Hash, X, Users, Code, Coins, Newspaper, BookOpen, Scroll, Cpu, Trophy, Film, Heart, TrendingUp, Utensils, MapPin, Home, PawPrint, Shirt, Image, Zap, Settings, Book, Network, Car, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react'
import { useState, useEffect, useMemo, useRef } from 'react'
@ -21,7 +20,7 @@ import { NostrEvent } from 'nostr-tools' @@ -21,7 +20,7 @@ import { NostrEvent } from 'nostr-tools'
import { prefixNostrAddresses } from '@/lib/nostr-address'
import { showPublishingError, showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { simplifyUrl } from '@/lib/url'
import relaySelectionService from '@/services/relay-selection.service'
import relaySelectionService, { type RelaySourceType } from '@/services/relay-selection.service'
import dayjs from 'dayjs'
import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics'
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
@ -118,6 +117,7 @@ export default function CreateThreadDialog({ @@ -118,6 +117,7 @@ export default function CreateThreadDialog({
const [selectedTopic, setSelectedTopic] = useState(initialTopic)
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
const [selectableRelays, setSelectableRelays] = useState<string[]>([])
const [relayTypes, setRelayTypes] = useState<Record<string, RelaySourceType>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<{ title?: string; content?: string; relay?: string; author?: string; subject?: string; group?: string }>({})
const [isNsfw, setIsNsfw] = useState(false)
@ -256,11 +256,13 @@ export default function CreateThreadDialog({ @@ -256,11 +256,13 @@ export default function CreateThreadDialog({
setSelectableRelays(result.selectableRelays)
setSelectedRelayUrls(result.selectedRelays)
setRelayTypes(result.relayTypes ?? {})
} catch (error) {
logger.error('[CreateThreadDialog] Failed to initialize relays:', error)
// Fallback to availableRelays
setSelectableRelays(availableRelays)
setSelectedRelayUrls(availableRelays)
setRelayTypes({})
} finally {
setIsLoadingRelays(false)
}
@ -896,7 +898,9 @@ export default function CreateThreadDialog({ @@ -896,7 +898,9 @@ export default function CreateThreadDialog({
{/* Relay Selection */}
<div className="space-y-2">
<Label>{t('Publish to Relays')}</Label>
<ScrollArea className={`max-h-64 rounded-md border p-4 ${errors.relay ? 'border-destructive' : ''}`}>
<div
className={`max-h-64 min-h-0 overflow-y-scroll overflow-x-hidden rounded-md border p-4 ${errors.relay ? 'border-destructive' : ''}`}
>
{isLoadingRelays ? (
<div className="text-sm text-muted-foreground text-center py-4">
{t('Loading relays...')}
@ -909,6 +913,8 @@ export default function CreateThreadDialog({ @@ -909,6 +913,8 @@ export default function CreateThreadDialog({
<div className="space-y-3">
{selectableRelays.map(relay => {
const isChecked = selectedRelayUrls.includes(relay)
const sourceType = relayTypes[relay]
const typeLabel = sourceType ? t(`relayType_${sourceType}`) : ''
return (
<div key={relay} className="flex items-center space-x-3">
<Checkbox
@ -919,17 +925,22 @@ export default function CreateThreadDialog({ @@ -919,17 +925,22 @@ export default function CreateThreadDialog({
/>
<label
htmlFor={`relay-${relay}`}
className="flex items-center gap-2 text-sm font-medium leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex-1"
className="flex items-center gap-2 text-sm font-medium leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex-1 min-w-0"
>
<RelayIcon url={relay} className="w-4 h-4" />
<RelayIcon url={relay} className="w-4 h-4 shrink-0" />
<span className="truncate">{simplifyUrl(relay)}</span>
{typeLabel && (
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{typeLabel}
</span>
)}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
</div>
{errors.relay && (
<p className="text-sm text-destructive">{errors.relay}</p>
)}

20
src/pages/secondary/GeneralSettingsPage/index.tsx

@ -32,7 +32,12 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -32,7 +32,12 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
setMediaAutoLoadPolicy
} = useContentPolicy()
const { hideUntrustedNotes, updateHideUntrustedNotes } = useUserTrust()
const { notificationListStyle, updateNotificationListStyle } = useUserPreferences()
const {
notificationListStyle,
updateNotificationListStyle,
addRandomRelaysToPublish,
updateAddRandomRelaysToPublish
} = useUserPreferences()
const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value)
@ -142,6 +147,19 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -142,6 +147,19 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
</Label>
<Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
</SettingItem>
<SettingItem>
<Label htmlFor="add-random-relays" className="text-base font-normal">
<div>{t('Add 3 random relays to every publish')}</div>
<div className="text-muted-foreground">
{t('Add 3 random relays to every publish description')}
</div>
</Label>
<Switch
id="add-random-relays"
checked={addRandomRelaysToPublish}
onCheckedChange={updateAddRandomRelaysToPublish}
/>
</SettingItem>
<SettingItem>
<Label htmlFor="hide-untrusted-notes" className="text-base font-normal">
{t('Hide untrusted notes')}

17
src/providers/UserPreferencesProvider.tsx

@ -7,6 +7,8 @@ type TUserPreferencesContext = { @@ -7,6 +7,8 @@ type TUserPreferencesContext = {
updateNotificationListStyle: (style: TNotificationStyle) => void
showRecommendedRelaysPanel: boolean
updateShowRecommendedRelaysPanel: (show: boolean) => void
addRandomRelaysToPublish: boolean
updateAddRandomRelaysToPublish: (value: boolean) => void
}
const UserPreferencesContext = createContext<TUserPreferencesContext | undefined>(undefined)
@ -26,7 +28,11 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod @@ -26,7 +28,11 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
// DEPRECATED: Double-panel functionality removed for technical debt reduction
// Keeping for backward compatibility in case we miss any references
const [showRecommendedRelaysPanel] = useState(false)
const [addRandomRelaysToPublish, setAddRandomRelaysToPublish] = useState(
storage.getAddRandomRelaysToPublish()
)
// DEPRECATED: Mobile panel forcing removed - double-panel functionality disabled
const updateNotificationListStyle = (style: TNotificationStyle) => {
@ -39,13 +45,20 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod @@ -39,13 +45,20 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
// No-op: double-panel functionality has been removed
}
const updateAddRandomRelaysToPublish = (value: boolean) => {
setAddRandomRelaysToPublish(value)
storage.setAddRandomRelaysToPublish(value)
}
return (
<UserPreferencesContext.Provider
value={{
notificationListStyle,
updateNotificationListStyle,
showRecommendedRelaysPanel,
updateShowRecommendedRelaysPanel
updateShowRecommendedRelaysPanel,
addRandomRelaysToPublish,
updateAddRandomRelaysToPublish
}}
>
{children}

56
src/services/client.service.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { BIG_RELAY_URLS, BOOKSTR_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { BIG_RELAY_URLS, BOOKSTR_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, NIP66_DISCOVERY_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
@ -16,7 +16,7 @@ import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata @@ -16,7 +16,7 @@ import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata
import logger from '@/lib/logger'
import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url'
import { isSafari } from '@/lib/utils'
import { ISigner, TProfile, TPublishOptions, TRelayList, TMailboxRelay, TSubRequestFilter } from '@/types'
import { sha256 } from '@noble/hashes/sha2'
@ -37,6 +37,7 @@ import { @@ -37,6 +37,7 @@ import {
} from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb, { StoreNames } from './indexed-db.service'
import nip66Service from './nip66.service'
type TTimelineRef = [string, number]
@ -93,6 +94,50 @@ class ClientService extends EventTarget { @@ -93,6 +94,50 @@ class ClientService extends EventTarget {
async init() {
await indexedDb.iterateProfileEvents((profileEvent) => this.addUsernameToIndex(profileEvent))
this.fetchNip66RelayDiscovery().catch(() => {})
}
/** NIP-66: fetch relay discovery events (30166) in background to supplement search/NIP support. */
private async fetchNip66RelayDiscovery(): Promise<void> {
try {
const discoveryRelays = Array.from(new Set([...BIG_RELAY_URLS, ...NIP66_DISCOVERY_RELAY_URLS]))
const events = await this.query(
discoveryRelays,
{ kinds: [ExtendedKind.RELAY_DISCOVERY] },
undefined,
{ eoseTimeout: 4000, globalTimeout: 8000 }
)
if (events.length > 0) {
nip66Service.loadFromEvents(events)
logger.info('NIP-66: loaded relay discovery events', { count: events.length })
}
} catch (err) {
logger.info('NIP-66: failed to fetch relay discovery', { err })
}
}
/**
* NIP-66: fetch 30166 events for a single relay (relay info page). Uses discovery relay set,
* filter by #d so we get the newest report for this relay and can show monitor (author) info.
*/
async fetchNip66DiscoveryForRelay(relayUrl: string): Promise<void> {
const discoveryRelays = Array.from(new Set([...BIG_RELAY_URLS, ...NIP66_DISCOVERY_RELAY_URLS]))
const dTag = normalizeUrl(relayUrl) || relayUrl
const shortForm = simplifyUrl(dTag)
const dValues = dTag !== shortForm ? [dTag, shortForm] : [dTag]
try {
const events = await this.query(
discoveryRelays,
{ kinds: [ExtendedKind.RELAY_DISCOVERY], '#d': dValues, limit: 20 },
undefined,
{ eoseTimeout: 4000, globalTimeout: 6000 }
)
if (events.length > 0) {
nip66Service.loadFromEvents(events)
}
} catch {
// ignore per-relay fetch failure
}
}
/**
@ -703,9 +748,12 @@ class ClientService extends EventTarget { @@ -703,9 +748,12 @@ class ClientService extends EventTarget {
if (!grouped.has(key)) grouped.set(key, [])
grouped.get(key)!.push(...filters)
}
const searchableSet = new Set(SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const searchableSet = new Set([
...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u)
])
const groupedRequests = Array.from(grouped.entries()).map(([url, f]) => {
const relaySupportsSearch = searchableSet.has(url)
const relaySupportsSearch = searchableSet.has(url) || nip66Service.isRelaySearchable(url)
const filtersForRelay = f.map((one) => filterForRelay(one, relaySupportsSearch))
return { url, filters: filtersForRelay }
})

106
src/services/indexed-db.service.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import { TRelayInfo } from '@/types'
import { TNip66RelayDiscovery, TRelayInfo } from '@/types'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
import { isReplaceableEvent } from '@/lib/event'
@ -34,11 +34,15 @@ export const StoreNames = { @@ -34,11 +34,15 @@ export const StoreNames = {
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos',
RELAY_INFO_EVENTS: 'relayInfoEvents', // deprecated
PUBLICATION_EVENTS: 'publicationEvents'
PUBLICATION_EVENTS: 'publicationEvents',
/** NIP-66: cached list of public lively relay URLs (from 30166 discovery). */
PUBLIC_LIVELY_RELAYS: 'publicLivelyRelays',
/** NIP-66: per-relay discovery cache (key = relay URL, value = { discovery, cachedAt }). */
NIP66_DISCOVERY: 'nip66Discovery'
}
/** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 17
const DB_VERSION = 19
/** Convert IDB request.onerror Event to a proper Error for logging and UI */
function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]): Error {
@ -175,6 +179,12 @@ class IndexedDbService { @@ -175,6 +179,12 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.PUBLIC_LIVELY_RELAYS)) {
db.createObjectStore(StoreNames.PUBLIC_LIVELY_RELAYS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.NIP66_DISCOVERY)) {
db.createObjectStore(StoreNames.NIP66_DISCOVERY, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.CACHE_RELAYS_EVENTS)) {
db.createObjectStore(StoreNames.CACHE_RELAYS_EVENTS, { keyPath: 'key' })
}
@ -608,6 +618,96 @@ class IndexedDbService { @@ -608,6 +618,96 @@ class IndexedDbService {
})
}
/** NIP-66: cache key for the single public lively relay list entry. */
private static PUBLIC_LIVELY_CACHE_KEY = 'list'
async getPublicLivelyRelayUrlsCache(): Promise<{ urls: string[]; cachedAt: number } | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(null)
}
const transaction = this.db.transaction(StoreNames.PUBLIC_LIVELY_RELAYS, 'readonly')
const store = transaction.objectStore(StoreNames.PUBLIC_LIVELY_RELAYS)
const request = store.get(IndexedDbService.PUBLIC_LIVELY_CACHE_KEY)
request.onsuccess = () => {
transaction.commit()
const row = request.result as TValue<{ urls: string[]; cachedAt: number }> | undefined
resolve(row?.value ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async setPublicLivelyRelayUrlsCache(urls: string[]): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve()
}
const transaction = this.db.transaction(StoreNames.PUBLIC_LIVELY_RELAYS, 'readwrite')
const store = transaction.objectStore(StoreNames.PUBLIC_LIVELY_RELAYS)
const value = this.formatValue(IndexedDbService.PUBLIC_LIVELY_CACHE_KEY, {
urls,
cachedAt: Date.now()
})
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getNip66Discovery(relayUrl: string): Promise<{ discovery: TNip66RelayDiscovery; cachedAt: number } | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(null)
}
const transaction = this.db.transaction(StoreNames.NIP66_DISCOVERY, 'readonly')
const store = transaction.objectStore(StoreNames.NIP66_DISCOVERY)
const request = store.get(relayUrl)
request.onsuccess = () => {
transaction.commit()
const row = request.result as TValue<{ discovery: TNip66RelayDiscovery; cachedAt: number }> | undefined
resolve(row?.value ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async setNip66Discovery(relayUrl: string, discovery: TNip66RelayDiscovery): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve()
}
const transaction = this.db.transaction(StoreNames.NIP66_DISCOVERY, 'readwrite')
const store = transaction.objectStore(StoreNames.NIP66_DISCOVERY)
const value = this.formatValue(relayUrl, { discovery, cachedAt: Date.now() })
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
private getReplaceableEventKeyFromEvent(event: Event): string {
// Events that are replaceable by pubkey only (no d-tag)
// RSS_FEED_LIST (10895) is in the 10000-20000 range, so it's automatically handled

13
src/services/local-storage.service.ts

@ -64,6 +64,7 @@ class LocalStorageService { @@ -64,6 +64,7 @@ class LocalStorageService {
private globalQuietMode: boolean = false
private showRssFeed: boolean = true
private panelMode: 'single' | 'double' = 'single'
private addRandomRelaysToPublish: boolean = true
constructor() {
if (!LocalStorageService.instance) {
@ -341,6 +342,9 @@ class LocalStorageService { @@ -341,6 +342,9 @@ class LocalStorageService {
const panelModeStr = window.localStorage.getItem(StorageKey.PANE_MODE)
this.panelMode = panelModeStr === 'double' ? 'double' : 'single' // Default to 'single'
const addRandomRelaysStr = window.localStorage.getItem(StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH)
this.addRandomRelaysToPublish = addRandomRelaysStr === null ? true : addRandomRelaysStr === 'true'
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@ -601,6 +605,15 @@ class LocalStorageService { @@ -601,6 +605,15 @@ class LocalStorageService {
window.localStorage.setItem(StorageKey.SHOW_RECOMMENDED_RELAYS_PANEL, show.toString())
}
getAddRandomRelaysToPublish(): boolean {
return this.addRandomRelaysToPublish
}
setAddRandomRelaysToPublish(value: boolean) {
this.addRandomRelaysToPublish = value
window.localStorage.setItem(StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH, value.toString())
}
getShowKinds() {
return this.showKinds
}

107
src/services/nip66-monitor.ts

@ -0,0 +1,107 @@ @@ -0,0 +1,107 @@
/**
* NIP-66 relay monitor (client stub).
* Publishing 30166/10166 runs in the server cron only; this module only exposes isNip66MonitorEnabled() === false
* and no-op builders so relay-info and bootstrap can keep calling without branching.
*/
import { BIG_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { TRelayInfo } from '@/types'
import { Event as NEvent, finalizeEvent } from 'nostr-tools'
import { ExtendedKind } from '@/constants'
import logger from '@/lib/logger'
import client from '@/services/client.service'
const RELAY_DISCOVERY_KIND = ExtendedKind.RELAY_DISCOVERY
const RELAY_MONITOR_ANNOUNCEMENT_KIND = ExtendedKind.RELAY_MONITOR_ANNOUNCEMENT
let publishedAnnouncementThisSession = false
function getMonitorSecretKey(): Uint8Array | null {
return null
}
/** False in the client; publishing is done by the server cron. */
export function isNip66MonitorEnabled(): boolean {
return getMonitorSecretKey() !== null
}
/**
* Build and sign a kind 30166 relay discovery event from NIP-11derived relay info.
* Returns null in the client (signing runs in the server cron).
*/
export function buildAndSignDiscoveryEvent(relayInfo: TRelayInfo): NEvent | null {
const sk = getMonitorSecretKey()
if (!sk) return null
const d = normalizeUrl(relayInfo.url) || relayInfo.url
const tags: string[][] = [['d', d]]
if (Array.isArray(relayInfo.supported_nips)) {
for (const n of relayInfo.supported_nips) {
tags.push(['N', String(n)])
}
}
const lim = relayInfo.limitation
if (lim?.auth_required) tags.push(['R', 'auth'])
else tags.push(['R', '!auth'])
if (lim?.payment_required) tags.push(['R', 'payment'])
else tags.push(['R', '!payment'])
const draft = {
kind: RELAY_DISCOVERY_KIND,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags
}
try {
const event = finalizeEvent(draft, sk)
return event as NEvent
} catch (err) {
logger.warn('NIP-66 monitor: failed to sign 30166 event', { err, url: relayInfo.url })
return null
}
}
/**
* Build and sign a kind 10166 relay monitor announcement.
* Returns null in the client (handled by server cron).
*/
function buildAndSignMonitorAnnouncement(): NEvent | null {
const sk = getMonitorSecretKey()
if (!sk) return null
const draft = {
kind: RELAY_MONITOR_ANNOUNCEMENT_KIND,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['frequency', '3600'],
['c', 'nip11'],
['c', 'ws']
]
}
try {
return finalizeEvent(draft, sk) as NEvent
} catch (err) {
logger.warn('NIP-66 monitor: failed to sign 10166 event', { err })
return null
}
}
/** No-op in the client; 10166 is published by the server cron on startup. */
export function publishMonitorAnnouncementOnce(): void {
if (publishedAnnouncementThisSession || !isNip66MonitorEnabled()) return
const event = buildAndSignMonitorAnnouncement()
if (!event) return
publishedAnnouncementThisSession = true
logger.info('NIP-66: publishing monitor announcement (10166)')
client.publishEvent([...BIG_RELAY_URLS.slice(0, 4)], event).then((res) => {
if (res.successCount > 0) {
logger.info('NIP-66: published monitor announcement (10166)', { successCount: res.successCount })
}
}).catch((err) => {
logger.warn('NIP-66: publish monitor announcement failed', { err })
})
}

231
src/services/nip66.service.ts

@ -0,0 +1,231 @@ @@ -0,0 +1,231 @@
/**
* NIP-66 Relay Discovery and Liveness Monitoring (consumer side).
*
* Parses kind 30166 relay discovery events and exposes relay metadata (supported NIPs,
* requirements, RTT, etc.) to supplement NIP-11 and static relay lists. Clients MUST NOT
* require this data to function; use as a hint only.
*/
import { PUBLIC_LIVELY_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import indexDb from '@/services/indexed-db.service'
import { TNip66RelayDiscovery, TRelayInfo } from '@/types'
import { Event as NEvent } from 'nostr-tools'
const RELAY_DISCOVERY_KIND = 30166
function parseRequirement(value: string): { key: string; required: boolean } {
const negated = value.startsWith('!')
return { key: negated ? value.slice(1) : value, required: !negated }
}
function parseEvent(ev: NEvent): TNip66RelayDiscovery | null {
if (ev.kind !== RELAY_DISCOVERY_KIND) return null
const d = ev.tags.find((t) => t[0] === 'd')?.[1]
if (!d) return null
const url = d.startsWith('wss://') || d.startsWith('ws://') ? d : `wss://${d}`
const nips = ev.tags.filter((t) => t[0] === 'N').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n))
const requirements: TNip66RelayDiscovery['requirements'] = {}
for (const t of ev.tags.filter((t) => t[0] === 'R')) {
const { key, required } = parseRequirement(t[1] ?? '')
if (key === 'auth') requirements.auth = required
else if (key === 'payment') requirements.payment = required
else if (key === 'writes') requirements.writes = required
else if (key === 'pow') requirements.pow = required
}
const rttOpen = ev.tags.find((t) => t[0] === 'rtt-open')?.[1]
const rttRead = ev.tags.find((t) => t[0] === 'rtt-read')?.[1]
const rttWrite = ev.tags.find((t) => t[0] === 'rtt-write')?.[1]
const networkType = ev.tags.find((t) => t[0] === 'n')?.[1]
const relayType = ev.tags.find((t) => t[0] === 'T')?.[1]
const topics = ev.tags.filter((t) => t[0] === 't').map((t) => t[1]).filter(Boolean) as string[]
return {
url,
supportedNips: [...new Set(nips)],
requirements,
rttOpenMs: rttOpen != null ? parseInt(rttOpen, 10) : undefined,
rttReadMs: rttRead != null ? parseInt(rttRead, 10) : undefined,
rttWriteMs: rttWrite != null ? parseInt(rttWrite, 10) : undefined,
networkType,
relayType,
topics: topics.length ? topics : undefined,
created_at: ev.created_at,
monitorPubkey: ev.pubkey
}
}
/** TTL for the IndexedDB cache of public lively relay list (7 days). */
const PUBLIC_LIVELY_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
/** TTL for per-relay NIP-66 discovery cache (24h). After this, we refetch from network. */
const DISCOVERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
class Nip66Service {
private static instance: Nip66Service
/** Normalized relay URL -> latest discovery (we keep the most recent 30166 per relay). */
private discoveryByUrl = new Map<string, TNip66RelayDiscovery>()
static getInstance(): Nip66Service {
if (!Nip66Service.instance) {
Nip66Service.instance = new Nip66Service()
}
return Nip66Service.instance
}
private isDiscoveryStale(cachedAt: number): boolean {
return Date.now() - cachedAt > DISCOVERY_CACHE_TTL_MS
}
/**
* Ingest kind 30166 events (e.g. from a query). Merges supported NIPs from multiple
* events for the same relay; keeps the most recent event's metadata, union of NIPs.
* Updates the IndexedDB cache of public lively relays and per-relay discovery cache.
*/
loadFromEvents(events: NEvent[]): void {
const updatedKeys = new Set<string>()
for (const ev of events) {
const discovery = parseEvent(ev)
if (!discovery) continue
const key = normalizeUrl(discovery.url) || discovery.url
const existing = this.discoveryByUrl.get(key)
if (!existing) {
this.discoveryByUrl.set(key, discovery)
updatedKeys.add(key)
continue
}
const mergedNips = [...new Set([...existing.supportedNips, ...discovery.supportedNips])]
if (discovery.created_at >= existing.created_at) {
this.discoveryByUrl.set(key, { ...discovery, supportedNips: mergedNips })
} else {
this.discoveryByUrl.set(key, { ...existing, supportedNips: mergedNips })
}
updatedKeys.add(key)
}
const publicLively = this.buildPublicLivelyFromDiscovery()
if (publicLively.length > 0 && typeof window !== 'undefined') {
indexDb.setPublicLivelyRelayUrlsCache(publicLively).catch(() => {})
}
if (typeof window !== 'undefined') {
for (const key of updatedKeys) {
const d = this.discoveryByUrl.get(key)
if (d) indexDb.setNip66Discovery(key, d).catch(() => {})
}
}
}
/**
* Get discovery for a relay from memory or IndexedDB cache (if not stale).
* Use this to show UI immediately; then refetch if stale to update cache and GUI.
*/
async getDiscoveryCached(relayUrl: string): Promise<TNip66RelayDiscovery | undefined> {
const key = normalizeUrl(relayUrl) || relayUrl
const fromMemory = this.discoveryByUrl.get(key)
if (fromMemory) return fromMemory
if (typeof window === 'undefined') return undefined
try {
const cached = await indexDb.getNip66Discovery(key)
if (!cached?.discovery || this.isDiscoveryStale(cached.cachedAt)) return undefined
this.discoveryByUrl.set(key, cached.discovery)
return cached.discovery
} catch {
return undefined
}
}
/**
* True if we should refetch discovery (no cache or IDB cache is stale).
* Uses IDB only (not memory), so we refetch when cached data is past TTL.
*/
async isDiscoveryStaleForRelay(relayUrl: string): Promise<boolean> {
const key = normalizeUrl(relayUrl) || relayUrl
try {
const cached = await indexDb.getNip66Discovery(key)
return !cached || this.isDiscoveryStale(cached.cachedAt)
} catch {
return true
}
}
/**
* Build list of relay URLs that are public (no auth, no payment) and have been
* reported by NIP-66 monitors (lively). Used for "add 3 random relays" censorship resilience.
*/
private buildPublicLivelyFromDiscovery(): string[] {
const out: string[] = []
for (const d of this.discoveryByUrl.values()) {
const authRequired = d.requirements.auth === true
const paymentRequired = d.requirements.payment === true
if (!authRequired && !paymentRequired) out.push(d.url)
}
return out
}
/**
* Returns relay URLs to use for "add 3 random relays to publish". Prefers NIP-66 discovery
* (in-memory then IndexedDB cache), falls back to static PUBLIC_LIVELY_RELAY_URLS.
*/
async getPublicLivelyRelayUrls(): Promise<string[]> {
const fromMemory = this.buildPublicLivelyFromDiscovery()
if (fromMemory.length > 0) return fromMemory
if (typeof window === 'undefined') return [...PUBLIC_LIVELY_RELAY_URLS]
try {
const cached = await indexDb.getPublicLivelyRelayUrlsCache()
if (cached?.urls?.length && (Date.now() - cached.cachedAt) < PUBLIC_LIVELY_CACHE_TTL_MS) {
return cached.urls
}
} catch {
// ignore
}
return [...PUBLIC_LIVELY_RELAY_URLS]
}
getDiscovery(url: string): TNip66RelayDiscovery | undefined {
const key = normalizeUrl(url) || url
return this.discoveryByUrl.get(key)
}
/**
* Ingest relay info from our own monitor (after we publish 30166). Adds the relay to
* in-memory discovery and updates the IndexedDB public lively cache so it can be used
* for "add 3 random relays" and relay info page liveliness display.
*/
addDiscoveryFromRelayInfo(relayInfo: TRelayInfo): void {
const lim = relayInfo.limitation
const discovery: TNip66RelayDiscovery = {
url: relayInfo.url,
supportedNips: relayInfo.supported_nips ?? [],
requirements: {
auth: lim?.auth_required ?? false,
payment: lim?.payment_required ?? false
},
created_at: Math.floor(Date.now() / 1000)
}
const key = normalizeUrl(relayInfo.url) || relayInfo.url
this.discoveryByUrl.set(key, discovery)
const publicLively = this.buildPublicLivelyFromDiscovery()
if (publicLively.length > 0 && typeof window !== 'undefined') {
indexDb.setPublicLivelyRelayUrlsCache(publicLively).catch(() => {})
}
}
/** Relay URLs that NIP-66 reports as supporting NIP-50 (search). Do not rely solely on this. */
getSearchableRelayUrls(): string[] {
const out: string[] = []
for (const d of this.discoveryByUrl.values()) {
if (d.supportedNips.includes(50)) out.push(d.url)
}
return out
}
/** True if we have a 30166 for this relay that lists NIP 50. Fall back to static list / NIP-11 when false. */
isRelaySearchable(url: string): boolean {
const d = this.getDiscovery(url)
return d?.supportedNips.includes(50) ?? false
}
}
export const nip66Service = Nip66Service.getInstance()
export default nip66Service

70
src/services/relay-info.service.ts

@ -1,9 +1,13 @@ @@ -1,9 +1,13 @@
import { BIG_RELAY_URLS } from '@/constants'
import { simplifyUrl } from '@/lib/url'
import indexDb from '@/services/indexed-db.service'
import { TAwesomeRelayCollection, TRelayInfo } from '@/types'
import DataLoader from 'dataloader'
import FlexSearch from 'flexsearch'
import logger from '@/lib/logger'
import client from '@/services/client.service'
import { nip66Service } from '@/services/nip66.service'
import { buildAndSignDiscoveryEvent, isNip66MonitorEnabled } from '@/services/nip66-monitor'
class RelayInfoService {
static instance: RelayInfoService
@ -36,6 +40,12 @@ class RelayInfoService { @@ -36,6 +40,12 @@ class RelayInfoService {
{ maxBatchSize: 1 }
)
private relayUrlsForRandom: string[] = []
/** NIP-66: throttle publishing 30166 per relay (min interval 1 hour). */
private lastNip66PublishByUrl = new Map<string, number>()
private static NIP66_PUBLISH_INTERVAL_MS = 60 * 60 * 1000
/** Relay info cache TTL: refetch NIP-11 after this long (24h). */
private static RELAY_INFO_CACHE_TTL_MS = 24 * 60 * 60 * 1000
async search(query: string) {
if (this.initPromise) {
@ -111,24 +121,35 @@ class RelayInfoService { @@ -111,24 +121,35 @@ class RelayInfoService {
return this.awesomeRelayCollections
}
private isStale(relayInfo: TRelayInfo): boolean {
const at = relayInfo.cachedAt
if (at == null) return true
return Date.now() - at > RelayInfoService.RELAY_INFO_CACHE_TTL_MS
}
private async _getRelayInfo(url: string) {
const exist = this.relayInfoMap.get(url)
if (exist) {
if (exist && !this.isStale(exist)) {
return exist
}
if (exist && this.isStale(exist)) {
this.relayInfoMap.delete(url)
}
const storedRelayInfo = await indexDb.getRelayInfo(url)
if (storedRelayInfo) {
if (storedRelayInfo && !this.isStale(storedRelayInfo)) {
return await this.addRelayInfo(storedRelayInfo)
}
const nip11 = await this.fetchRelayNip11(url)
const relayInfo = {
const relayInfo: TRelayInfo = {
...(nip11 ?? {}),
url,
shortUrl: simplifyUrl(url)
}
return await this.addRelayInfo(relayInfo)
const added = await this.addRelayInfo(relayInfo)
this.maybePublishNip66Discovery(added)
return added
}
private async fetchRelayNip11(url: string) {
@ -147,6 +168,7 @@ class RelayInfoService { @@ -147,6 +168,7 @@ class RelayInfoService {
if (!Array.isArray(relayInfo.supported_nips)) {
relayInfo.supported_nips = []
}
relayInfo.cachedAt = relayInfo.cachedAt ?? Date.now()
this.relayInfoMap.set(relayInfo.url, relayInfo)
await Promise.allSettled([
@ -163,6 +185,46 @@ class RelayInfoService { @@ -163,6 +185,46 @@ class RelayInfoService {
])
return relayInfo
}
/**
* When monitor nsec is set: publish a kind 30166 for this relay after we've fetched NIP-11
* (only when the fetch was from the network, not from cache). Throttled to once per hour per relay.
* Triggered whenever getRelayInfo/getRelayInfos causes a fresh NIP-11 fetch (e.g. first time
* opening a relay, or relay not in IndexedDB).
*/
private maybePublishNip66Discovery(relayInfo: TRelayInfo): void {
if (!isNip66MonitorEnabled()) {
logger.info('NIP-66: skip 30166 (publishing is handled by server cron)', { url: relayInfo.url })
return
}
const key = relayInfo.url
const now = Date.now()
const last = this.lastNip66PublishByUrl.get(key) ?? 0
if (now - last < RelayInfoService.NIP66_PUBLISH_INTERVAL_MS) {
logger.info('NIP-66: skip 30166 (throttled, 1h per relay)', { url: relayInfo.url, nextInMin: Math.ceil((RelayInfoService.NIP66_PUBLISH_INTERVAL_MS - (now - last)) / 60000) })
return
}
const event = buildAndSignDiscoveryEvent(relayInfo)
if (!event) {
logger.info('NIP-66: skip 30166 (build/sign failed)', { url: relayInfo.url })
return
}
this.lastNip66PublishByUrl.set(key, now)
const urls = [relayInfo.url, ...BIG_RELAY_URLS.slice(0, 3)]
logger.info('NIP-66: publishing relay discovery (30166)', { url: relayInfo.url })
client.publishEvent(urls, event).then((res) => {
if (res.successCount > 0) {
nip66Service.addDiscoveryFromRelayInfo(relayInfo)
logger.info('NIP-66: published relay discovery (30166)', { url: relayInfo.url, successCount: res.successCount })
} else {
logger.info('NIP-66: relay discovery (30166) not accepted by any relay', { url: relayInfo.url })
}
}).catch((err) => {
logger.warn('NIP-66: publish relay discovery failed', { url: relayInfo.url, err })
})
}
}
const instance = RelayInfoService.getInstance()

96
src/services/relay-selection.service.ts

@ -7,6 +7,8 @@ import { TRelaySet, TRelayList } from '@/types' @@ -7,6 +7,8 @@ import { TRelaySet, TRelayList } from '@/types'
import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service'
import { getRelayListFromEvent } from '@/lib/event-metadata'
import nip66Service from '@/services/nip66.service'
import storage from '@/services/local-storage.service'
export interface RelaySelectionContext {
// User's own relays
@ -25,10 +27,23 @@ export interface RelaySelectionContext { @@ -25,10 +27,23 @@ export interface RelaySelectionContext {
openFrom?: string[]
}
/** Display type for a relay in the publish relay selector */
export type RelaySourceType =
| 'local'
| 'relay_list'
| 'client_default'
| 'open_from'
| 'favorite'
| 'relay_set'
| 'contextual'
| 'randomly_selected'
export interface RelaySelectionResult {
selectableRelays: string[]
selectedRelays: string[]
description: string
/** Source type per relay URL (for UI labels). */
relayTypes: Record<string, RelaySourceType>
}
class RelaySelectionService {
@ -50,8 +65,8 @@ class RelaySelectionService { @@ -50,8 +65,8 @@ class RelaySelectionService {
* Main entry point for relay selection logic
*/
async selectRelays(context: RelaySelectionContext): Promise<RelaySelectionResult> {
// Step 1: Build the list of selectable relays
const selectableRelays = await this.buildSelectableRelays(context)
// Step 1: Build the list of selectable relays and their source types
const { relays: selectableRelays, relayTypes } = await this.buildSelectableRelaysWithTypes(context)
// Step 2: Determine which relays should be selected (checked)
const selectedRelays = await this.determineSelectedRelays(context)
@ -62,16 +77,19 @@ class RelaySelectionService { @@ -62,16 +77,19 @@ class RelaySelectionService {
return {
selectableRelays,
selectedRelays,
description
description,
relayTypes
}
}
/**
* Build the list of all relays that can be selected
* Build the list of all relays that can be selected, with a source type for each (first source wins).
* Always includes: user's write relays (or fast write fallback) + favorite relays + relay sets
* Plus contextual relays for replies and public messages
* Plus contextual relays for replies and public messages.
*/
private async buildSelectableRelays(context: RelaySelectionContext): Promise<string[]> {
private async buildSelectableRelaysWithTypes(
context: RelaySelectionContext
): Promise<{ relays: string[]; relayTypes: Record<string, RelaySourceType> }> {
const {
userWriteRelays,
favoriteRelays,
@ -81,51 +99,67 @@ class RelaySelectionService { @@ -81,51 +99,67 @@ class RelaySelectionService {
openFrom
} = context
const selectableRelays = new Set<string>()
const order: { url: string; type: RelaySourceType }[] = []
const seen = new Set<string>()
// Helper function to safely add normalized URLs
const addRelay = (url: string) => {
const addRelay = (url: string, type: RelaySourceType) => {
if (!url) return
const normalized = normalizeUrl(url)
if (normalized) {
selectableRelays.add(normalized)
} else {
// If normalization fails or returns empty (invalid URL), skip it
if (normalized && !seen.has(normalized)) {
seen.add(normalized)
order.push({ url: normalized, type })
} else if (!normalized) {
logger.warn('Skipping invalid relay URL', { url })
}
}
// Always include user's write relays (or fallback to fast write relays)
// User's write relays (or fallback = client default)
const userRelays = userWriteRelays.length > 0 ? userWriteRelays : FAST_WRITE_RELAY_URLS
userRelays.forEach(addRelay)
const userType: RelaySourceType = userWriteRelays.length > 0 ? 'relay_list' : 'client_default'
userRelays.forEach((url) => addRelay(url, userType))
// Explicitly ensure cache relays (local network URLs) are included in selectable relays
// This ensures they show up even if there's a timing issue with relay list updates
const cacheRelays = userWriteRelays.filter(url => isLocalNetworkUrl(url))
cacheRelays.forEach(addRelay)
// Cache relays (local) – may duplicate user write; only add if not already present
const cacheRelays = userWriteRelays.filter((url) => isLocalNetworkUrl(url))
cacheRelays.forEach((url) => addRelay(url, 'local'))
// Always include favorite relays
favoriteRelays.forEach(addRelay)
favoriteRelays.forEach((url) => addRelay(url, 'favorite'))
// Always include relays from relay sets
relaySets.forEach(set => {
set.relayUrls.forEach(addRelay)
relaySets.forEach((set) => {
set.relayUrls.forEach((url) => addRelay(url, 'relay_set'))
})
// Add contextual relays for replies and public messages
if (parentEvent || isPublicMessage) {
const contextualRelays = await this.getContextualRelays(context)
contextualRelays.forEach(addRelay)
contextualRelays.forEach((url) => addRelay(url, 'contextual'))
}
// If called with specific relay URLs (e.g., from openFrom), include those
if (openFrom && openFrom.length > 0) {
openFrom.forEach(addRelay)
openFrom.forEach((url) => addRelay(url, 'open_from'))
}
// Optional random relays: preload list with 3 random public lively relays (unchecked) when setting is on
if (typeof window !== 'undefined' && storage.getAddRandomRelaysToPublish()) {
try {
const publicLively = await nip66Service.getPublicLivelyRelayUrls()
const existing = new Set(order.map((o) => o.url))
const candidates = publicLively.filter((u) => {
const n = normalizeUrl(u) || u
return !existing.has(n)
})
const shuffled = candidates.slice().sort(() => Math.random() - 0.5)
shuffled.slice(0, 3).forEach((url) => addRelay(normalizeUrl(url) || url, 'randomly_selected'))
} catch {
// ignore
}
}
// Filter out blocked relays and return deduplicated list
const deduplicatedRelays = Array.from(selectableRelays).filter(Boolean)
return this.filterBlockedRelays(deduplicatedRelays, context.blockedRelays)
const deduplicatedRelays = order.map((o) => o.url)
const filtered = this.filterBlockedRelays(deduplicatedRelays, context.blockedRelays)
const relayTypes: Record<string, RelaySourceType> = {}
order.forEach(({ url, type }) => {
if (filtered.includes(url)) relayTypes[url] = type
})
return { relays: filtered, relayTypes }
}
/**

18
src/types/index.d.ts vendored

@ -74,6 +74,24 @@ export type TRelayInfo = { @@ -74,6 +74,24 @@ export type TRelayInfo = {
auth_required?: boolean
payment_required?: boolean
}
/** Set when caching; used to expire relay info and refetch NIP-11. */
cachedAt?: number
}
/** NIP-66 relay discovery (kind 30166) parsed tags. Used to supplement NIP-11 / static lists. */
export type TNip66RelayDiscovery = {
url: string
supportedNips: number[]
requirements: { auth?: boolean; payment?: boolean; writes?: boolean; pow?: boolean }
rttOpenMs?: number
rttReadMs?: number
rttWriteMs?: number
networkType?: string
relayType?: string
topics?: string[]
created_at: number
/** Pubkey of the 30166 event author (the monitor who reported this relay). */
monitorPubkey?: string
}
export type TWebMetadata = {

Loading…
Cancel
Save