Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
3225fb0dd1
  1. 2
      knip.json
  2. 4946
      package-lock.json
  3. 16
      package.json
  4. 5
      src/features/feed/index.ts
  5. 75
      src/lib/fetch-badge-nip58.ts
  6. 28
      src/lib/fetch-badge-recipient-pubkeys.ts
  7. 61
      src/lib/follow-outbox-aggregate-relays.ts
  8. 92
      src/lib/search-follows-feed-cache.ts

2
knip.json

@ -2,6 +2,6 @@
"$schema": "https://unpkg.com/knip@5/schema.json", "$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["electron/preload.cjs!", "nip66-cron/index.mjs!"], "entry": ["electron/preload.cjs!", "nip66-cron/index.mjs!"],
"ignore": ["src/global-polyfill-types.d.ts", "src/types/**/*.d.ts"], "ignore": ["src/global-polyfill-types.d.ts", "src/types/**/*.d.ts"],
"ignoreBinaries": ["tsx", "electron", "electron-builder"], "ignoreBinaries": ["electron", "electron-builder"],
"ignoreDependencies": ["ws"] "ignoreDependencies": ["ws"]
} }

4946
package-lock.json generated

File diff suppressed because it is too large Load Diff

16
package.json

@ -21,17 +21,17 @@
"docker:prune-libretranslate-packages": "bash scripts/prune-libretranslate-packages.sh", "docker:prune-libretranslate-packages": "bash scripts/prune-libretranslate-packages.sh",
"docker:editor-tools": "bash scripts/ensure-libretranslate-dirs.sh && docker compose -f docker-compose.dev.yml --profile editor-tools up -d languagetool libretranslate", "docker:editor-tools": "bash scripts/ensure-libretranslate-dirs.sh && docker compose -f docker-compose.dev.yml --profile editor-tools up -d languagetool libretranslate",
"docker:local-ancillary": "bash scripts/ensure-libretranslate-dirs.sh && docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts build piper-tts-proxy && docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts up -d og-proxy languagetool libretranslate piper-wyoming piper-tts-proxy", "docker:local-ancillary": "bash scripts/ensure-libretranslate-dirs.sh && docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts build piper-tts-proxy && docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts up -d og-proxy languagetool libretranslate piper-wyoming piper-tts-proxy",
"piper-tts-proxy": "cross-env NODE_ENV=development npx --yes tsx services/piper-tts-proxy/http.ts", "piper-tts-proxy": "cross-env NODE_ENV=development tsx services/piper-tts-proxy/http.ts",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"knip": "npx --yes knip@5", "knip": "knip",
"format": "prettier --write .", "format": "prettier --write .",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"i18n:sync": "npx tsx scripts/sync-i18n-locales.ts && prettier --write \"src/i18n/locales/*.ts\"", "i18n:sync": "tsx scripts/sync-i18n-locales.ts && prettier --write \"src/i18n/locales/*.ts\"",
"i18n:audit": "npx tsx scripts/i18n-audit.ts", "i18n:audit": "tsx scripts/i18n-audit.ts",
"i18n:gaps": "npx tsx scripts/export-en-parity-gaps.ts", "i18n:gaps": "tsx scripts/export-en-parity-gaps.ts",
"i18n:translate-de": "PYTHONUNBUFFERED=1 .venv-i18n/bin/python scripts/auto_translate_i18n.py de", "i18n:translate-de": "PYTHONUNBUFFERED=1 .venv-i18n/bin/python scripts/auto_translate_i18n.py de",
"electron:dev": "concurrently -k -n vite,electron -c blue,green \"vite --host\" \"wait-on http://127.0.0.1:5173 && cross-env NODE_ENV=development electron .\"", "electron:dev": "concurrently -k -n vite,electron -c blue,green \"vite --host\" \"wait-on http://127.0.0.1:5173 && cross-env NODE_ENV=development electron .\"",
"build:electron": "tsc -b && vite build --mode electron", "build:electron": "tsc -b && vite build --mode electron",
@ -40,13 +40,10 @@
}, },
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3", "@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5", "@codemirror/lint": "^6.9.5",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0", "@codemirror/view": "^6.41.0",
@ -56,7 +53,6 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0", "@getalby/bitcoin-connect-react": "^3.10.0",
"@getalby/lightning-tools": "^6.1.0", "@getalby/lightning-tools": "^6.1.0",
"@lezer/markdown": "^1.6.3",
"@noble/hashes": "^1.6.1", "@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
@ -144,10 +140,12 @@
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0", "globals": "^15.13.0",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"knip": "^6.12.2",
"opentype.js": "^1.3.4", "opentype.js": "^1.3.4",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "3.4.2", "prettier": "3.4.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4.21.0",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.18.1", "typescript-eslint": "^8.18.1",
"vite": "^6.0.3", "vite": "^6.0.3",

5
src/features/feed/index.ts

@ -1,5 +0,0 @@
export * from './adapters'
export * from './descriptor'
export * from './diagnostics'
export * from './relay-policy'
export * from './runtime'

75
src/lib/fetch-badge-nip58.ts

@ -1,75 +0,0 @@
import { ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/constants'
import { normalizeUrl, isWebsocketUrl } from '@/lib/url'
import { queryService } from '@/services/client.service'
import type { Event } from 'nostr-tools'
const BADGE_AWARD_KIND = 8
function addRelayUrl(out: Set<string>, raw: string | undefined, blocked: Set<string>) {
if (!raw?.trim()) return
const n = normalizeUrl(raw.trim()) || raw.trim()
if (!n || !isWebsocketUrl(n) || blocked.has(n)) return
out.add(n)
}
/**
* Relay pool for NIP-58 definition + award fetches: profile mirrors, optional `e`-tag hint from kind 30008,
* then app profile/fast-read fallbacks. Issuer definitions often live off default fast read relays only.
*/
export function mergeNip58BadgeRelayPool(
profileRelayUrls: string[],
awardRelayHint: string | undefined,
blockedRelays: string[]
): string[] {
const blocked = new Set(blockedRelays.map((u) => normalizeUrl(u) || u).filter(Boolean))
const out = new Set<string>()
for (const u of profileRelayUrls) addRelayUrl(out, u, blocked)
addRelayUrl(out, awardRelayHint, blocked)
for (const u of PROFILE_FETCH_RELAY_URLS) addRelayUrl(out, u, blocked)
for (const u of FAST_READ_RELAY_URLS) addRelayUrl(out, u, blocked)
return [...out]
}
export async function fetchNip58BadgeDefinition(
issuerPubkey: string,
dTag: string,
relayUrls: string[]
): Promise<Event | undefined> {
if (!relayUrls.length) return undefined
const hexPk = issuerPubkey.toLowerCase()
const events = await queryService.fetchEvents(
relayUrls,
{
authors: [hexPk],
kinds: [ExtendedKind.BADGE_DEFINITION],
'#d': [dTag]
},
{
replaceableRace: true,
eoseTimeout: 4000,
globalTimeout: 22_000,
firstRelayResultGraceMs: false
}
)
const match = events.filter((e) => {
if (e.pubkey.toLowerCase() !== hexPk) return false
const d = e.tags.find((t) => t[0] === 'd')?.[1]
return d === dTag
})
return match.sort((a, b) => b.created_at - a.created_at)[0]
}
export async function fetchNip58BadgeAward(awardId: string, relayUrls: string[]): Promise<Event | undefined> {
if (!relayUrls.length || !/^[a-f0-9]{64}$/i.test(awardId)) return undefined
const events = await queryService.fetchEvents(
relayUrls,
{ ids: [awardId.toLowerCase()], kinds: [BADGE_AWARD_KIND] },
{
immediateReturn: true,
eoseTimeout: 4000,
globalTimeout: 18_000,
firstRelayResultGraceMs: false
}
)
return events.find((e) => e.id.toLowerCase() === awardId.toLowerCase())
}

28
src/lib/fetch-badge-recipient-pubkeys.ts

@ -1,28 +0,0 @@
import { ExtendedKind } from '@/constants'
import { queryService } from '@/services/client.service'
import { Event } from 'nostr-tools'
function profileBadgesEventReferencesA(ev: Event, badgeATag: string): boolean {
return ev.tags.some((t) => t[0] === 'a' && t[1] === badgeATag)
}
/**
* Pubkeys whose latest profile badge lists (kind 30008) include this badge definition `a` tag.
* Uses the same relay set as other profile fetches (typically outbox + profile mirrors).
*/
export async function fetchBadgeRecipientPubkeys(
relayUrls: string[],
badgeATag: string
): Promise<string[]> {
if (relayUrls.length === 0 || !badgeATag) return []
const events = await queryService.fetchEvents(
relayUrls,
[{ kinds: [ExtendedKind.PROFILE_BADGES], '#a': [badgeATag], limit: 200 }],
{ eoseTimeout: 2500, globalTimeout: 18000, firstRelayResultGraceMs: false }
)
const authors = new Set<string>()
for (const ev of events) {
if (profileBadgesEventReferencesA(ev, badgeATag)) authors.add(ev.pubkey)
}
return [...authors]
}

61
src/lib/follow-outbox-aggregate-relays.ts

@ -1,61 +0,0 @@
import {
DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS,
READ_ONLY_RELAY_URLS
} from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority'
import type { TRelayList } from '@/types'
/** First N NIP-65 `write` (outbox) URLs per followed pubkey, follow-list order; locals first per author. */
const FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR = 2
/** Plain `ws://` relays are almost always someone else's LAN; the client cannot use them for third-party reads. */
function isNonPublicWsRelayUrl(normalizedUrl: string): boolean {
return normalizedUrl.toLowerCase().startsWith('ws://')
}
function addLayer(
out: string[],
seen: Set<string>,
blocked: Set<string>,
urls: readonly string[]
): void {
for (const u of urls) {
const n = normalizeUrl(u) || u
if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue
seen.add(n)
out.push(n)
}
}
/**
* Merge each author's outboxes (capped per author) with {@link READ_ONLY_RELAY_URLS},
* {@link FAST_READ_RELAY_URLS}, and user favorites: normalized, blocked-stripped,
* deduped (first occurrence wins).
*/
export function buildFollowOutboxAggregateReadUrls(
relayLists: readonly TRelayList[],
blockedRelays: readonly string[],
favoriteRelays: readonly string[] = []
): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b).filter(Boolean))
const seen = new Set<string>()
const out: string[] = []
for (const rl of relayLists) {
const writes = relayUrlsLocalsFirst(rl.write ?? [])
for (const u of writes.slice(0, FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR)) {
const n = normalizeUrl(u) || u
if (!n || isNonPublicWsRelayUrl(n) || blocked.has(n) || seen.has(n)) continue
seen.add(n)
out.push(n)
}
}
addLayer(out, seen, blocked, READ_ONLY_RELAY_URLS)
addLayer(out, seen, blocked, FAST_READ_RELAY_URLS)
addLayer(out, seen, blocked, favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS)
return out
}

92
src/lib/search-follows-feed-cache.ts

@ -1,92 +0,0 @@
import logger from '@/lib/logger'
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import type { NostrEvent } from 'nostr-tools'
const STORAGE_KEY = 'jumble.searchFollowsFeed.v1'
/** Stay under typical 5MB localStorage budgets */
const MAX_JSON_CHARS = 4_000_000
export type SearchFollowsFeedCachePayloadV1 = {
v: 1
scopeKey: string
/** Hex pubkey → recent posts (same shape as in-memory map) */
posts: Record<string, NostrEvent[]>
savedAtMs: number
}
export function fingerprintSortedPubkeys(pubkeys: string[]): string {
if (pubkeys.length === 0) return '0'
const sorted = [...pubkeys].sort()
return bytesToHex(sha256(new TextEncoder().encode(sorted.join('\n'))))
}
export function fingerprintRelaySet(urls: string[]): string {
if (urls.length === 0) return '0'
return bytesToHex(sha256(new TextEncoder().encode(urls.join('\n'))))
}
export function buildSearchFollowsFeedScopeKey(input: {
mode: 'self' | 'recommended'
viewerPubkey: string | null
followListFingerprint: string
aggregateRelayFingerprint: string
}): string {
const v = input.viewerPubkey?.toLowerCase() ?? ''
return `${input.mode}|${v}|${input.followListFingerprint}|${input.aggregateRelayFingerprint}`
}
export function readSearchFollowsFeedCache(
scopeKey: string
): SearchFollowsFeedCachePayloadV1 | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw || raw.length > MAX_JSON_CHARS) return null
const data = JSON.parse(raw) as unknown
if (!data || typeof data !== 'object') return null
const o = data as Record<string, unknown>
if (o.v !== 1 || o.scopeKey !== scopeKey) return null
if (typeof o.savedAtMs !== 'number' || typeof o.posts !== 'object' || o.posts === null) return null
const posts = o.posts as Record<string, unknown>
const out: Record<string, NostrEvent[]> = {}
for (const [pk, arr] of Object.entries(posts)) {
if (!Array.isArray(arr)) continue
const evs = arr.filter((x): x is NostrEvent => x && typeof x === 'object' && typeof (x as NostrEvent).id === 'string')
if (evs.length) out[pk] = evs
}
return { v: 1, scopeKey, posts: out, savedAtMs: o.savedAtMs }
} catch {
return null
}
}
export function writeSearchFollowsFeedCache(payload: SearchFollowsFeedCachePayloadV1): void {
try {
const json = JSON.stringify(payload)
if (json.length > MAX_JSON_CHARS) {
logger.debug('[SearchFollowsFeedCache] skip write (payload too large)', {
chars: json.length
})
return
}
localStorage.setItem(STORAGE_KEY, json)
} catch (e) {
logger.debug('[SearchFollowsFeedCache] write failed', { error: e })
}
}
export function postsMapToRecord(m: Map<string, NostrEvent[]>): Record<string, NostrEvent[]> {
const o: Record<string, NostrEvent[]> = {}
for (const [k, v] of m) {
if (v.length) o[k] = v
}
return o
}
export function postsRecordToMap(r: Record<string, NostrEvent[]>): Map<string, NostrEvent[]> {
const m = new Map<string, NostrEvent[]>()
for (const [k, v] of Object.entries(r)) {
if (Array.isArray(v) && v.length) m.set(k, v)
}
return m
}
Loading…
Cancel
Save