From da64b464944ccbe9c7a0abfaea328deeff5d9a9d Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 08:55:54 -0700 Subject: [PATCH 01/45] Show notification badges on thread items --- src/app/components/ThreadBoardItem.svelte | 22 ++++++++++++++-------- src/app/notifications.ts | 5 ++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/app/components/ThreadBoardItem.svelte b/src/app/components/ThreadBoardItem.svelte index 74265a5b..7871d80f 100644 --- a/src/app/components/ThreadBoardItem.svelte +++ b/src/app/components/ThreadBoardItem.svelte @@ -3,9 +3,12 @@ import {formatTimestamp, max} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {COMMENT, tagSpec, tagValue} from "@welshman/util" + import {fade} from "@lib/transition" + import Link from '@lib/components/Link.svelte' import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileName from "@app/components/ProfileName.svelte" import {deriveEventsForUrl} from "@app/repository" + import {notifications} from "@app/notifications" import {makeThreadPath} from "@app/routes" type Props = { @@ -22,15 +25,15 @@ const lastActive = $derived(max([...$replies, event].map(e => e.created_at))) const title = tagValue(tagSpec("title"), event.tags) const path = makeThreadPath(url, event.id) + const onClick = () => goto(path) + // notification={$notifications.has(path)} - const goToThread = () => goto(path) {#if mobile} - + {:else} + onclick={onClick} + class="hover:bg-surface-less cursor-pointer border-b border-solid border-line text-sm transition-colors"> + {#if $notifications.has(path)} +
+ {/if} {title || "Untitled thread"} diff --git a/src/app/notifications.ts b/src/app/notifications.ts index cf7bc9b6..55b84f4e 100644 --- a/src/app/notifications.ts +++ b/src/app/notifications.ts @@ -304,7 +304,10 @@ export const allNotifications = derived( for (const [path, {listPath, latestEvent}] of latestEventByContentPath(url, events)) { if (hasNotification(path, latestEvent)) { paths.add(path) - paths.add(listPath) + + if (hasNotification(listPath, latestEvent)) { + paths.add(listPath) + } if (hasNotification(spacePath, latestEvent)) { paths.add(spacePath) From 3d66fb3109b71dd181950c0a72ed5724ad5e85da Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 09:21:30 -0700 Subject: [PATCH 02/45] Use welshman's pendingJoins util --- src/app/actionItems.ts | 107 +--------------------- src/app/components/ThreadBoardItem.svelte | 3 +- 2 files changed, 5 insertions(+), 105 deletions(-) diff --git a/src/app/actionItems.ts b/src/app/actionItems.ts index bd551bd4..d2c254c1 100644 --- a/src/app/actionItems.ts +++ b/src/app/actionItems.ts @@ -1,111 +1,12 @@ import {derived} from "svelte/store" -import {first, groupBy, removeUndefined, spec} from "@welshman/lib" -import { - REPORT, - ROOM_ADD_MEMBER, - ROOM_JOIN, - ROOM_LEAVE, - ROOM_MEMBERS, - ROOM_REMOVE_MEMBER, - hexTags, - sortEventsAsc, - sortEventsDesc, - tagSpec, - tagValue, - tagValues, -} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" +import {REPORT, sortEventsDesc} from "@welshman/util" +import {rooms} from "@app/core" import {deriveEventsForUrl} from "@app/repository" -// A room's membership replayed in order — each member list is a snapshot that resets -// the set, with add/remove events applied on top. -const getRoomMembers = (events: TrustedEvent[]) => { - const members = new Set() - - for (const event of sortEventsAsc(events)) { - const pubkeys = tagValues(hexTags("p"), event.tags) - - if (event.kind === ROOM_MEMBERS) { - members.clear() - pubkeys.forEach(pubkey => members.add(pubkey)) - } else if (event.kind === ROOM_ADD_MEMBER) { - pubkeys.forEach(pubkey => members.add(pubkey)) - } else if (event.kind === ROOM_REMOVE_MEMBER) { - pubkeys.forEach(pubkey => members.delete(pubkey)) - } - } - - return members -} - // Action items (admin review queue) export const deriveSpaceActionItems = (url: string) => derived( - deriveEventsForUrl(url, [ - {kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]}, - ]), - $events => { - const getRoomId = (e: TrustedEvent) => - tagValue(tagSpec(e.kind === ROOM_MEMBERS ? "d" : "h"), e.tags) - const reports = $events.filter(spec({kind: REPORT})) - const pendingJoins: TrustedEvent[] = [] - - // Room-level join requests — most recent per pubkey+h - for (const [h, roomEvents] of groupBy(getRoomId, $events)) { - if (!h) continue - - const roomJoins: TrustedEvent[] = [] - const roomLeaves: TrustedEvent[] = [] - const roomMembershipEvents: TrustedEvent[] = [] - - for (const event of roomEvents) { - switch (event.kind) { - case ROOM_JOIN: - roomJoins.push(event) - break - case ROOM_LEAVE: - roomLeaves.push(event) - break - case ROOM_MEMBERS: - case ROOM_ADD_MEMBER: - case ROOM_REMOVE_MEMBER: - roomMembershipEvents.push(event) - break - } - } - - const roomMembers = getRoomMembers(roomMembershipEvents) - - pendingJoins.push( - ...removeUndefined( - Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events => - first(sortEventsDesc(events)), - ), - ).filter(({pubkey, created_at}) => { - if (roomMembers.has(pubkey)) return false - if ( - roomMembershipEvents.some(event => { - if (event.created_at <= created_at) { - return false - } - - if (event.kind === ROOM_MEMBERS) { - return true - } - - return tagValues(hexTags("p"), event.tags).includes(pubkey) - }) - ) { - return false - } - if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false - - return true - }), - ) - } - - return sortEventsDesc([...reports, ...pendingJoins]) - }, + [deriveEventsForUrl(url, [{kinds: [REPORT]}]), rooms.get().pendingJoins(url).$], + ([$reports, $pendingJoins]) => sortEventsDesc([...$reports, ...$pendingJoins]), ) diff --git a/src/app/components/ThreadBoardItem.svelte b/src/app/components/ThreadBoardItem.svelte index 7871d80f..f55b67cf 100644 --- a/src/app/components/ThreadBoardItem.svelte +++ b/src/app/components/ThreadBoardItem.svelte @@ -4,7 +4,7 @@ import type {TrustedEvent} from "@welshman/util" import {COMMENT, tagSpec, tagValue} from "@welshman/util" import {fade} from "@lib/transition" - import Link from '@lib/components/Link.svelte' + import Link from "@lib/components/Link.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileName from "@app/components/ProfileName.svelte" import {deriveEventsForUrl} from "@app/repository" @@ -27,7 +27,6 @@ const path = makeThreadPath(url, event.id) const onClick = () => goto(path) // notification={$notifications.has(path)} - {#if mobile} From 4ee05fd21cb1e9d891da83a5767a4b4cfdff19c5 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 09:41:38 -0700 Subject: [PATCH 03/45] Fix unread notification count --- src/app/notifications.ts | 164 +++++++++++++++++++------------------- src/routes/+layout.svelte | 9 ++- 2 files changed, 89 insertions(+), 84 deletions(-) diff --git a/src/app/notifications.ts b/src/app/notifications.ts index 55b84f4e..e95f6f0b 100644 --- a/src/app/notifications.ts +++ b/src/app/notifications.ts @@ -1,7 +1,18 @@ import {derived, get, writable} from "svelte/store" import {Badge} from "@capawesome/capacitor-badge" import {page} from "$app/stores" -import {assoc, prop, first, identity, groupBy, now, throttle, parseJson, gt} from "@welshman/lib" +import { + assoc, + prop, + first, + identity, + groupBy, + now, + remove, + throttle, + parseJson, + gt, +} from "@welshman/lib" import type {SignedEvent, TrustedEvent} from "@welshman/util" import { getIdOrAddress, @@ -17,13 +28,7 @@ import {synced, throttled, withGetter} from "@welshman/store" import {Relays, RoomLists} from "@welshman/app" import {deriveEventsByIdByUrl} from "@app/repository" import {app, fromApp} from "@app/core" -import { - makeSpacePath, - makeRoomPath, - makeSpaceChatPath, - makeChatPath, - makeContentPath, -} from "@app/routes" +import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes" import {CONTENT_KINDS, makeCommentFilter} from "@app/content" import {notificationSettings} from "@app/settings" import {chatsById} from "@app/chats" @@ -49,15 +54,15 @@ export const setChecked = (key: string) => checked.update(assoc(key, now())) /** Room path while video call UI hides chat; checked + badge stay active until chat is shown. */ export const deferredRoomPath = writable(undefined) +const getPaths = (path: string) => + path + .split("/") + .map((_, i, segments) => segments.slice(0, i + 1).join("/")) + .slice(1) + export const syncChecked = () => { let prev = "" - const getPaths = (path: string) => - path - .split("/") - .map((_, i, segments) => segments.slice(0, i + 1).join("/")) - .slice(1) - return page.subscribe($page => { // Set checked when we leave a given page checked.update($checked => { @@ -200,7 +205,7 @@ const getContentTarget = (event: TrustedEvent) => { // Assumes `events` is sorted descending, so the first event seen per content item wins. const latestEventByContentPath = (url: string, events: TrustedEvent[]) => { - const byPath = new Map() + const byPath = new Map() for (const event of events) { const target = getContentTarget(event) @@ -208,23 +213,21 @@ const latestEventByContentPath = (url: string, events: TrustedEvent[]) => { if (!target) continue const path = makeContentPath(url, target.kind, target.idOrAddress) - const listPath = makeContentPath(url, target.kind) - if (path && listPath && !byPath.has(path)) { - byPath.set(path, {listPath, latestEvent: event}) + if (path && !byPath.has(path)) { + byPath.set(path, event) } } return byPath } -export const allNotifications = derived( +export const latestActivityByPath = derived( throttled( 1000, derived( [ app, - checked, chatsById, fromApp($app => $app.use(Relays).index.$), fromApp($app => $app.use(RoomLists).index.$), @@ -236,17 +239,56 @@ export const allNotifications = derived( identity, ), ), - ([$app, $checked, $chatsById, $relays, $roomLists, eventsByIdByUrl]) => { - const hasNotification = (path: string, latestEvent?: TrustedEvent) => { - if (!latestEvent || latestEvent.pubkey === $app.user?.pubkey) { + ([$app, $chatsById, $relays, $roomLists, eventsByIdByUrl]) => { + const activity = new Map() + + for (const {pubkeys, messages} of $chatsById.values()) { + if (messages[0]) { + activity.set(makeChatPath(pubkeys), messages[0]) + } + } + + const roomList = $app.user?.pubkey ? $roomLists.get($app.user.pubkey) : undefined + const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? []) + + for (const url of urls) { + const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values()) + + if ($relays.get(url)?.hasNip(29)) { + for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) { + if (h) { + activity.set(makeRoomPath(url, h), latestEvent) + } + } + } else { + const latestEvent = first(events) + + if (latestEvent) { + activity.set(makeSpaceChatPath(url), latestEvent) + } + } + + for (const [path, latestEvent] of latestEventByContentPath(url, events)) { + activity.set(path, latestEvent) + } + } + + return activity + }, +) + +export const allNotifications = derived( + [app, latestActivityByPath, checked], + ([$app, $latestActivityByPath, $checked]) => { + const hasNotification = (path: string, latestEvent: TrustedEvent) => { + if (latestEvent.pubkey === $app.user?.pubkey) { return false } for (const [entryPath, ts] of Object.entries($checked)) { - const isMatch = - entryPath === "*" || - entryPath.startsWith(path) || - (entryPath === "/chat/*" && path.startsWith("/chat/")) + const isMatch = entryPath.endsWith("*") + ? path.startsWith(entryPath.slice(0, -1)) + : entryPath.startsWith(path) if (isMatch && ts > latestEvent.created_at) { return false @@ -258,59 +300,13 @@ export const allNotifications = derived( const paths = new Set() - for (const {pubkeys, messages} of $chatsById.values()) { - const chatPath = makeChatPath(pubkeys) + for (const [path, latestEvent] of $latestActivityByPath) { + if (hasNotification(path, latestEvent)) { + paths.add(path) - if (hasNotification(chatPath, messages[0])) { - paths.add("/chat") - paths.add(chatPath) - } - } - - const roomList = $app.user?.pubkey ? $roomLists.get($app.user?.pubkey) : undefined - const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? []) - - for (const url of urls) { - const spacePath = makeSpacePath(url) - const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values()) - - if ($relays.get(url)?.hasNip(29)) { - for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) { - if (h) { - const roomPath = makeRoomPath(url, h) - - if (hasNotification(roomPath, latestEvent)) { - paths.add(roomPath) - - if (hasNotification(spacePath, latestEvent)) { - paths.add(spacePath) - } - } - } - } - } else { - const messagesPath = makeSpaceChatPath(url) - const latestEvent = first(events) - - if (hasNotification(messagesPath, latestEvent)) { - paths.add(messagesPath) - - if (hasNotification(spacePath, latestEvent)) { - paths.add(spacePath) - } - } - } - - for (const [path, {listPath, latestEvent}] of latestEventByContentPath(url, events)) { - if (hasNotification(path, latestEvent)) { - paths.add(path) - - if (hasNotification(listPath, latestEvent)) { - paths.add(listPath) - } - - if (hasNotification(spacePath, latestEvent)) { - paths.add(spacePath) + for (const branchPath of remove(path, getPaths(path.split("?")[0]))) { + if (hasNotification(branchPath, latestEvent)) { + paths.add(branchPath) } } } @@ -335,11 +331,15 @@ export const notifications = derived( // Badges export const syncBadges = () => - derived([notifications, notificationSettings], identity).subscribe( - async ([$notifications, $notificationSettings]) => { + derived([latestActivityByPath, notifications, notificationSettings], identity).subscribe( + async ([$latestActivityByPath, $notifications, $notificationSettings]) => { if ($notificationSettings.badge) { + const count = [...$latestActivityByPath.keys()].filter(path => + $notifications.has(path), + ).length + try { - await Badge.set({count: $notifications.size}) + await Badge.set({count}) } catch (err) { // pass - firefox doesn't support badges } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5cbc1be9..a5ad1dee 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -35,7 +35,11 @@ import {activeTheme, flTheme, theme} from "@app/theme" import {toast, pushToast} from "@app/toast" import * as notifications from "@app/notifications" - import {notifications as notificationPaths, allNotifications} from "@app/notifications" + import { + notifications as notificationPaths, + allNotifications, + latestActivityByPath, + } from "@app/notifications" import {Push} from "@app/push" import {onPushNotificationAction, pushState} from "@app/push/adapters/common" import {syncKeyboard} from "@app/keyboard" @@ -274,7 +278,8 @@ const title = getPageTitle({page: $page, pubkey: $app.user?.pubkey}) // While the tab isn't actively focused the user isn't actually looking at the // active page, so count notifications for it too rather than treating it as read. - const unreadCount = $documentActive ? $notificationPaths.size : $allNotifications.size + const paths = $documentActive ? $notificationPaths : $allNotifications + const unreadCount = [...$latestActivityByPath.keys()].filter(path => paths.has(path)).length document.title = unreadCount > 0 ? `(${unreadCount}) ${title}` : title }) From 25f7d6892d746380e53a4c7a5d56666a3a130ceb Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 09:44:13 -0700 Subject: [PATCH 04/45] Fix nip 55 --- src/routes/+layout.svelte | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a5ad1dee..ce1f50fe 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,11 +6,14 @@ import type {Unsubscriber} from "svelte/store" import {get} from "svelte/store" import {App, type URLOpenListenerEvent} from "@capacitor/app" + import {Capacitor} from "@capacitor/core" + import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin" import {dev} from "$app/environment" import {goto} from "$app/navigation" import {page} from "$app/stores" import {context as pomadeContext} from "@pomade/core" import {sync, throttled} from "@welshman/store" + import {setNip55Plugin} from "@welshman/signer" import * as domain from "@welshman/domain" import * as util from "@welshman/util" import * as lib from "@welshman/lib" @@ -68,6 +71,10 @@ pomadeContext.setSignerUrls(env.POMADE_SIGNERS) pomadeContext.setArgonWorker(import("@pomade/core/argon-worker.js?worker")) + if (Capacitor.isNativePlatform()) { + setNip55Plugin(NostrSignerPlugin) + } + // Handle a deep link (universal/app link or custom scheme). Used for both // warm-start links (via the appUrlOpen event) and cold-start links (via // getLaunchUrl below) so the invite relay/claim query params are preserved From 0f448e0b5acdcea9a61d60be9f3f30a3d0824f4d Mon Sep 17 00:00:00 2001 From: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Date: Mon, 17 Aug 2026 17:19:13 +0000 Subject: [PATCH 05/45] Restore mic state on full voice reconnect after a dropout (#373) Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> --- src/app/call.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app/call.ts b/src/app/call.ts index ccc2364b..8e3e4752 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -642,6 +642,11 @@ export const participantKey = (p: CallParticipant) => p.pubkey ?? p.liveKitIdent let activeRoom: LiveKitRoom | undefined let reconnectTimeout: ReturnType | undefined let reconnectAttempt = 0 +// Captured from the dropped session so a full rejoin (as opposed to LiveKit's +// own internal reconnect, which reuses the existing tracks) restores the +// user's mic state instead of always rejoining muted. +let reconnectMicMuted = true +let reconnectMicDeviceId: string | undefined let joinAbortController: AbortController | undefined let hadCallSession = false @@ -814,7 +819,7 @@ const attemptReconnect = async () => { if (!target) return try { - await joinVoiceRoom(target.url, target.h) + await joinVoiceRoom(target.url, target.h, reconnectMicMuted, reconnectMicDeviceId) } catch { if (reconnectAttempt >= RECONNECT_DELAYS.length) { pushToast({theme: "error", message: "Voice connection lost."}) @@ -855,6 +860,12 @@ const makeOnRoomDisconnected = (livekit: LiveKitRoom) => (reason?: DisconnectRea activeRoom = undefined livekit.removeAllListeners() + // Capture mic state before resetting it, so a subsequent full rejoin (see + // scheduleReconnect/attemptReconnect below) can restore it instead of + // silently coming back muted regardless of what the user had set. + reconnectMicMuted = get(callMicMuted) + reconnectMicDeviceId = livekit.getActiveDevice(DeviceKind.AudioInput) + callMicMuted.set(true) currentCallSession.set(undefined) if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { From 01a79d570bf66b7e7aede84f275f06b223f0b083 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 10:17:12 -0700 Subject: [PATCH 06/45] Cache space nav items and fix meta storage --- src/app/components/SpaceMenuNavItems.svelte | 13 ++++++++--- src/app/content.ts | 3 ++- src/app/storage.ts | 25 ++++++++++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/app/components/SpaceMenuNavItems.svelte b/src/app/components/SpaceMenuNavItems.svelte index e120f20a..6765e955 100644 --- a/src/app/components/SpaceMenuNavItems.svelte +++ b/src/app/components/SpaceMenuNavItems.svelte @@ -1,5 +1,6 @@ diff --git a/src/app/content.ts b/src/app/content.ts index 39194ad5..90fca8ca 100644 --- a/src/app/content.ts +++ b/src/app/content.ts @@ -6,6 +6,7 @@ import { DIRECT_MESSAGE_FILE, EVENT_TIME, GENERIC_REPOST, + PINBOARD, POLL, REACTION, REPORT, @@ -40,7 +41,7 @@ if (ENABLE_ZAPS) { REACTION_KINDS.push(ZAP_RECEIPT) } -export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL] +export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL, PINBOARD] export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] diff --git a/src/app/storage.ts b/src/app/storage.ts index c94ec31d..be3d0e01 100644 --- a/src/app/storage.ts +++ b/src/app/storage.ts @@ -250,7 +250,8 @@ class Storage { } private syncEvents = () => { - const table = this.db.table("events") + const eventsTable = this.db.table("events") + const trackerTable = this.db.table("tracker") return on( this.app.repository, @@ -273,11 +274,26 @@ class Storage { } if (add.length > 0) { - await table.bulkPut(add) + // The ingest policy tracks an event before publishing it, so by the time the + // repository reports it, its provenance is already in the tracker — persist the + // two together so an event is never saved without the relays it came from. + const items: TrackerItem[] = [] + + for (const {id} of add) { + const relays = Array.from(this.app.tracker.getRelays(id)) + + if (relays.length > 0) { + items.push({id, relays}) + } + } + + await eventsTable.bulkPut(add) + await trackerTable.bulkPut(items) } if (remove.size > 0) { - await table.bulkDelete(remove) + await eventsTable.bulkDelete(remove) + await trackerTable.bulkDelete(remove) } }), ) @@ -312,6 +328,9 @@ class Storage { for (const id of ids) { const event = this.app.repository.getEvent(id) + // A brand-new event is tracked before it's published, so it isn't queryable here — + // syncEvents persists its provenance along with the event itself. This pass only + // records additional relays for events we already have. if (!event || !shouldPersistEvent(event)) continue const relays = Array.from(this.app.tracker.getRelays(id)) From dfb971e7dd7dba6851fd6d4deaadc137a85fb713 Mon Sep 17 00:00:00 2001 From: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Date: Mon, 17 Aug 2026 18:13:37 +0000 Subject: [PATCH 07/45] feat: add "Convert to Thread" action on room messages (#370) Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> --- src/app/components/RoomItemMenu.svelte | 15 +++++++++++++++ src/app/components/RoomItemMenuMobile.svelte | 10 ++++++++++ src/app/components/ThreadCreate.svelte | 14 ++++++++++++-- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/app/components/RoomItemMenu.svelte b/src/app/components/RoomItemMenu.svelte index 49cc2b63..9673aef7 100644 --- a/src/app/components/RoomItemMenu.svelte +++ b/src/app/components/RoomItemMenu.svelte @@ -6,6 +6,7 @@ import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl" import Danger from "@assets/icons/danger.svg?dataurl" import Pin from "@assets/icons/pin.svg?dataurl" + import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" import Button from "@lib/components/Button.svelte" import Icon from "@lib/components/Icon.svelte" import Confirm from "@lib/components/Confirm.svelte" @@ -13,6 +14,7 @@ import Report from "@app/components/Report.svelte" import PinboardSelect from "@app/components/PinboardSelect.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" + import ThreadCreate from "@app/components/ThreadCreate.svelte" import {app, relayManagement, roomPinLists, user} from "@app/core" import {deriveUserIsSpaceAdmin} from "@app/management" import {ROOM, deriveUserIsRoomAdmin} from "@app/rooms" @@ -38,6 +40,11 @@ pushModal(PinboardSelect, {url, event}) } + const createThread = () => { + onClick() + pushModal(ThreadCreate, {url, h, parent: event}) + } + const report = () => { onClick() pushModal(Report, {url, event}) @@ -100,6 +107,14 @@ Add to Library + {#if h} +
  • + +
  • + {/if} {#if h && $userIsRoomAdmin}
  • {/if} + {#if h} + + {/if} {#if ENABLE_ZAPS} diff --git a/src/app/components/ThreadCreate.svelte b/src/app/components/ThreadCreate.svelte index 7a578fb2..7af89e55 100644 --- a/src/app/components/ThreadCreate.svelte +++ b/src/app/components/ThreadCreate.svelte @@ -1,7 +1,10 @@ + +
    + {#if h && showRoom} + + Posted in # + + {/if} +
    + {#each uniq(topics) as topic (topic)} + + {/each} +
    + + + {#if showActivity} + + {/if} + +
    diff --git a/src/app/components/ArticleCreate.svelte b/src/app/components/ArticleCreate.svelte new file mode 100644 index 00000000..0d1ab32c --- /dev/null +++ b/src/app/components/ArticleCreate.svelte @@ -0,0 +1,215 @@ + + + + + + Write an Article + Publish a long-form post to this space. + +
    + + {#snippet label()} +

    Title*

    + {/snippet} + {#snippet input()} + + {/snippet} +
    + + {#snippet label()} +

    Summary

    + {/snippet} + {#snippet input()} + + {/snippet} +
    + + {#snippet label()} +

    Topics

    + {/snippet} + {#snippet input()} + + {/snippet} +
    + + {#snippet label()} +

    Content*

    + {/snippet} + {#snippet input()} +
    + +
    + {/snippet} +
    + +
    +
    + + + + +
    diff --git a/src/app/components/ArticleItem.svelte b/src/app/components/ArticleItem.svelte new file mode 100644 index 00000000..53f4f9ad --- /dev/null +++ b/src/app/components/ArticleItem.svelte @@ -0,0 +1,57 @@ + + + + {#if image} + + {/if} +
    +

    {title || "Untitled"}

    +

    + {formatTimestamp(article.publishedAt())} +

    +
    + +
    + + Written by + + {#if h} + in + {/if} + + +
    + diff --git a/src/app/components/ArticleSidebar.svelte b/src/app/components/ArticleSidebar.svelte new file mode 100644 index 00000000..304336dc --- /dev/null +++ b/src/app/components/ArticleSidebar.svelte @@ -0,0 +1,80 @@ + + + diff --git a/src/app/components/CommentActions.svelte b/src/app/components/CommentActions.svelte index 4c379480..b675888f 100644 --- a/src/app/components/CommentActions.svelte +++ b/src/app/components/CommentActions.svelte @@ -11,7 +11,7 @@ type Props = { url: string event: TrustedEvent - segment: string + segment?: string showActivity?: boolean } @@ -19,7 +19,7 @@ const h = tagValue(tagSpec("h"), event.tags) - const path = makeSpacePath(url, segment, event.id) + const path = segment && makeSpacePath(url, segment, event.id) const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) @@ -30,7 +30,7 @@
    - {#if showActivity} + {#if showActivity && path} {/if} diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte new file mode 100644 index 00000000..63f1c200 --- /dev/null +++ b/src/app/components/CommentCompose.svelte @@ -0,0 +1,121 @@ + + +
    +
    +
    + +
    + +
    +
    + + +
    +
    diff --git a/src/app/components/CommentTree.svelte b/src/app/components/CommentTree.svelte new file mode 100644 index 00000000..427b6e5a --- /dev/null +++ b/src/app/components/CommentTree.svelte @@ -0,0 +1,62 @@ + + +
    + +
    + + {#if url} +
    + + +
    + {/if} +
    +
    + {#if composing && url} +
    + +
    + {/if} + {#if node.children.length > 0} +
    + {#each node.children as child (child.comment.id)} + + {/each} +
    + {/if} +
    diff --git a/src/app/components/ComposeMenu.svelte b/src/app/components/ComposeMenu.svelte index cc55fada..0fba2249 100644 --- a/src/app/components/ComposeMenu.svelte +++ b/src/app/components/ComposeMenu.svelte @@ -4,12 +4,14 @@ import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl" import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl" + import DocumentText from "@assets/icons/document-text.svg?dataurl" import Revote from "@assets/icons/revote.svg?dataurl" import Button from "@lib/components/Button.svelte" import Icon from "@lib/components/Icon.svelte" import {pushModal} from "@app/modal" import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte" import ThreadCreate from "@app/components/ThreadCreate.svelte" + import ArticleCreate from "@app/components/ArticleCreate.svelte" import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte" import GoalCreate from "@app/components/GoalCreate.svelte" import PollCreate from "@app/components/PollCreate.svelte" @@ -32,6 +34,8 @@ const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true}) + const createArticle = () => pushModal(ArticleCreate, {url, h, shareToChat: true}) + let ul: Element onMount(() => { @@ -64,6 +68,12 @@ Create Thread
  • +
  • + +
  • + + {/if} +{/if} diff --git a/src/app/components/NoteContent.svelte b/src/app/components/NoteContent.svelte index 406e40b8..64cd8108 100644 --- a/src/app/components/NoteContent.svelte +++ b/src/app/components/NoteContent.svelte @@ -1,9 +1,19 @@ + +
    + {#if image} + + {/if} + {#if title} +

    {title}

    + {/if} + {#if props.event.content} + + {/if} +
    diff --git a/src/app/components/NoteContentMinimal.svelte b/src/app/components/NoteContentMinimal.svelte index d715d4df..a9c18eb5 100644 --- a/src/app/components/NoteContentMinimal.svelte +++ b/src/app/components/NoteContentMinimal.svelte @@ -1,9 +1,19 @@ + +{#if title} + {title} +{/if} +{#if props.event.content} + +{/if} diff --git a/src/app/components/RecentItem.svelte b/src/app/components/RecentItem.svelte index cc19e22c..5ec1e9af 100644 --- a/src/app/components/RecentItem.svelte +++ b/src/app/components/RecentItem.svelte @@ -1,8 +1,9 @@ + + + {#snippet leading()} + + {/snippet} + {#snippet title()} + Articles + {/snippet} + {#snippet action()} + + {/snippet} + + + +
    + {#each filtered as event (getAddress(event))} +
    + +
    + {/each} +

    + + {#if loading} + Looking for articles... + {:else if articles.length === 0} + No articles found. + {:else if filtered.length === 0} + No articles match that filter. + {:else} + That's all! + {/if} + +

    +
    + +
    diff --git a/src/routes/spaces/[relay]/articles/[address]/+page.svelte b/src/routes/spaces/[relay]/articles/[address]/+page.svelte new file mode 100644 index 00000000..c78886cf --- /dev/null +++ b/src/routes/spaces/[relay]/articles/[address]/+page.svelte @@ -0,0 +1,62 @@ + + + + {#snippet title()} +

    {$article?.title() ?? ""}

    + {/snippet} +
    + + + {#if $event && $article} + +
    + {#if $article.image()} + + {/if} +
    +

    {$article.title() ?? "Untitled"}

    +

    + Published {formatTimestamp($article.publishedAt())} +

    +
    + {#if $article.summary()} +

    {$article.summary()}

    + {/if} + + +
    +
    + + {:else} +
    + {#await sleep(5000)} + Loading article... + {:then} +

    Failed to load article.

    + {/await} +
    + {/if} +
    diff --git a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte index 50a47ff5..3d5c288d 100644 --- a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte @@ -1,10 +1,9 @@ diff --git a/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte b/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte index 049bed8b..1048d2a2 100644 --- a/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte @@ -1,10 +1,9 @@ diff --git a/src/routes/spaces/[relay]/goals/[id]/+page.svelte b/src/routes/spaces/[relay]/goals/[id]/+page.svelte index 8ce2c71e..7ae10b5f 100644 --- a/src/routes/spaces/[relay]/goals/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/goals/[id]/+page.svelte @@ -1,10 +1,9 @@ diff --git a/src/routes/spaces/[relay]/polls/[id]/+page.svelte b/src/routes/spaces/[relay]/polls/[id]/+page.svelte index fa593438..4fda5fab 100644 --- a/src/routes/spaces/[relay]/polls/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/polls/[id]/+page.svelte @@ -3,7 +3,7 @@ import {page} from "$app/stores" import {sleep} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib" - import {COMMENT, POLL, POLL_RESPONSE} from "@welshman/util" + import {POLL, POLL_RESPONSE, getCommentFiltersForRoot} from "@welshman/util" import {deriveEventsAsc} from "@welshman/store" import SortVertical from "@assets/icons/sort-vertical.svg?dataurl" import Icon from "@lib/components/Icon.svelte" @@ -22,8 +22,8 @@ const {relay, id} = $page.params as MakeNonOptional const url = decodeRelay(relay) const event = deriveEvent(id, [url]) - const filters = [{kinds: [COMMENT], "#E": [id]}] - const comments = deriveEventsAsc(deriveEventsById(filters)) + const filters = $derived($event ? getCommentFiltersForRoot([$event]) : []) + const comments = $derived(deriveEventsAsc(deriveEventsById(filters))) const back = () => history.back() @@ -47,7 +47,10 @@ $network.request({ relays: [url], - filters: [{kinds: [POLL], ids: [id]}, {kinds: [POLL_RESPONSE], "#e": [id]}, ...filters], + filters: [ + {kinds: [POLL], ids: [id]}, + {kinds: [POLL_RESPONSE], "#e": [id]}, + ], signal: controller.signal, }) @@ -55,6 +58,16 @@ controller.abort() } }) + + $effect(() => { + if (filters.length > 0) { + const controller = new AbortController() + + $network.request({relays: [url], filters, signal: controller.signal}) + + return () => controller.abort() + } + }) diff --git a/src/routes/spaces/[relay]/threads/[id]/+page.svelte b/src/routes/spaces/[relay]/threads/[id]/+page.svelte index 5628b1ff..05c10621 100644 --- a/src/routes/spaces/[relay]/threads/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/threads/[id]/+page.svelte @@ -1,5 +1,4 @@ From 3dfb5e60dec9b6c2ac03b87ba8a74968355f0ce3 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 15:14:12 -0700 Subject: [PATCH 11/45] Hide emoji picker border in dialog --- src/lib/components/base.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/components/base.css b/src/lib/components/base.css index b5601321..b723dda6 100644 --- a/src/lib/components/base.css +++ b/src/lib/components/base.css @@ -413,6 +413,12 @@ emoji-picker { max-width: 100%; } +/* In a popover the border is what separates the picker from the page, but in a dialog + it sits on a surface of its own color, so it reads as a stray box. */ +.dialog emoji-picker { + --border-size: 0; +} + [data-tippy-root]:has(emoji-picker) { max-width: min(352px, calc(100vw - 2rem)); } From 847d89845e21c87f8c33ac3e06463783c9050825 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 14 Aug 2026 10:34:46 -0700 Subject: [PATCH 12/45] Use new welshman membership helpers --- src/app/components/RoomChat.svelte | 3 +- src/app/components/RoomDetailMenu.svelte | 4 +- src/app/rooms.ts | 63 +++--------------------- 3 files changed, 9 insertions(+), 61 deletions(-) diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index 68895549..70bada97 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -10,7 +10,7 @@ import type {Maybe} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util" import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util" - import {publish} from "@welshman/app" + import {MembershipStatus, publish} from "@welshman/app" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import ClockCircle from "@assets/icons/clock-circle.svg?dataurl" import Login2 from "@assets/icons/login-3.svg?dataurl" @@ -37,7 +37,6 @@ PROTECTED, RoomType, deriveUserRoomMembershipStatus, - MembershipStatus, getRoomType, prependParent, } from "@app/rooms" diff --git a/src/app/components/RoomDetailMenu.svelte b/src/app/components/RoomDetailMenu.svelte index a8625657..b6224281 100644 --- a/src/app/components/RoomDetailMenu.svelte +++ b/src/app/components/RoomDetailMenu.svelte @@ -2,7 +2,7 @@ import {onMount} from "svelte" import {goto} from "$app/navigation" import type {Command} from "@welshman/app" - import {publish} from "@welshman/app" + import {MembershipStatus, publish} from "@welshman/app" import Pen from "@assets/icons/pen.svg?dataurl" import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl" import Login3 from "@assets/icons/login-3.svg?dataurl" @@ -13,7 +13,7 @@ import Confirm from "@lib/components/Confirm.svelte" import RoomEdit from "@app/components/RoomEdit.svelte" import {app, roomLists, rooms} from "@app/core" - import {deriveUserIsRoomAdmin, deriveUserRoomMembershipStatus, MembershipStatus} from "@app/rooms" + import {deriveUserIsRoomAdmin, deriveUserRoomMembershipStatus} from "@app/rooms" import {makeSpacePath} from "@app/routes" import {pushModal} from "@app/modal" import {pushToast} from "@app/toast" diff --git a/src/app/rooms.ts b/src/app/rooms.ts index 8fb7995d..a94bd1c9 100644 --- a/src/app/rooms.ts +++ b/src/app/rooms.ts @@ -2,20 +2,9 @@ import * as nip19 from "nostr-tools/nip19" import {derived} from "svelte/store" import {sortBy, uniq} from "@welshman/lib" import type {Maybe} from "@welshman/lib" -import { - MESSAGE, - ROOM_ADD_MEMBER, - ROOM_JOIN, - ROOM_LEAVE, - ROOM_REMOVE_MEMBER, - makeEvent, - outbox, - seen, - sortEventsAsc, - toNostrURI, -} from "@welshman/util" +import {MESSAGE, makeEvent, outbox, seen, toNostrURI} from "@welshman/util" import type {EventContent, TrustedEvent} from "@welshman/util" -import {RoomLists, makeRoomKey, createSearch} from "@welshman/app" +import {MembershipStatus, RoomLists, makeRoomKey, createSearch} from "@welshman/app" import type {Room, RoomMeta} from "@welshman/app" import { deriveUserItem, @@ -29,15 +18,8 @@ import { user, } from "@app/core" import {deriveUserIsSpaceAdmin} from "@app/management" -import {deriveEventsForUrl} from "@app/repository" import {makeRoomPath} from "@app/routes" -export enum MembershipStatus { - Initial, - Pending, - Granted, -} - export const ROOM = "h" export const PROTECTED = ["-"] @@ -58,7 +40,7 @@ export const displayRoom = (url: string, h: string) => export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() export const deriveRoomMembers = (url: string, h: string) => - derived(rooms.get().forRoom(url, h), $room => $room?.members?.members() ?? []) + derived(rooms.get().members(url, h).$, $members => Array.from($members)) // A room member also has to be allowed at the relay level, or they won't be able to read the // room at all. @@ -181,44 +163,11 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) => $isSpaceAdmin || Boolean($room?.admins?.pubkeys().includes($user.pubkey)), ) +// Room membership is the relay's business, but a space admin outranks it. export const deriveUserRoomMembershipStatus = (url: string, h: string) => derived( - [ - user, - rooms.get().forRoom(url, h), - deriveEventsForUrl(url, [{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}]), - deriveEventsForUrl(url, [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]), - deriveUserIsRoomAdmin(url, h), - ], - ([$user, $room, $addRemoveEvents, $joinLeaveEvents, $isAdmin]) => { - if ($isAdmin) { - return MembershipStatus.Granted - } - - let isMember = false - - if ($room?.members) { - isMember = $room.members.isMember($user.pubkey) - } else { - for (const event of sortEventsAsc($addRemoveEvents)) { - if (event.pubkey === $user.pubkey) { - isMember = event.kind === ROOM_ADD_MEMBER - } - } - } - - for (const event of $joinLeaveEvents) { - if (event.pubkey === $user.pubkey) { - if (event.kind === ROOM_JOIN) { - return isMember ? MembershipStatus.Granted : MembershipStatus.Pending - } - - return MembershipStatus.Initial - } - } - - return isMember ? MembershipStatus.Granted : MembershipStatus.Initial - }, + [rooms.get().membershipStatus(url, h).$, deriveUserIsRoomAdmin(url, h)], + ([$status, $isAdmin]) => ($isAdmin ? MembershipStatus.Granted : $status), ) export const deriveUserRoomSearch = () => From f5d22664ac442f32bb5fc77e0f39d18dc92eff5d Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 15:17:16 -0700 Subject: [PATCH 13/45] Bump welshman --- package.json | 20 ++++++++++---------- pnpm-lock.yaml | Bin 382072 -> 382072 bytes 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 720941da..57bd41ea 100644 --- a/package.json +++ b/package.json @@ -76,16 +76,16 @@ "@types/throttle-debounce": "^5.0.2", "@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/sveltekit": "^1.1.0", - "@welshman/app": "^0.9.0-pre5", - "@welshman/content": "^0.9.0-pre5", - "@welshman/domain": "^0.9.0-pre5", - "@welshman/editor": "^0.9.0-pre5", - "@welshman/feeds": "^0.9.0-pre5", - "@welshman/lib": "^0.9.0-pre5", - "@welshman/net": "^0.9.0-pre5", - "@welshman/signer": "^0.9.0-pre5", - "@welshman/store": "^0.9.0-pre5", - "@welshman/util": "^0.9.0-pre5", + "@welshman/app": "^0.9.0-pre6", + "@welshman/content": "^0.9.0-pre6", + "@welshman/domain": "^0.9.0-pre6", + "@welshman/editor": "^0.9.0-pre6", + "@welshman/feeds": "^0.9.0-pre6", + "@welshman/lib": "^0.9.0-pre6", + "@welshman/net": "^0.9.0-pre6", + "@welshman/signer": "^0.9.0-pre6", + "@welshman/store": "^0.9.0-pre6", + "@welshman/util": "^0.9.0-pre6", "cheerio": "^1.2.0", "compressorjs-next": "^1.1.2", "dompurify": "^3.4.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24b0f1f5fd6d8994be796ae7e6973afc846e7b16..2e35138b70bee7ee47b5bd6038e8458438f330f6 100644 GIT binary patch delta 3746 zcmdT{TZr7&8Aj_9+euCA__m4Tbyz#LlT5QCjqbrosu_)T?)TAXCe$z^X+|T>Mbc=l z)TJfRKwnZb)aRu)FbR1mCQfJ<{Nk3>kUEAYeJg!v-KRiXs2>V>YtMLgw_dZ0uJ~Ul9bauXA2IDF?GyU|~LHI`l+dB>IJ~Dmn@Z+1? z)ihG&G_|HtEUELXPEizr^SG|@WS~w|xx3pfA3n3WfYeo5mVe*Z?ut?0+rb#l^300DxCN0E=76qoM6RnfU}9hZis;*1l&p z89cO8+3BB-o!)@4Lmjq6#`OGgeRKWYc!SIDf|WPVEv=nf!u}9LV}y9N5R%i0fmikk zeS+j$(V=ZdX`>Q}cf>|aR=e9aU>u60x8DV)(C6DX!7}akWzRg|PnjamdwHXXwsvdm~85HljxTsJ|;eXfi*3Nv`i@Un@K$ja@%pDnn?D5w;I z*-^RY8|b2=R*m8aALRqC=V_HjPwq=~(wuYx-GIE>t56ovCx%Wi>Zye7aKtb)E_5wD z!;O0ZiK;lR77TPD{wmVooQ*v`i|VHk%WV~yQ-{Y}*emOlGlV9_lbJ?tsDw0&sD~;{ zM@a}KTXf@LDaSRlf}LSWjD+lr$P~GBgTcE=*>W^0 zWzQ&6YrEUna|@e#!)O;PtQ(DIn=vn)F2$Ng!;tWH(XF?JGUFx{ro#_L z9f2fD_QYQ`oeEVR`?D20=C7Gn5LYsIzA4I#QyCkQXHu(GrJf5*s;?Zb&A+LY&p%WeHw<1;rTNo4IjA;C0hKFtL{o^t`UC ze+^&ZSMZl@SDzoLQI!$wc%Y^?+QnqY!Bc!%rC6<68z-%a(rSiQ-9$%b+EJ<+cB>^{ zv`yxd-GL)m*#s46jdi>%Ov2G}rm#Ap>1HM4GQP-muAU=7r~2Gn1G&`9)jR%RpLPb4 zAeJggf3y}wVv0_l?J<a=+a!G^m^zOD1}_WXK~*=3-jg^j9iDo7ab< zRA8W`v-S9}0f|?!lQR)c!0+A!GxZVo<+sQIhMt8raq`_FvtUIiZ|K&F8;^OP@eGfyGYW?Vai9Tha@23FOkR39;zYkc2*LyRd_rwJ} zeFeNZ^D^r4W-_QTf?f_*U2L+SeM^WaIif}wx* zHGu9t4^-%@ThABrY-oee1B=g=0}Eff3AF#*;O{;Gi46$oW_k8*<++E|hqnNQF>if- N3#@&9>#^_c`6q{&SV{l@ delta 3769 zcmdT{ON`sb83xyrteqOMvwm#idR?p?dlRc!QhbVmo0JkIQSTKcQ4)g~5JgJVi&{~l zv{LJ$MSuqAB{hV4E`0jPpj9~Z0$La6pNIQ@ z4GxblAKH)3oxcwJQyhPG$Ne{x;3Pcu4%jIby&`Z@GmB|dmMQD(DK zyq4z+e1?vN9bsS({gTpMk*jsOl9Wg@$_3eACGEwHc*ZJ_Ilr1%8%n)GK*|f9NWltK zdTBPq*+hd&RkG=-8tqvvcqN3LfLBe-hvMpux4;SX_|Xlph#rVn_wh;%L*~LC)-ZQZ z_y8QomU^;ZvX0(8A=xIjKs&Zm2@ASJvQb?59 zW`XQR8X38fqGD`>D(bivjf-rKh$z(|DVLn8A$kj}Coje5)#9KZh38+y7U9J@MoqMI zSj}v-!(AbyB@*3{(C=5d=hIhUPX4iMMu;VlLScj1|mPMLxU0TWxY_A|$O+84ngNPQhG+)V7 zG`uOv34Nf^o=PtuMRB>#DJ3FUY-ZFTS4c3D$~08Uv1*AyG-U=9(;R6TTup`S)%>XB zD&C+06~qU;KY@O26+4RdZi(1Zwu<-lL5L`-xYkVtogSTF({?;q^gDx570;2wWGBSN z>_N8EDhFDLs3ziZmXoEtp*bnV7KVn##hsF^$Mw8Fky8e0Bn$uWcWeRvV`gSI{C)@f zCc=u@OIdHOU&$8bPCeTq&E_!Z$5l!#l+;ln*2h_ARP{&gQPa^#a~O__NlT}xkcE3% z0Y@%$hjy?rw9_lH(D2kOpO6m=)=DOM&*d)G3A)a8@U4ks2|CtEb|f)JSLi+)>uX$~ zEjnH=TNiT@S&lVU(-B&a_(!Z2QPo^0M0wJ=x~~;xOu1N~5=~3%iVig>lWAYeNz46A zhs|x~8qsx~xW(^8_0Q|r>HXba+cv&SEc4663#+!VnN~`SQ#w-I?sJ1chr%t<@5{sk z0ivAm4pt4VTuT`3#v0Kv1cPYXm2TEENNTpfVDdsaURpD|csyvfM80N)a}6n7A69r@ zZPOAI@Y_7SX$i{gGZPL%+?o>%hmvbv4>QOPLUuE2)~h*hIO>dOuNclKS}s{8gJG-E z<;@C{8i{s*Ua?|)VH9M2M6@j7m8f3t$7RXZ$>E4=i}iaP9D_f(0bKP6`r_+UA49*H zb*jJX7oWNj_DFj3N%-+)u<)}x*i-OtG0?l`rGGOcZ1j>=4Vyy;A9hFxBeL&T{BPhPkPUU_7Maqa{%H|4}JsjXAC$p7;6b$!dr=WZ@M7A}xDb4vmCVpo!!QX!X zX!zl8!G6TnSIxKZ_BjxOpY*}L^_Aa&=K&&nvmkEw0uuQ6`aiFN)2Jsu5;o!OYv9$d zBZMDd^2B@KIi&XP#a#=qt~|C6uDu6VwmA60jE%mXo`7fH2h5j}c;GrXhc+IzU~blE zh`Q)L`0^as3*%i>uHQHdo`lO7dbfHN?3se0Z|}@6 Date: Mon, 17 Aug 2026 15:25:53 -0700 Subject: [PATCH 14/45] Undo reaction by tapping --- src/app/components/ReactionSummary.svelte | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/components/ReactionSummary.svelte b/src/app/components/ReactionSummary.svelte index 86090edd..2d0cc949 100644 --- a/src/app/components/ReactionSummary.svelte +++ b/src/app/components/ReactionSummary.svelte @@ -110,12 +110,11 @@ pushModal(ProfileList, {title, subtitle, pubkeys, url}) const onReactionClick = (events: TrustedEvent[], pubkeys: string[], info: string) => { - if (isMobile) { + if (isMobile && !pubkeys.includes($user.pubkey)) { showReactors(pubkeys, info.replace(" reacted", ""), "Reacted to this message") - return + } else { + toggleReaction(events) } - - toggleReaction(events) } const onZapClick = (pubkeys: string[], info: string) => { From f71cdf3f8dfd4dc9f4282a0e4fc887a98c56ad80 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 15:30:45 -0700 Subject: [PATCH 15/45] Add lnvps link --- src/routes/spaces/create/+page.svelte | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/routes/spaces/create/+page.svelte b/src/routes/spaces/create/+page.svelte index d6b49f68..c2aa3ede 100644 --- a/src/routes/spaces/create/+page.svelte +++ b/src/routes/spaces/create/+page.svelte @@ -114,6 +114,16 @@ +
    + Looking for third party managed hosting? + + Try lnvps.net + + +
    From b8ec94e5482a8163a11ab93b9b938a90739fb0e0 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 15:33:12 -0700 Subject: [PATCH 16/45] Patch requestIdleCallback for android --- src/app.html | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app.html b/src/app.html index 5124988f..b12cef26 100644 --- a/src/app.html +++ b/src/app.html @@ -25,6 +25,19 @@ + + %sveltekit.head% From 20f12a48a45e968e36af1792516e7aadbb1fc8ee Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 16:10:00 -0700 Subject: [PATCH 17/45] Fix session persistence on android --- package.json | 20 ++++++++++---------- pnpm-lock.yaml | Bin 382072 -> 380572 bytes src/app/storage.ts | 27 ++++++++++++--------------- src/routes/+page.svelte | 4 ++++ 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 57bd41ea..ff0ec991 100644 --- a/package.json +++ b/package.json @@ -76,16 +76,16 @@ "@types/throttle-debounce": "^5.0.2", "@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/sveltekit": "^1.1.0", - "@welshman/app": "^0.9.0-pre6", - "@welshman/content": "^0.9.0-pre6", - "@welshman/domain": "^0.9.0-pre6", - "@welshman/editor": "^0.9.0-pre6", - "@welshman/feeds": "^0.9.0-pre6", - "@welshman/lib": "^0.9.0-pre6", - "@welshman/net": "^0.9.0-pre6", - "@welshman/signer": "^0.9.0-pre6", - "@welshman/store": "^0.9.0-pre6", - "@welshman/util": "^0.9.0-pre6", + "@welshman/app": "^0.9.0", + "@welshman/content": "^0.9.0", + "@welshman/domain": "^0.9.0", + "@welshman/editor": "^0.9.0", + "@welshman/feeds": "^0.9.0", + "@welshman/lib": "^0.9.0", + "@welshman/net": "^0.9.0", + "@welshman/signer": "^0.9.0", + "@welshman/store": "^0.9.0", + "@welshman/util": "^0.9.0", "cheerio": "^1.2.0", "compressorjs-next": "^1.1.2", "dompurify": "^3.4.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e35138b70bee7ee47b5bd6038e8458438f330f6..d761f488b05e44be82e6fbcd65c99acab0186888 100644 GIT binary patch delta 3194 zcmd5aFqA2K(=yqOX<;MRq=I@k+BXJj z->~{*&KC|(FgfBKs^OAml3lCGZYuf2K=fMm>Ly3DI^9OJQ_D+MD_lwz(ipt(b>umC z_gi3TYfl?lTW}SIu`ZHvF{xZ+l+D%4Sz0lO=(wY3ViIF4!H@cTGMCNwh82uWmQ3F? zl+Oz>E}f5BJcf4265ff2Q_*Hh5X!h&SBeEK7|WnC@AHl4te5W@ugmqCfgE#jOW{^> zvsIQ7XuD~qVi~%b_Qxno<29?&@Ky`4J~3r9MKAGUufSUVTx!5`GCvZFBcFgp`tgK{ zb`6Ck`IJ8-qQqu99?8Pqj}}(pKNlAE!Eg7F6&I*GnY20@JHZ;x6o(PU3!=?@J%HZ6@*SIMd*TDGiaU-vW8yCi9DB!OCvqRr1h+3C}A`q-b2u( z)w=?b5@nX!&sW{(|WNoN)V$& zq@<;j1yS|Y1LUObRWo#~-eY4yU)k9ocZ2^sO9y=R2lcyj8C43^3f)fEN6;0fm}+=i znMtW#(~2^E$!F$=S<)J1 z$m*Q#8vOB1uncERJueeOWcFLwB8Gc@_NPbT+)hBcf%nJDVD+bWktg6k;=phP|KFzF zZXgF9!l;8zh`Lt06-om=>v>Re;!wJ{XU(z80~arW;v?kDI9Yt)vt_SqW4q?e=St6W z@T~!|_{FKtK<}@?kr43qHBkQI zmgOE1PF)9IgT#}wtBGFNdk~JVgM`Cj=XLr^@ci!qwuAQJ8({Jf#&Fu%tyRa~55jYY zz^$W= Sw*dO`om*h*&aJ&aS^5tr?ie}% delta 4777 zcmeHLON`sr6$NcNGj^wO#-8cKcE)y)X6!()MT#Gh1zMZ>jsAa8l(Gp$Qlv;p6ea4r zN!+3^iWEf(#(pU3pf%D@kSuB=Xb0%WPLS5wCf#(=AjmRZcGpGQRMJo**)vX6!!Qz{ zivjNN@x6QRIp^JbscWyT{o{?b8;gqv&NZ46X&Ec4wsmQ3#kSDJui4i1)#q%BKX}3V z)#B35^EHtCJ$%Ogcm6{{I|tYYgVZ13IQaOt@Gh65mHgXVhyJo)@A<)zwT*GmXC)MI zlSoN)iv&ecxFkv_MwTU!6x@$|JvliEm-cdpj3*fW@Em+@OSXC~LS#+v%ny&9|IVi; zwmv-p{WWaA#v>cKxR*^tJ7&Sbv{8(%hPn+kL@32zr0$c$ESDM>U53RR3L7R^laa%& zoKLUi2NAs>#}GOnL`j6jyiFxr)zEk>?e)9+YRNkN{bxY)q)O#kKTm8y8X}^<$;<^ zPI70$rGC{C}Y zhFq?&66Di*6lL2~0g?rRU+Yo>Z^Ua&#o0 zA@EABhx9V8rfG^rxy7~vWlSB^UAl|iY!z`0ZKGYo6EX#~VNht-+t2Enl%)DCR{$>> zP0{1ht-rte4EW%!ojNoRU{jQ?@X+yb$e?l)o>a+EtVvY#MPf-g-Q~QZhL*iWvd%>r z)tzfb{DCx8Nil|?6uK0r)uI8hPRqTv*OPCGbg_aa{8-T2NczYem5@n9kFlB|;_=3q z?3et|GWb&!I=S_d2K@`N*_ws~eq4i+Gjv5#m9Uver0&ZI;NYjt#=ua$QM!+5HIanc}MsMna1Rub}(y zH7la(j$tz6O+_n)oWO)K*Y8pBK(!ihD^#k9I!wG^W~d;~qP37wrnzR)OKEkQ4Oq)V z*(Qw%)3XFjBiT{s6D?D3O84HU9#1ljh^r*YwOp)jAaOb&;BJvG^<&zAt5&?5&1jt^ zYayKXH~G9HRKqecy<;$1DvGS1=>s*T`X9dPS<6jn3O&NaA!OV{92qb??W zDY$q6T$Z87=bR$J)z{$(><==4JZiB`=DYMP36Fn|&fZ_f7_Pm@!-`jt3cBpuei2qOgsP64qTfn#8gYogiWfSt>zRJLukc(~j z?A6$TV_-NqEo}YsCcFY~^E)&4-aW>T96h%6@^9dceYO(5G)($`2cvd&UyLifH { - let p = Promise.resolve() + const enqueue = makeQueue() const get = async (key: string): Promise => { const result = await Preferences.get({key}) @@ -58,22 +58,18 @@ export const kv = call(() => { } const set = async (key: string, value: T): Promise => { - p = p.then(() => Preferences.set({key, value: JSON.stringify(value)})) - - await p + await enqueue(() => Preferences.set({key, value: JSON.stringify(value)})) } const clear = async () => { - p = p.then(() => Preferences.clear()) - - await p + await enqueue(() => Preferences.clear()) } return {get, set, clear} }) export const ss = call(() => { - let p = Promise.resolve() + const enqueue = makeQueue() const get = async (key: string): Promise => { let value = await SecureStorage.getItem(key) @@ -97,16 +93,17 @@ export const ss = call(() => { } } + // Android's SecureStorage rejects undefined const set = async (key: string, value: T): Promise => { - p = p.then(() => SecureStorage.setItem(key, JSON.stringify(value))) - - await p + await enqueue(() => + value === undefined + ? SecureStorage.removeItem(key) + : SecureStorage.setItem(key, JSON.stringify(value)), + ) } const clear = async () => { - p = p.then(() => SecureStorage.clear()) - - await p + await enqueue(() => SecureStorage.clear()) } return {get, set, clear} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c4009076..32fc047e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,11 @@ From 45cbd1d6bad65ae69d1afd6548c6d96a844f9417 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 18 Aug 2026 09:20:30 -0700 Subject: [PATCH 18/45] Fix loading events to avoid lost provenance --- src/app/storage.ts | 92 ++++++++++++++++++++++----------------- src/routes/+layout.svelte | 23 ++++++---- 2 files changed, 67 insertions(+), 48 deletions(-) diff --git a/src/app/storage.ts b/src/app/storage.ts index fec09344..880d4e9c 100644 --- a/src/app/storage.ts +++ b/src/app/storage.ts @@ -3,7 +3,7 @@ import type {Unsubscriber} from "svelte/store" import {deleteDB} from "idb" import {SecureStorage} from "@aparajita/capacitor-secure-storage" import {Preferences} from "@capacitor/preferences" -import {noop, on, throttle, batch, call, makeQueue} from "@welshman/lib" +import {noop, on, throttle, batch, call, addToMapKey, makeQueue} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import { ALERT_ANDROID, @@ -146,11 +146,13 @@ const kinds = { ], } +// Space and room events mean nothing apart from the relay they came from — welshman keys them +// per relay, and only counts room state the relay itself signed. +const isRelayScoped = (event: TrustedEvent) => + kinds.space.includes(event.kind) || kinds.room.includes(event.kind) + const shouldPersistEvent = (event: TrustedEvent) => - kinds.meta.includes(event.kind) || - kinds.alert.includes(event.kind) || - kinds.space.includes(event.kind) || - kinds.room.includes(event.kind) + kinds.meta.includes(event.kind) || kinds.alert.includes(event.kind) || isRelayScoped(event) type TrackerItem = {id: string; relays: string[]} @@ -191,9 +193,7 @@ class Storage { private start = async () => { await this.db.connect() - const [, unsubscribeRelays] = await Promise.all([this.loadCriticalEvents(), this.initRelays()]) - - await this.loadCriticalTracker() + const [, unsubscribeRelays] = await Promise.all([this.loadCriticalData(), this.initRelays()]) this.addUnsubscriber(this.syncEvents()) this.addUnsubscriber(this.syncTracker()) @@ -224,25 +224,57 @@ class Storage { } } - private loadCriticalEvents = async () => { - const table = this.db.table("events") - const initialEvents = await table.getAll() - const keep: TrustedEvent[] = [] - const drop: string[] = [] + /** + * Events load together with the relays they came from, since a relay-scoped event that lost + * its provenance can never be keyed to a space again — it sits in the repository invisible, + * and negentropy reconciles it away as already-present, so it never comes back. Drop those + * and let the next sync pull them down again. + */ + private loadCriticalData = async () => { + const eventsTable = this.db.table("events") + const trackerTable = this.db.table("tracker") + const [storedEvents, storedItems] = await Promise.all([ + eventsTable.getAll(), + trackerTable.getAll(), + ]) - for (const event of initialEvents) { - if (shouldPersistEvent(event)) { + const relaysById = new Map(storedItems.map(({id, relays}) => [id, new Set(relays)])) + const events: TrustedEvent[] = [] + const staleEvents: string[] = [] + + for (const event of storedEvents) { + if (shouldPersistEvent(event) && (!isRelayScoped(event) || relaysById.has(event.id))) { event[verifiedSymbol] = true - keep.push(event) + events.push(event) } else { - drop.push(event.id) + staleEvents.push(event.id) } } - this.app.repository.load(keep) + for (const [id, relays] of this.app.tracker.relaysById) { + for (const relay of relays) { + addToMapKey(relaysById, id, relay) + } + } - if (drop.length > 0) { - void table.bulkDelete(drop) + this.app.repository.load([...this.app.repository.dump(), ...events]) + + const staleItems = storedItems + .map(item => item.id) + .filter(id => !this.app.repository.getEvent(id)) + + for (const id of staleItems) { + relaysById.delete(id) + } + + this.app.tracker.load(relaysById) + + if (staleEvents.length > 0) { + void eventsTable.bulkDelete(staleEvents) + } + + if (staleItems.length > 0) { + void trackerTable.bulkDelete(staleItems) } } @@ -296,26 +328,6 @@ class Storage { ) } - private loadCriticalTracker = async () => { - const table = this.db.table("tracker") - const relaysById = new Map>() - const stale: string[] = [] - - for (const {id, relays} of await table.getAll()) { - if (this.app.repository.getEvent(id)) { - relaysById.set(id, new Set(relays)) - } else { - stale.push(id) - } - } - - this.app.tracker.load(relaysById) - - if (stale.length > 0) { - void table.bulkDelete(stale) - } - } - private syncTracker = () => { const table = this.db.table("tracker") diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ce1f50fe..b96dd6c9 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -14,14 +14,13 @@ import {context as pomadeContext} from "@pomade/core" import {sync, throttled} from "@welshman/store" import {setNip55Plugin} from "@welshman/signer" - import * as domain from "@welshman/domain" import * as util from "@welshman/util" import * as lib from "@welshman/lib" import {Logger} from "@welshman/app" import {isMobile, documentActive} from "@lib/html" import AppContainer from "@app/components/AppContainer.svelte" import ModalContainer from "@app/components/ModalContainer.svelte" - import {app} from "@app/core" + import * as core from "@app/core" import {setupHistory} from "@app/routes" import {setupAnalytics} from "@app/analytics" import {setupLogging} from "@app/logger" @@ -65,7 +64,7 @@ ) // Add stuff to window for convenience - Object.assign(window, {get, nip19, theme, app, domain, Logger, ...lib, ...util}) + Object.assign(window, {get, nip19, theme, Logger, ...lib, ...util, ...core}) // Set up context for various modules pomadeContext.setSignerUrls(env.POMADE_SIGNERS) @@ -214,14 +213,21 @@ unsubscribers.push(Push.sync()) // Logging in swaps in a fresh app — its policies rebind themselves, we just have to sync - // application data against the new identity's relays. - let currentApp = app.get() + // application data against the new identity's relays. Loading the new app's storage + // replaces its repository and tracker wholesale, so wait for it the way startup does. + let currentApp = core.app.get() + + const resync = async () => { + await storage.get()?.ready + + syncApplicationData() + } unsubscribers.push( - app.subscribe($app => { + core.app.subscribe($app => { if ($app !== currentApp) { currentApp = $app - syncApplicationData() + resync() } }), ) @@ -282,7 +288,8 @@ }) $effect(() => { - const title = getPageTitle({page: $page, pubkey: $app.user?.pubkey}) + const {user} = core.app.get() + const title = getPageTitle({page: $page, pubkey: user?.pubkey}) // While the tab isn't actively focused the user isn't actually looking at the // active page, so count notifications for it too rather than treating it as read. const paths = $documentActive ? $notificationPaths : $allNotifications From 8a0918d5517347ede744b09f6091ffae3c87cd07 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 18 Aug 2026 11:32:39 -0700 Subject: [PATCH 19/45] Merge tracker/events store into a single indexeddb store --- src/app/storage.ts | 132 +++++++++++++++----------------------- src/lib/indexeddb.ts | 12 +++- src/routes/+layout.svelte | 4 +- 3 files changed, 64 insertions(+), 84 deletions(-) diff --git a/src/app/storage.ts b/src/app/storage.ts index 880d4e9c..6d137bfc 100644 --- a/src/app/storage.ts +++ b/src/app/storage.ts @@ -3,7 +3,7 @@ import type {Unsubscriber} from "svelte/store" import {deleteDB} from "idb" import {SecureStorage} from "@aparajita/capacitor-secure-storage" import {Preferences} from "@capacitor/preferences" -import {noop, on, throttle, batch, call, addToMapKey, makeQueue} from "@welshman/lib" +import {noop, on, throttle, batch, call, makeQueue} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import { ALERT_ANDROID, @@ -111,7 +111,6 @@ export const ss = call(() => { const TABLES = [ {name: "events", keyPath: "id"}, - {name: "tracker", keyPath: "id"}, {name: "relays", keyPath: "url"}, {name: "relayStats", keyPath: "url"}, {name: "handles", keyPath: "nip05"}, @@ -154,7 +153,7 @@ const isRelayScoped = (event: TrustedEvent) => const shouldPersistEvent = (event: TrustedEvent) => kinds.meta.includes(event.kind) || kinds.alert.includes(event.kind) || isRelayScoped(event) -type TrackerItem = {id: string; relays: string[]} +type EventItem = {id: string; event: TrustedEvent; relays: string[]} type PlaintextItem = {key: string; value: string} @@ -225,62 +224,53 @@ class Storage { } /** - * Events load together with the relays they came from, since a relay-scoped event that lost - * its provenance can never be keyed to a space again — it sits in the repository invisible, - * and negentropy reconciles it away as already-present, so it never comes back. Drop those - * and let the next sync pull them down again. + * Each row holds an event together with the relays it came from, since a relay-scoped event + * that lost its provenance can never be keyed to a space again — it sits in the repository + * invisible, and negentropy reconciles it away as already-present, so it never comes back. + * Drop those and let the next sync pull them down again. */ private loadCriticalData = async () => { - const eventsTable = this.db.table("events") - const trackerTable = this.db.table("tracker") - const [storedEvents, storedItems] = await Promise.all([ - eventsTable.getAll(), - trackerTable.getAll(), - ]) + const table = this.db.table("events") + const items: EventItem[] = [] + const stale: string[] = [] - const relaysById = new Map(storedItems.map(({id, relays}) => [id, new Set(relays)])) - const events: TrustedEvent[] = [] - const staleEvents: string[] = [] - - for (const event of storedEvents) { - if (shouldPersistEvent(event) && (!isRelayScoped(event) || relaysById.has(event.id))) { - event[verifiedSymbol] = true - events.push(event) + for (const item of await table.getAll()) { + // Rows written before provenance was stored inline hold a bare event + if ( + item.event && + shouldPersistEvent(item.event) && + (!isRelayScoped(item.event) || item.relays.length > 0) + ) { + item.event[verifiedSymbol] = true + items.push(item) } else { - staleEvents.push(event.id) + stale.push(item.id) } } - for (const [id, relays] of this.app.tracker.relaysById) { - for (const relay of relays) { - addToMapKey(relaysById, id, relay) + this.app.repository.load(items.map(item => item.event)) + + const relaysById = new Map>() + + for (const {id, relays} of items) { + // Anything the repository rejected was superseded by an event that arrived before we + // finished loading + if (this.app.repository.getEvent(id)) { + relaysById.set(id, new Set(relays)) + } else { + stale.push(id) } } - this.app.repository.load([...this.app.repository.dump(), ...events]) - - const staleItems = storedItems - .map(item => item.id) - .filter(id => !this.app.repository.getEvent(id)) - - for (const id of staleItems) { - relaysById.delete(id) - } - this.app.tracker.load(relaysById) - if (staleEvents.length > 0) { - void eventsTable.bulkDelete(staleEvents) - } - - if (staleItems.length > 0) { - void trackerTable.bulkDelete(staleItems) + if (stale.length > 0) { + void table.bulkDelete(stale) } } private syncEvents = () => { - const eventsTable = this.db.table("events") - const trackerTable = this.db.table("tracker") + const table = this.db.table("events") return on( this.app.repository, @@ -304,73 +294,55 @@ class Storage { if (add.length > 0) { // The ingest policy tracks an event before publishing it, so by the time the - // repository reports it, its provenance is already in the tracker — persist the - // two together so an event is never saved without the relays it came from. - const items: TrackerItem[] = [] - - for (const {id} of add) { - const relays = Array.from(this.app.tracker.getRelays(id)) - - if (relays.length > 0) { - items.push({id, relays}) - } - } - - await eventsTable.bulkPut(add) - await trackerTable.bulkPut(items) + // repository reports it, its provenance is already in the tracker + await table.bulkPut( + add.map(event => ({ + id: event.id, + event, + relays: Array.from(this.app.tracker.getRelays(event.id)), + })), + ) } if (remove.size > 0) { - await eventsTable.bulkDelete(remove) - await trackerTable.bulkDelete(remove) + await table.bulkDelete(remove) } }), ) } private syncTracker = () => { - const table = this.db.table("tracker") + const table = this.db.table("events") - const _onAdd = async (ids: Iterable) => { - const items: TrackerItem[] = [] + const _onChange = async (ids: Iterable) => { + const items: EventItem[] = [] for (const id of ids) { const event = this.app.repository.getEvent(id) // A brand-new event is tracked before it's published, so it isn't queryable here — // syncEvents persists its provenance along with the event itself. This pass only - // records additional relays for events we already have. - if (!event || !shouldPersistEvent(event)) continue - - const relays = Array.from(this.app.tracker.getRelays(id)) - - if (relays.length === 0) continue - - items.push({id, relays}) + // records relay changes for events we already have. + if (event && shouldPersistEvent(event)) { + items.push({id, event, relays: Array.from(this.app.tracker.getRelays(id))}) + } } await table.bulkPut(items) } - const _onRemove = async (ids: Iterable) => { - await table.bulkDelete(Array.from(ids)) - } - - const onAdd = batch(3000, _onAdd) - const onRemove = batch(3000, _onRemove) - const onLoad = () => _onAdd(this.app.tracker.relaysById.keys()) - const onClear = () => _onRemove(this.app.tracker.relaysById.keys()) + const onAdd = batch(3000, _onChange) + const onRemove = batch(3000, _onChange) + const onLoad = () => _onChange(this.app.tracker.relaysById.keys()) this.app.tracker.on("add", onAdd) this.app.tracker.on("remove", onRemove) this.app.tracker.on("load", onLoad) - this.app.tracker.on("clear", onClear) return () => { this.app.tracker.off("add", onAdd) this.app.tracker.off("remove", onRemove) this.app.tracker.off("load", onLoad) - this.app.tracker.off("clear", onClear) } } diff --git a/src/lib/indexeddb.ts b/src/lib/indexeddb.ts index 03f60e4e..60898b6a 100644 --- a/src/lib/indexeddb.ts +++ b/src/lib/indexeddb.ts @@ -1,5 +1,6 @@ import {openDB, deleteDB} from "idb" import type {IDBPDatabase} from "idb" +import {spec} from "@welshman/lib" import type {Maybe} from "@welshman/lib" export type IDBStore = { @@ -19,14 +20,17 @@ export class IDB { constructor(readonly options: IDBOptions) {} // Object stores can only be created during a version change, and which stores we need depends - // on who is logged in, so open at whatever version exists and bump it to add missing ones. + // on who is logged in, so open at whatever version exists and bump it to reconcile the schema. private open = async () => { const {name, stores} = this.options const blocking = () => this.close() const db = await openDB(name, undefined, {blocking}) const missing = stores.filter(store => !db.objectStoreNames.contains(store.name)) + const obsolete = Array.from(db.objectStoreNames).filter( + storeName => !stores.some(spec({name: storeName})), + ) - if (missing.length === 0) { + if (missing.length === 0 && obsolete.length === 0) { return db } @@ -39,6 +43,10 @@ export class IDB { for (const {name, keyPath} of missing) { idbDb.createObjectStore(name, {keyPath}) } + + for (const storeName of obsolete) { + idbDb.deleteObjectStore(storeName) + } }, blocked: (currentVersion, blockedVersion) => console.error( diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b96dd6c9..417e0b8c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -213,8 +213,8 @@ unsubscribers.push(Push.sync()) // Logging in swaps in a fresh app — its policies rebind themselves, we just have to sync - // application data against the new identity's relays. Loading the new app's storage - // replaces its repository and tracker wholesale, so wait for it the way startup does. + // application data against the new identity's relays. Wait for the new app's storage to + // load the way startup does, so sync reconciles against cached events. let currentApp = core.app.get() const resync = async () => { From 0a9d3cfb49ae97a7ae181c21014b1c9d9ce7f9d3 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 18 Aug 2026 12:55:08 -0700 Subject: [PATCH 20/45] Use nip 86 for claim management --- src/app/access.ts | 68 +++++++++++++++++++++++++------------------ src/app/management.ts | 3 -- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/app/access.ts b/src/app/access.ts index 361e1b23..0b0b371f 100644 --- a/src/app/access.ts +++ b/src/app/access.ts @@ -10,13 +10,13 @@ import { FOLLOWS, MESSAGING_RELAYS, PROFILE, - RELAY_INVITE, RELAYS, + type ManagementResponse, } from "@welshman/util" -import {RelayInvite, RelayJoin, RelayLeave, RoomJoin} from "@welshman/domain" +import {RelayJoin, RelayLeave, RoomJoin} from "@welshman/domain" import {Sync, User, publish} from "@welshman/app" import {stripPrefix} from "@lib/util" -import {app, command, network, reader, roomLists, thunks, writer} from "@app/core" +import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core" import {PLATFORM_URL} from "@app/env" import {relaysMostlyRestricted} from "@app/policies" import {Push} from "@app/push" @@ -347,34 +347,44 @@ export class Access { async prepareInvite(h?: string) { this.loading.set(true) - // A request that times out or hits a closed socket resolves with no events, so an - // empty result is the only signal we get that the relay never answered. - const [events, roomInviteResult] = await Promise.all([ - network.get().request({ - relays: [this.url], - autoClose: true, - signal: AbortSignal.timeout(10000), - filters: [{kinds: [RELAY_INVITE]}], - }), - h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}), - // Keep the spinner up long enough that a fast relay doesn't make it flash - sleep(300), - ]) + try { + const management = relayManagement.get().forUrl(this.url) - if (events[0]) { - const eventReader = reader(RelayInvite) - const invite = await eventReader(events[0]) + const [{result: methods}, roomInviteResult] = await Promise.all([ + management.supportedMethods().catch((error): ManagementResponse => { + console.error(error) + return {} + }), + h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}), + // Keep the spinner up long enough that a fast relay doesn't make it flash + sleep(300), + ]) - this.claim.set(invite.claim() ?? "") + // The relay reports methods relay-wide rather than per-user, so a listed method can + // still come back "blocked" for this particular user — treat that as having no claim. + if (methods?.includes("createclaim")) { + const {result: claims} = await management.listClaims() + + if (claims?.[0]) { + this.claim.set(claims[0]) + } else { + const claim = randomId() + const {error} = await management.createClaim(claim) + + if (error) { + this.claimFailed.set(true) + } else { + this.claim.set(claim) + } + } + } + + if (h) { + this.roomCode.set(roomInviteResult.code) + this.roomInviteError.set(roomInviteResult.error) + } + } finally { + this.loading.set(false) } - - this.claimFailed.set(events.length === 0) - - if (h) { - this.roomCode.set(roomInviteResult.code) - this.roomInviteError.set(roomInviteResult.error) - } - - this.loading.set(false) } } diff --git a/src/app/management.ts b/src/app/management.ts index a63c0c62..232685ec 100644 --- a/src/app/management.ts +++ b/src/app/management.ts @@ -21,9 +21,6 @@ export const deriveSpaceBannedPubkeyItems = (url: string) => { return store } -// A relay only answers NIP-86 for its admins, so the methods it reports double as the user's -// permissions. This costs a signature and an http round trip, so share one store per url and -// only re-check when the answer has had time to go stale (or when a check failed). export const deriveSpaceSupportedMethods = simpleCache(([url]: [string | undefined]) => { let checkedAt = 0 From ce4ecedf7e1e12ac1c8440e2a7a0e3190e41f538 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 18 Aug 2026 14:44:43 -0700 Subject: [PATCH 21/45] De-emphasize link meta --- src/app/components/PinForm.svelte | 83 ++++++++++++++++--------------- 1 file changed, 44 insertions(+), 39 deletions(-) diff --git a/src/app/components/PinForm.svelte b/src/app/components/PinForm.svelte index 1ae65e64..4896b363 100644 --- a/src/app/components/PinForm.svelte +++ b/src/app/components/PinForm.svelte @@ -9,6 +9,7 @@ Written by - {#if h} - in - {/if} - + diff --git a/src/app/components/CalendarEventActions.svelte b/src/app/components/CalendarEventActions.svelte index 5d429b83..5199fbc5 100644 --- a/src/app/components/CalendarEventActions.svelte +++ b/src/app/components/CalendarEventActions.svelte @@ -41,21 +41,22 @@ Posted in # {/if} - - - {#if showActivity} - - {/if} - - {#snippet customActions()} - {#if event.pubkey === $user.pubkey} -
  • - -
  • - {/if} - {/snippet} - + + + {#if showActivity} + + {/if} + + {#snippet customActions()} + {#if event.pubkey === $user.pubkey} +
  • + +
  • + {/if} + {/snippet} +
    +
    diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index 94ae8fb6..ea5462f7 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -179,7 +179,7 @@ eventToEdit = event } - const onEditPrevious = () => ifLet($chat?.messages.toReversed().find(canEditEvent), onEditEvent) + const onEditPrevious = () => ifLet($chat?.messages.find(canEditEvent), onEditEvent) let loading = $state(true) let compose: ChatCompose | undefined = $state() @@ -300,7 +300,7 @@ {#each missingRelayLists as pubkey (pubkey)} {/each} - to enable direct messaging by opening this conversation in their $app. + to enable direct messaging by opening this conversation in their client.

    diff --git a/src/app/components/ClassifiedActions.svelte b/src/app/components/ClassifiedActions.svelte index b33a7ac4..e20cb30a 100644 --- a/src/app/components/ClassifiedActions.svelte +++ b/src/app/components/ClassifiedActions.svelte @@ -55,23 +55,25 @@ {/each} - - - - {#if showActivity} - - {/if} - - {#snippet customActions()} - {#if event.pubkey === $user.pubkey} -
  • - -
  • - {/if} + {#snippet status()} + {/snippet} -
    + + {#if showActivity} + + {/if} + + {#snippet customActions()} + {#if event.pubkey === $user.pubkey} +
  • + +
  • + {/if} + {/snippet} +
    + diff --git a/src/app/components/CommentActions.svelte b/src/app/components/CommentActions.svelte index b675888f..90fc1589 100644 --- a/src/app/components/CommentActions.svelte +++ b/src/app/components/CommentActions.svelte @@ -28,11 +28,12 @@
    - - - {#if showActivity && path} - - {/if} - + + + {#if showActivity && path} + + {/if} + +
    diff --git a/src/app/components/Content.svelte b/src/app/components/Content.svelte index 2db4660b..cd673585 100644 --- a/src/app/components/Content.svelte +++ b/src/app/components/Content.svelte @@ -13,6 +13,7 @@ isInvoice, isLink, isProfile, + isRoom, isEvent, isEllipsis, isAddress, @@ -35,7 +36,9 @@ import ContentQuote from "@app/components/ContentQuote.svelte" import ContentTopic from "@app/components/ContentTopic.svelte" import ContentMention from "@app/components/ContentMention.svelte" + import RoomName from "@app/components/RoomName.svelte" import {entityLink} from "@app/env" + import {makeRoomPath} from "@app/routes" import {userSettingsValues} from "@app/settings" type Props = { @@ -182,6 +185,10 @@ {/if} {:else if isProfile(parsed)} + {:else if isRoom(parsed)} + + # + {:else if isQuote(parsed)} {#if isBlock(i)} diff --git a/src/app/components/GoalActions.svelte b/src/app/components/GoalActions.svelte index 315ae032..67b2deec 100644 --- a/src/app/components/GoalActions.svelte +++ b/src/app/components/GoalActions.svelte @@ -36,10 +36,11 @@ Posted in # {/if} - - - {#if showActivity} - - {/if} - + + + {#if showActivity} + + {/if} + + diff --git a/src/app/components/PollActions.svelte b/src/app/components/PollActions.svelte index c2458912..6b258fb2 100644 --- a/src/app/components/PollActions.svelte +++ b/src/app/components/PollActions.svelte @@ -33,10 +33,11 @@ Posted in # {/if} - - - {#if showActivity} - - {/if} - + + + {#if showActivity} + + {/if} + + diff --git a/src/app/components/ProfileTrust.svelte b/src/app/components/ProfileTrust.svelte index 308ebcef..02b23e2a 100644 --- a/src/app/components/ProfileTrust.svelte +++ b/src/app/components/ProfileTrust.svelte @@ -63,7 +63,8 @@

    {$followers.length} - {$followers.length === 1 ? "person" : "people"} you follow also follow + {$followers.length === 1 ? "person" : "people"} you follow also + {$followers.length === 1 ? "follows" : "follow"} {$profileDisplay}.

    diff --git a/src/app/components/RelayAdd.svelte b/src/app/components/RelayAdd.svelte index 26a47714..d42b5733 100644 --- a/src/app/components/RelayAdd.svelte +++ b/src/app/components/RelayAdd.svelte @@ -18,7 +18,7 @@ import ModalFooter from "@lib/components/ModalFooter.svelte" import RelayItem from "@app/components/RelayItem.svelte" import {pushToast} from "@app/toast" - import {app} from "@app/core" + import {app, blockedRelayLists, user} from "@app/core" interface Props { relays: Readable @@ -56,6 +56,7 @@ const loading = $state(new SvelteSet()) const relaySearch = $app.use(Relays).relaySearch + const blockedRelayUrls = $blockedRelayLists.urls($user.pubkey).$ const searchResults = $derived( $relaySearch @@ -64,6 +65,7 @@ if (matchRelay?.(url) === false) return false if ($relays.includes(url)) return false if (isIPAddress(url)) return false + if ($blockedRelayUrls.includes(url)) return false return true }) diff --git a/src/app/components/ReportMenuList.svelte b/src/app/components/ReportMenuList.svelte index 994f00fe..9e316812 100644 --- a/src/app/components/ReportMenuList.svelte +++ b/src/app/components/ReportMenuList.svelte @@ -41,7 +41,7 @@ if (error) { pushToast({theme: "error", message: error}) } else { - pushToast({message: "Content has successfully been deleted!"}) + pushToast({message: "Report has successfully been dismissed!"}) $app.repository.removeEvent(event.id) onResolved?.() } diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index 70bada97..a7c1b869 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -10,7 +10,7 @@ import type {Maybe} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util" import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util" - import {MembershipStatus, publish} from "@welshman/app" + import {MembershipStatus} from "@welshman/app" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import ClockCircle from "@assets/icons/clock-circle.svg?dataurl" import Login2 from "@assets/icons/login-3.svg?dataurl" @@ -31,7 +31,7 @@ import VideoCallContent from "@app/components/VideoCallContent.svelte" import CallControlBar from "@app/components/CallControlBar.svelte" import {deletes, relays, rooms, thunks, user} from "@app/core" - import {publishRoomJoinRequest} from "@app/access" + import {joinRoom, leaveRoom} from "@app/access" import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call" import { PROTECTED, @@ -130,18 +130,14 @@ joining = true try { - const thunk = await (inviteCode - ? publishRoomJoinRequest(url, h, inviteCode) - : $rooms.joinRoom(url, {h}).then(publish)) + const message = await joinRoom(url, h, inviteCode) - const message = await thunk.waitForError() - - if (message && !message.startsWith("duplicate:")) { - return pushToast({theme: "error", message}) + if (message) { + pushToast({theme: "error", message}) + } else { + // Restart the feed now that we're a member + start() } - - // Restart the feed now that we're a member - start() } finally { joining = false } @@ -151,12 +147,14 @@ const leave = async () => { if (h) { leaving = true - try { - const thunk = await $rooms.leaveRoom(url, {h}).then(publish) - const message = await thunk.waitForError() - if (message && !message.startsWith("duplicate:")) { + try { + const message = await leaveRoom(url, h) + + if (message) { pushToast({theme: "error", message}) + } else { + pushToast({message: "You have left the room."}) } } finally { leaving = false diff --git a/src/app/components/RoomCompose.svelte b/src/app/components/RoomCompose.svelte index cfaf8587..69ffcc78 100644 --- a/src/app/components/RoomCompose.svelte +++ b/src/app/components/RoomCompose.svelte @@ -28,7 +28,8 @@ const {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props() - const draftKey = url || h ? new DraftKey(`room:${url ?? ""}:${h ?? ""}`) : undefined + const draftKey = + (url || h) && !initialValues ? new DraftKey(`room:${url ?? ""}:${h ?? ""}`) : undefined const autofocus = !isMobile diff --git a/src/app/components/RoomDetailMenu.svelte b/src/app/components/RoomDetailMenu.svelte index b6224281..95146430 100644 --- a/src/app/components/RoomDetailMenu.svelte +++ b/src/app/components/RoomDetailMenu.svelte @@ -1,7 +1,7 @@ diff --git a/src/app/components/ThunkStatusOrDeleted.svelte b/src/app/components/ThunkStatusOrDeleted.svelte index cd4d8787..3efb266c 100644 --- a/src/app/components/ThunkStatusOrDeleted.svelte +++ b/src/app/components/ThunkStatusOrDeleted.svelte @@ -8,10 +8,11 @@ type Props = { event: TrustedEvent + status?: Snippet children?: Snippet } - const {event, children}: Props = $props() + const {event, status, children}: Props = $props() const deleted = deriveIsDeleted(event) const history = $thunks.history @@ -20,8 +21,11 @@ {#if $deleted}
    Deleted
    -{:else if thunk.thunks.length > 0 && !thunk.hasStatus(PublishStatus.Success)} - -{:else if children} +{:else} + {#if thunk.thunks.length > 0 && !thunk.hasStatus(PublishStatus.Success)} + + {:else} + {@render status?.()} + {/if} {@render children?.()} {/if} diff --git a/src/app/management.ts b/src/app/management.ts index 232685ec..aa35c387 100644 --- a/src/app/management.ts +++ b/src/app/management.ts @@ -43,6 +43,8 @@ export const deriveSpaceSupportedMethods = simpleCache(([url]: [string | undefin // User +// A relay answers supportedmethods with everything it implements rather than with what the +// caller may use, so all this can tell us is that the call wasn't refused outright. export const deriveUserIsSpaceAdmin = (url?: string) => derived(deriveSpaceSupportedMethods(url), $methods => $methods.length > 0) diff --git a/src/app/policies.ts b/src/app/policies.ts index 1f29e2c0..30963328 100644 --- a/src/app/policies.ts +++ b/src/app/policies.ts @@ -122,12 +122,12 @@ const trustPolicy = (socket: Socket) => { const mostlyRestrictedPolicy = (socket: Socket) => { let total = 0 - let restricted = 0 + let refused = 0 const pending = new Set() const updateStatus = (error?: string) => { - if (total > 5 && restricted > total / 2) { + if (total > 5 && refused > total / 2) { if (error) { return relaysMostlyRestricted.update(assoc(socket.url, error)) } @@ -136,6 +136,19 @@ const mostlyRestrictedPolicy = (socket: Socket) => { } } + // NIP-01 reserves "blocked: " for a ban and "restricted: " for lacking permission. + const countDetails = (details: string) => { + if (details.startsWith("auth-required: ")) { + total-- + updateStatus() + } + + if (details.startsWith("restricted: ") || details.startsWith("blocked: ")) { + refused++ + updateStatus(details) + } + } + const unsubscribers = [ on(socket, SocketEvent.Receive, (message: RelayMessage) => { if (isRelayOk(message)) { @@ -145,15 +158,7 @@ const mostlyRestrictedPolicy = (socket: Socket) => { pending.delete(id) if (!ok) { - if (details.startsWith("auth-required: ")) { - total-- - updateStatus() - } - - if (details.startsWith("restricted: ")) { - restricted++ - updateStatus(details) - } + countDetails(details) } } } @@ -163,16 +168,7 @@ const mostlyRestrictedPolicy = (socket: Socket) => { if (pending.has(id)) { pending.delete(id) - - if (details.startsWith("auth-required: ")) { - total-- - updateStatus() - } - - if (details.startsWith("restricted: ")) { - restricted++ - updateStatus(details) - } + countDetails(details) } } }), diff --git a/src/routes/settings/hosting/+page.svelte b/src/routes/settings/hosting/+page.svelte index 29d34450..c5113ac9 100644 --- a/src/routes/settings/hosting/+page.svelte +++ b/src/routes/settings/hosting/+page.svelte @@ -2,7 +2,7 @@ import {onMount} from "svelte" import {App} from "@capacitor/app" import type {Maybe} from "@welshman/lib" - import {indexBy, spec} from "@welshman/lib" + import {indexBy, sortBy, spec} from "@welshman/lib" import Add from "@assets/icons/add.svg?dataurl" import Server from "@assets/icons/server.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl" @@ -107,7 +107,7 @@ const [t, inv, rel, draft] = results if (t.status === "fulfilled") tenant = t.value - if (inv.status === "fulfilled") invoices = inv.value + if (inv.status === "fulfilled") invoices = sortBy(invoice => -invoice.created_at, inv.value) if (rel.status === "fulfilled") relays = rel.value if (draft.status === "fulfilled") draftInvoice = draft.value diff --git a/src/routes/settings/wallet/+page.svelte b/src/routes/settings/wallet/+page.svelte index 23bdac7d..e55df318 100644 --- a/src/routes/settings/wallet/+page.svelte +++ b/src/routes/settings/wallet/+page.svelte @@ -283,7 +283,6 @@ diff --git a/src/routes/spaces/+page.svelte b/src/routes/spaces/+page.svelte index 6d787bea..f4b25706 100644 --- a/src/routes/spaces/+page.svelte +++ b/src/routes/spaces/+page.svelte @@ -300,7 +300,7 @@

    You haven't joined any spaces yet.

    {/if} {#if otherSpaces.length > 0} - {filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"} + Browse Spaces {/if} {#each otherSpaces.slice(0, limit) as relay (relay.url)} {/if} - {#each groupedZaps.entries() as [key, zaps]} + {#each groupedZaps.entries() as [key, zaps] (key)} {@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))} {@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))} {@const isOwn = pubkeys.includes($user.pubkey)} @@ -217,7 +217,7 @@ {amount} {/each} - {#each groupedReactions.entries() as [key, events]} + {#each groupedReactions.entries() as [key, events] (key)} {@const pubkeys = events.map(e => e.pubkey)} {@const isOwn = pubkeys.includes($user.pubkey)} {@const info = displayList( diff --git a/src/app/components/RelayAdd.svelte b/src/app/components/RelayAdd.svelte index d42b5733..aeb0604f 100644 --- a/src/app/components/RelayAdd.svelte +++ b/src/app/components/RelayAdd.svelte @@ -54,7 +54,7 @@ let limit = $state(20) let element: Element | undefined = $state() - const loading = $state(new SvelteSet()) + const loading = new SvelteSet() const relaySearch = $app.use(Relays).relaySearch const blockedRelayUrls = $blockedRelayLists.urls($user.pubkey).$ diff --git a/src/app/components/RelayList.svelte b/src/app/components/RelayList.svelte index 59e7dfc6..ae24edc1 100644 --- a/src/app/components/RelayList.svelte +++ b/src/app/components/RelayList.svelte @@ -51,7 +51,7 @@ } } - const loading = $state(new SvelteSet()) + const loading = new SvelteSet() diff --git a/src/app/components/RelaySettingsHealthChecks.svelte b/src/app/components/RelaySettingsHealthChecks.svelte index 5e2e2e3c..d389f06f 100644 --- a/src/app/components/RelaySettingsHealthChecks.svelte +++ b/src/app/components/RelaySettingsHealthChecks.svelte @@ -33,7 +33,7 @@ {PLATFORM_NAME} actively checks your connection to the network in the background to discover relays that are offline, that you don't have access to, or are otherwise causing trouble.

    - {#each $pending as healthCheck} + {#each $pending as healthCheck (healthCheck.title)} {/each} {#if $pending.length > 0} diff --git a/src/app/components/RoomInvite.svelte b/src/app/components/RoomInvite.svelte index 4afee99f..07dcbfc7 100644 --- a/src/app/components/RoomInvite.svelte +++ b/src/app/components/RoomInvite.svelte @@ -46,11 +46,7 @@ } let canShare = $state(false) - let invite = $state("") - - $effect(() => { - invite = makeInviteLink({url, claim: $claim, h, code: $roomCode}) - }) + let invite = $derived(makeInviteLink({url, claim: $claim, h, code: $roomCode})) onMount(async () => { try { diff --git a/src/app/components/RoomItemAddMember.svelte b/src/app/components/RoomItemAddMember.svelte index aaf3f159..75e5bf21 100644 --- a/src/app/components/RoomItemAddMember.svelte +++ b/src/app/components/RoomItemAddMember.svelte @@ -11,7 +11,7 @@ const {url, event}: Props = $props() -{#each tagValues(hexTags("p"), event.tags) as pubkey} +{#each tagValues(hexTags("p"), event.tags) as pubkey (pubkey)}
    joined the room
    diff --git a/src/app/components/Share.svelte b/src/app/components/Share.svelte index 1e8d58ae..61cdd4ce 100644 --- a/src/app/components/Share.svelte +++ b/src/app/components/Share.svelte @@ -1,5 +1,4 @@
    diff --git a/src/lib/components/InputList.svelte b/src/lib/components/InputList.svelte index d17510bc..6f3f45a6 100644 --- a/src/lib/components/InputList.svelte +++ b/src/lib/components/InputList.svelte @@ -55,6 +55,7 @@
    + {#each value as item, index}
    - {#each cameras as camera} + {#each cameras as camera (camera.id)} {/each} diff --git a/src/routes/settings/wallet/+page.svelte b/src/routes/settings/wallet/+page.svelte index e55df318..60bb2214 100644 --- a/src/routes/settings/wallet/+page.svelte +++ b/src/routes/settings/wallet/+page.svelte @@ -269,6 +269,7 @@

    Preset amounts shown when sending a zap.

    + {#each zapAmountDraft as amount, index}
    diff --git a/src/lib/components/Link.svelte b/src/lib/components/Link.svelte index feee991c..a6b7d0cd 100644 --- a/src/lib/components/Link.svelte +++ b/src/lib/components/Link.svelte @@ -11,7 +11,7 @@ replaceState = false, ...restProps }: { - children: Snippet + children?: Snippet href: string external?: boolean replaceState?: boolean @@ -19,6 +19,7 @@ class?: string style?: string "data-tip"?: string + "aria-label"?: string } = $props() const go = (e: Event) => { From 99be43f396331f08c720984591c05585d157f299 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Sat, 22 Aug 2026 11:11:57 -0700 Subject: [PATCH 41/45] Fix pagination collapse --- src/app/components/ThreadPagination.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/components/ThreadPagination.svelte b/src/app/components/ThreadPagination.svelte index d571993c..a091084d 100644 --- a/src/app/components/ThreadPagination.svelte +++ b/src/app/components/ThreadPagination.svelte @@ -37,7 +37,8 @@ }) -
    +
    +

    Page {page} of {pageCount}

    {#if pageCount > 1} - + {/if} {#if showReply && replyTo && $event} Date: Sat, 22 Aug 2026 11:30:30 -0700 Subject: [PATCH 43/45] Make some event rendering components reactive --- src/app/components/CalendarEventDate.svelte | 4 ++-- src/app/components/CalendarEventHeader.svelte | 6 +++--- src/app/components/CalendarEventMeta.svelte | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/components/CalendarEventDate.svelte b/src/app/components/CalendarEventDate.svelte index 7e3a6a99..2cf63167 100644 --- a/src/app/components/CalendarEventDate.svelte +++ b/src/app/components/CalendarEventDate.svelte @@ -10,9 +10,9 @@ const {event}: Props = $props() - const timeEvent = reader(TimeEvent)(event) + const timeEvent = $derived(reader(TimeEvent)(event)) - const start = timeEvent.start() + const start = $derived(timeEvent.start()) {#if start} diff --git a/src/app/components/CalendarEventHeader.svelte b/src/app/components/CalendarEventHeader.svelte index 9d5b5208..ce61ee43 100644 --- a/src/app/components/CalendarEventHeader.svelte +++ b/src/app/components/CalendarEventHeader.svelte @@ -13,12 +13,12 @@ const {event}: Props = $props() - const timeEvent = reader(TimeEvent)(event) + const timeEvent = $derived(reader(TimeEvent)(event)) // NIP-52 settled on `title`, but events from before that still carry `name`. const title = $derived(timeEvent.title() ?? tagValue(tagSpec("name"), event.tags)) - const start = timeEvent.start() - const end = timeEvent.end() + const start = $derived(timeEvent.start()) + const end = $derived(timeEvent.end())
    diff --git a/src/app/components/CalendarEventMeta.svelte b/src/app/components/CalendarEventMeta.svelte index afa315cc..7a84ed4c 100644 --- a/src/app/components/CalendarEventMeta.svelte +++ b/src/app/components/CalendarEventMeta.svelte @@ -14,9 +14,9 @@ const {event, url}: Props = $props() - const timeEvent = reader(TimeEvent)(event) + const timeEvent = $derived(reader(TimeEvent)(event)) - const location = timeEvent.location() + const location = $derived(timeEvent.location())
    From 35ed7bc09f8204e258318bf8a008e56964989220 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Sat, 22 Aug 2026 11:40:05 -0700 Subject: [PATCH 44/45] React to addressed events getting updated for delete status --- src/app/components/ThunkStatusOrDeleted.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/components/ThunkStatusOrDeleted.svelte b/src/app/components/ThunkStatusOrDeleted.svelte index 3efb266c..8682cbcf 100644 --- a/src/app/components/ThunkStatusOrDeleted.svelte +++ b/src/app/components/ThunkStatusOrDeleted.svelte @@ -14,7 +14,8 @@ const {event, status, children}: Props = $props() - const deleted = deriveIsDeleted(event) + // Editing a replaceable event hands this a different event + const deleted = $derived(deriveIsDeleted(event)) const history = $thunks.history const thunk = $derived($thunks.merge($history.filter(t => t.event.id === event.id))) From 637be41a8be30b84cd7dad0c2abd7ae785090758 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Sat, 22 Aug 2026 12:36:25 -0700 Subject: [PATCH 45/45] Await deletions --- src/app/components/EventDeleteConfirm.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/components/EventDeleteConfirm.svelte b/src/app/components/EventDeleteConfirm.svelte index 8a8c945e..e04be9e2 100644 --- a/src/app/components/EventDeleteConfirm.svelte +++ b/src/app/components/EventDeleteConfirm.svelte @@ -3,6 +3,7 @@ import Confirm from "@lib/components/Confirm.svelte" import {deletes, relays} from "@app/core" import {clearModals} from "@app/modal" + import {pushToast} from "@app/toast" type Props = { url: string @@ -16,8 +17,11 @@ const confirm = async () => { const protect = await shouldProtect const command = await $deletes.deleteEvent(event, writer => writer.setProtected(protect)) + const error = await command.publishToRelays([url]).waitForError() - command.publishToRelays([url]) + if (error) { + return pushToast({theme: "error", message: error}) + } clearModals() }