From 3b93e067093ae3c55dd4c604ddbf108805bff1a5 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 10 Feb 2026 17:41:32 -0800 Subject: [PATCH 01/20] Disable wallet on ios --- ios/App/App.xcodeproj/project.pbxproj | 4 ++-- src/app/components/MenuSettings.svelte | 29 ++++++++++++++------------ src/routes/settings/+layout.svelte | 13 +++++++----- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index cd4c47df..e00521c1 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = S26U9DYW3A; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; @@ -385,7 +385,7 @@ CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = S26U9DYW3A; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; diff --git a/src/app/components/MenuSettings.svelte b/src/app/components/MenuSettings.svelte index 8a6e6f2e..f9456c64 100644 --- a/src/app/components/MenuSettings.svelte +++ b/src/app/components/MenuSettings.svelte @@ -1,4 +1,5 @@ diff --git a/src/app/components/ProfileDetail.svelte b/src/app/components/ProfileDetail.svelte index 70727ee4..3dbf68c4 100644 --- a/src/app/components/ProfileDetail.svelte +++ b/src/app/components/ProfileDetail.svelte @@ -15,6 +15,7 @@ import Letter from "@assets/icons/letter-opened.svg?dataurl" import MenuDots from "@assets/icons/menu-dots.svg?dataurl" import MinusCircle from "@assets/icons/minus-circle.svg?dataurl" + import Restart from "@assets/icons/restart.svg?dataurl" import {fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte" @@ -30,7 +31,7 @@ import EventInfo from "@app/components/EventInfo.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte" import ChatEnable from "@app/components/ChatEnable.svelte" - import {pubkeyLink, deriveUserIsSpaceAdmin} from "@app/core/state" + import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state" import {pushModal} from "@app/util/modal" import {pushToast} from "@app/util/toast" import {makeChatPath} from "@app/util/routes" @@ -46,6 +47,10 @@ const userIsAdmin = deriveUserIsSpaceAdmin(url) + const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined + + const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false) + const back = () => history.back() const chatPath = makeChatPath([pubkey]) @@ -81,6 +86,20 @@ }, }) + const restoreMember = async () => { + const {error} = await manageRelay(url!, { + method: ManagementMethod.AllowPubkey, + params: [pubkey], + }) + + if (error) { + pushToast({theme: "error", message: error}) + } else { + pushToast({message: "User has successfully been restored!"}) + back() + } + } + let showMenu = $state(false) onMount(() => { @@ -112,12 +131,21 @@ {/if} {#if $userIsAdmin} -
  • - -
  • + {#if isBanned} +
  • + +
  • + {:else} +
  • + +
  • + {/if} {/if} diff --git a/src/app/components/ReportDetails.svelte b/src/app/components/ReportDetails.svelte index 06cf399c..0eef7db1 100644 --- a/src/app/components/ReportDetails.svelte +++ b/src/app/components/ReportDetails.svelte @@ -40,9 +40,7 @@ All reports for this event are shown below. {#each $reports.values() as report (report.id)} -
    - -
    + {/each} diff --git a/src/app/components/ReportItem.svelte b/src/app/components/ReportItem.svelte index 8101797f..3faecb1e 100644 --- a/src/app/components/ReportItem.svelte +++ b/src/app/components/ReportItem.svelte @@ -3,14 +3,12 @@ import {getTag, getIdFilters} from "@welshman/util" import {load, LOCAL_RELAY_URL} from "@welshman/net" import type {TrustedEvent} from "@welshman/util" - import {pubkey} from "@welshman/app" import Button from "@lib/components/Button.svelte" import Profile from "@app/components/Profile.svelte" import ProfileName from "@app/components/ProfileName.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte" import NoteContent from "@app/components/NoteContent.svelte" import ReportMenu from "@app/components/ReportMenu.svelte" - import {publishDelete, canEnforceNip70} from "@app/core/commands" import {pushModal} from "@app/util/modal" import {goToEvent} from "@app/util/routes" @@ -25,7 +23,6 @@ const etag = getTag("e", event.tags) const ptag = getTag("p", event.tags) const reason = etag?.[2] || ptag?.[2] - const shouldProtect = canEnforceNip70(url) const onClick = (e: Event, event: TrustedEvent) => { // @ts-ignore @@ -35,17 +32,12 @@ goToEvent(event) } } - - const deleteReport = async () => { - publishDelete({event, relays: [url], protect: await shouldProtect}) - onDelete?.() - } -
    +
    - + Reported this event {#if reason} @@ -53,11 +45,7 @@ {/if}
    - {#if event.pubkey === $pubkey} - - {:else} - - {/if} +
    {#if event.content}
    diff --git a/src/app/components/ReportMenu.svelte b/src/app/components/ReportMenu.svelte index e3cc81df..48c08108 100644 --- a/src/app/components/ReportMenu.svelte +++ b/src/app/components/ReportMenu.svelte @@ -1,26 +1,32 @@ @@ -32,7 +38,7 @@
    {#each $reports as event (event.id)} - + {:else}

    No reports found.

    {/each} diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 2922bb0f..b2f5fac1 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -50,6 +50,7 @@ import { makeDeriveItem, deriveItemsByKey, deriveDeduplicated, + deriveEventsById, deriveEventsByIdByUrl, deriveEventsByIdForUrl, getEventsByIdForUrl, @@ -231,6 +232,9 @@ export const deriveEvent = makeDeriveEvent({ onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}), }) +export const deriveEvents = (filters: Filter[] = [{}]) => + deriveEventsDesc(deriveEventsById({repository, filters})) + export const getEventsForUrl = (url: string, filters: Filter[] = [{}]) => getEventsByIdForUrl({url, tracker, repository, filters}).values() diff --git a/src/lib/components/PageContent.svelte b/src/lib/components/PageContent.svelte index 0c9df337..e29379ee 100644 --- a/src/lib/components/PageContent.svelte +++ b/src/lib/components/PageContent.svelte @@ -1,4 +1,5 @@ -
    +
    {@render children?.()}
    diff --git a/src/routes/home/+page.svelte b/src/routes/home/+page.svelte index 1b221d46..daaa0eef 100644 --- a/src/routes/home/+page.svelte +++ b/src/routes/home/+page.svelte @@ -1,77 +1,204 @@ -
    -
    -
    -

    Welcome to

    -

    {PLATFORM_NAME}

    -
    - - - - {#snippet icon()} - - {/snippet} - {#snippet title()} -
    Browse the network
    - {/snippet} - {#snippet info()} -
    Find communities on the nostr network.
    - {/snippet} -
    - - -
    + + {#snippet icon()} +
    +
    -
    -
    + {/snippet} + {#snippet title()} + Recent Activity + {/snippet} + {#snippet action()} +
    + {/snippet} + + + + {#each $recentActivity as { type, event, url, count } (event.id)} + {#if type === "message"} + + {:else if event.kind === THREAD} + + {:else if event.kind === CLASSIFIED} + + {:else if event.kind === ZAP_GOAL} + + {:else if event.kind === EVENT_TIME} + + {:else} + + {/if} + {:else} + {#if loading} +
    + + Loading recent activity... +
    + {:else} +

    No recent activity found!

    + {/if} + {/each} +
    -- 2.52.0 From fdfee9cb75a55a8bdcc2d233dc4f284a6b33e6c6 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 13 Feb 2026 14:51:56 -0800 Subject: [PATCH 07/20] Revert makeFeed changes --- src/app/core/requests.ts | 78 ++++++++++++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index dfddcc3b..448a8fe6 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -1,4 +1,4 @@ -import {get, writable, derived} from "svelte/store" +import {get, writable} from "svelte/store" import { uniq, int, @@ -24,10 +24,10 @@ import { import type {TrustedEvent, Filter, List} from "@welshman/util" import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds" import {load, request} from "@welshman/net" -import {repository, makeFeedController, loadRelay} from "@welshman/app" +import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app" import {createScroller} from "@lib/html" import {daysBetween} from "@lib/util" -import {getEventsForUrl, deriveEventsForUrlDesc} from "@app/core/state" +import {getEventsForUrl} from "@app/core/state" // Utils @@ -42,12 +42,56 @@ export const makeFeed = ({ element: HTMLElement onExhausted?: () => void }) => { + const seen = new Set() const controller = new AbortController() - const allEvents = deriveEventsForUrlDesc(url, filters) - const total = derived(allEvents, $allEvents => $allEvents.length) - const limit = writable(0) + const buffer = writable([]) + const events = writable([]) - const events = derived([allEvents, limit], ([$allEvents, $limit]) => $allEvents.slice(0, $limit)) + const insertEvent = (event: TrustedEvent) => { + let handled = false + + if (seen.has(event.id)) { + return + } + + events.update($events => { + for (let i = 0; i < $events.length; i++) { + if ($events[i].id === event.id) return $events + if ($events[i].created_at < event.created_at) { + handled = true + return insertAt(i, event, $events) + } + } + + return $events + }) + + if (!handled) { + buffer.update($buffer => { + for (let i = 0; i < $buffer.length; i++) { + if ($buffer[i].id === event.id) return $buffer + if ($buffer[i].created_at < event.created_at) return insertAt(i, event, $buffer) + } + + return [...$buffer, event] + }) + } + + seen.add(event.id) + } + + const unsubscribe = on(repository, "update", ({added, removed}) => { + if (removed.size > 0) { + buffer.update($buffer => $buffer.filter(e => !removed.has(e.id))) + events.update($events => $events.filter(e => !removed.has(e.id))) + } + + for (const event of added) { + if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) { + insertEvent(event) + } + } + }) const ctrl = makeFeedController({ useWindowing: true, @@ -59,22 +103,26 @@ export const makeFeed = ({ const scroller = createScroller({ element, delay: 300, - threshold: 8_000, + threshold: 10_000, onScroll: async () => { - console.log(get(events).length, get(allEvents).length) - limit.update($limit => { - if (get(total) - $limit < 100) { - ctrl.load(100) - } + const $buffer = get(buffer) - return $limit + 10 - }) + events.update($events => [...$events, ...$buffer.splice(0, 30)]) + + if ($buffer.length < 100) { + ctrl.load(100) + } }, }) + for (const event of getEventsForUrl(url, filters)) { + insertEvent(event) + } + return { events, cleanup: () => { + unsubscribe() scroller.stop() controller.abort() }, -- 2.52.0 From 22c175e4f78c9bc927249c77c123e7e55e8cf384 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 13 Feb 2026 15:18:46 -0800 Subject: [PATCH 08/20] Watch tracker in feed utils --- src/app/core/requests.ts | 73 ++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index 448a8fe6..abfecfc6 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -1,5 +1,6 @@ import {get, writable} from "svelte/store" import { + call, uniq, int, YEAR, @@ -80,18 +81,29 @@ export const makeFeed = ({ seen.add(event.id) } - const unsubscribe = on(repository, "update", ({added, removed}) => { - if (removed.size > 0) { - buffer.update($buffer => $buffer.filter(e => !removed.has(e.id))) - events.update($events => $events.filter(e => !removed.has(e.id))) - } - - for (const event of added) { - if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) { - insertEvent(event) + const unsubscribers = [ + on(repository, "update", ({added, removed}) => { + if (removed.size > 0) { + buffer.update($buffer => $buffer.filter(e => !removed.has(e.id))) + events.update($events => $events.filter(e => !removed.has(e.id))) } - } - }) + + for (const event of added) { + if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) { + insertEvent(event) + } + } + }), + on(tracker, "add", (id: string, trackerUrl: string) => { + if (trackerUrl === url) { + const event = repository.getEvent(id) + + if (event && matchFilters(filters, event)) { + insertEvent(event) + } + } + }), + ] const ctrl = makeFeedController({ useWindowing: true, @@ -122,9 +134,9 @@ export const makeFeed = ({ return { events, cleanup: () => { - unsubscribe() scroller.stop() controller.abort() + unsubscribers.forEach(call) }, } } @@ -169,17 +181,28 @@ export const makeCalendarFeed = ({ }) } - const unsubscribe = on(repository, "update", ({added, removed}) => { - if (removed.size > 0) { - events.update($events => $events.filter(e => !removed.has(e.id))) - } - - for (const event of added) { - if (matchFilters(filters, event)) { - insertEvent(event) + const unsubscribers = [ + on(repository, "update", ({added, removed}) => { + if (removed.size > 0) { + events.update($events => $events.filter(e => !removed.has(e.id))) } - } - }) + + for (const event of added) { + if (matchFilters(filters, event)) { + insertEvent(event) + } + } + }), + on(tracker, "add", (id: string, trackerUrl: string) => { + if (trackerUrl === url) { + const event = repository.getEvent(id) + + if (event && matchFilters(filters, event)) { + insertEvent(event) + } + } + }), + ] const loadTimeframe = (since: number, until: number) => { const hashes = daysBetween(since, until).map(String) @@ -234,10 +257,10 @@ export const makeCalendarFeed = ({ return { events, cleanup: () => { - backwardScroller.stop() - forwardScroller.stop() controller.abort() - unsubscribe() + forwardScroller.stop() + backwardScroller.stop() + unsubscribers.forEach(call) }, } } -- 2.52.0 From bc84444a99b68c1ade1a3f3f5caa96ddaac6763d Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 13 Feb 2026 15:22:30 -0800 Subject: [PATCH 09/20] Make hover target for menu button more reasonable --- src/app/components/RoomItemMenuButton.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/RoomItemMenuButton.svelte b/src/app/components/RoomItemMenuButton.svelte index 4cb321be..40f14f33 100644 --- a/src/app/components/RoomItemMenuButton.svelte +++ b/src/app/components/RoomItemMenuButton.svelte @@ -17,7 +17,7 @@ if (popover) { const {x, y, width, height} = popover.popper.getBoundingClientRect() - if (!between([x, x + width], clientX) || !between([y, y + height + 30], clientY)) { + if (!between([x, x + width], clientX) || !between([y - 50, y + height + 50], clientY)) { popover.hide() } } -- 2.52.0 From 8b0645f756455397856deecb00e659bc2a5e07c9 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 13 Feb 2026 15:37:17 -0800 Subject: [PATCH 10/20] Fix DM media detection --- src/app/components/ContentLinkBlock.svelte | 9 +++++---- src/app/components/ContentLinkInline.svelte | 7 ++++--- src/app/core/state.ts | 6 +++++- src/app/util/routes.ts | 5 ++--- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/app/components/ContentLinkBlock.svelte b/src/app/components/ContentLinkBlock.svelte index abe1105d..ec981015 100644 --- a/src/app/components/ContentLinkBlock.svelte +++ b/src/app/components/ContentLinkBlock.svelte @@ -1,12 +1,12 @@ -{#if url.match(/\.(jpe?g|png|gif|webp)$/)} +{#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)} { } } - addEvents(repository.query([{kinds: [DIRECT_MESSAGE, PROFILE]}])) + addEvents(repository.query([{kinds: [...DM_KINDS, PROFILE]}])) const unsubscribers = [ on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)), diff --git a/src/app/util/routes.ts b/src/app/util/routes.ts index 51f9268d..9726625c 100644 --- a/src/app/util/routes.ts +++ b/src/app/util/routes.ts @@ -10,8 +10,6 @@ import {scrollToEvent} from "@lib/html" import {identity} from "@welshman/lib" import { getTagValue, - DIRECT_MESSAGE, - DIRECT_MESSAGE_FILE, MESSAGE, THREAD, CLASSIFIED, @@ -26,6 +24,7 @@ import { encodeRelay, userSpaceUrls, hasNip29, + DM_KINDS, ROOM, } from "@app/core/state" import {lastPageBySpaceUrl} from "@app/util/history" @@ -108,7 +107,7 @@ export const goToEvent = async (event: TrustedEvent, options: Record { - if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) { + if (DM_KINDS.includes(event.kind)) { return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)]) } -- 2.52.0 From c74f17d9311b1c06fb9ae57978931faef25f607c Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 16 Feb 2026 08:16:03 -0800 Subject: [PATCH 11/20] Fix editing messages with html tags --- src/app/components/ThunkStatusDetail.svelte | 3 +- src/app/editor/index.ts | 3 +- src/lib/html.ts | 8 ++ src/lib/util.ts | 2 + src/routes/spaces/[relay]/[h]/+page.svelte | 85 +++++++++++---------- src/routes/spaces/[relay]/chat/+page.svelte | 69 +++++++++-------- 6 files changed, 98 insertions(+), 72 deletions(-) diff --git a/src/app/components/ThunkStatusDetail.svelte b/src/app/components/ThunkStatusDetail.svelte index cc8b2653..3da57bb4 100644 --- a/src/app/components/ThunkStatusDetail.svelte +++ b/src/app/components/ThunkStatusDetail.svelte @@ -2,6 +2,7 @@ import {PublishStatus} from "@welshman/net" import {displayRelayUrl} from "@welshman/util" import Button from "@lib/components/Button.svelte" + import {addPeriod} from "@lib/util" interface Props { url: string @@ -25,7 +26,7 @@

    - Failed to publish to {displayRelayUrl(url)}: {message}. + Failed to publish to {displayRelayUrl(url)}: {addPeriod(message)}

    diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index 2184f317..8ea78606 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -15,6 +15,7 @@ import { } from "@welshman/app" import type {FileAttributes} from "@welshman/editor" import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor" +import {escapeHtml} from "@lib/html" import {makeMentionNodeView} from "@app/editor/MentionNodeView" import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte" import {uploadFile} from "@app/core/commands" @@ -82,7 +83,7 @@ export const makeEditor = async ({ ) return new Editor({ - content, + content: escapeHtml(content), autofocus, editorProps, element: document.createElement("div"), diff --git a/src/lib/html.ts b/src/lib/html.ts index ec38e0d4..adfd6aca 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -164,3 +164,11 @@ export const compressFile = async ( }) }) } + +export const escapeHtml = (html: string) => { + const element = document.createElement("div") + + element.innerText = html + + return element.innerHTML +} diff --git a/src/lib/util.ts b/src/lib/util.ts index a1ae8d5b..6a2248ec 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -26,3 +26,5 @@ export const buildUrl = (base: string | URL, ...pathname: string[]) => { return url.toString() } + +export const addPeriod = (s: string) => (s + ".").replace(/\.+$/, ".") diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index e93bf402..53dd5624 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -110,51 +110,58 @@ } const onSubmit = async ({content, tags}: EventContent) => { - tags.push(["h", h]) + try { + tags.push(["h", h]) - if (await shouldProtect) { - tags.push(PROTECTED) - } + if (await shouldProtect) { + tags.push(PROTECTED) + } - let template: EventContent & {created_at?: number} = {content, tags} + let template: EventContent & {created_at?: number} = {content, tags} - if (eventToEdit) { - // Delete previous message, to be republished with same timestamp - template.created_at = eventToEdit.created_at - publishDelete({ + if (eventToEdit) { + // Don't do anything if message hasn't changed + if (eventToEdit.content === content) { + return + } + + // Delete previous message, to be republished with same timestamp + template.created_at = eventToEdit.created_at + publishDelete({ + relays: [url], + event: $state.snapshot(eventToEdit), + protect: await shouldProtect, + }) + } + + if (share) { + template = prependParent(share, template, url) + } + + if (parent) { + template = prependParent(parent, template, url) + } + + const thunk = publishThunk({ relays: [url], - event: $state.snapshot(eventToEdit), - protect: await shouldProtect, + event: makeEvent(MESSAGE, template), + delay: $userSettingsValues.send_delay, }) + + if ($userSettingsValues.send_delay) { + pushToast({ + timeout: 30_000, + children: { + component: ThunkToast, + props: {thunk}, + }, + }) + } + } finally { + clearParent() + clearShare() + clearEventToEdit() } - - if (share) { - template = prependParent(share, template, url) - } - - if (parent) { - template = prependParent(parent, template, url) - } - - const thunk = publishThunk({ - relays: [url], - event: makeEvent(MESSAGE, template), - delay: $userSettingsValues.send_delay, - }) - - if ($userSettingsValues.send_delay) { - pushToast({ - timeout: 30_000, - children: { - component: ThunkToast, - props: {thunk}, - }, - }) - } - - clearParent() - clearShare() - clearEventToEdit() } const onScroll = () => { diff --git a/src/routes/spaces/[relay]/chat/+page.svelte b/src/routes/spaces/[relay]/chat/+page.svelte index 3da1032e..596f1d1f 100644 --- a/src/routes/spaces/[relay]/chat/+page.svelte +++ b/src/routes/spaces/[relay]/chat/+page.svelte @@ -54,45 +54,52 @@ } const onSubmit = async ({content, tags}: EventContent) => { - let template: EventContent & {created_at?: number} = {content, tags} + try { + let template: EventContent & {created_at?: number} = {content, tags} - if (eventToEdit) { - // Delete previous message, to be republished with same timestamp - template.created_at = eventToEdit.created_at - publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect}) - } + if (eventToEdit) { + // Don't do anything if message hasn't changed + if (eventToEdit.content === content) { + return + } - if (await shouldProtect) { - tags.push(PROTECTED) - } + // Delete previous message, to be republished with same timestamp + template.created_at = eventToEdit.created_at + publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect}) + } - if (share) { - template = prependParent(share, template, url) - } + if (await shouldProtect) { + tags.push(PROTECTED) + } - if (parent) { - template = prependParent(parent, template, url) - } + if (share) { + template = prependParent(share, template, url) + } - const thunk = publishThunk({ - relays: [url], - event: makeEvent(MESSAGE, template), - delay: $userSettingsValues.send_delay, - }) + if (parent) { + template = prependParent(parent, template, url) + } - if ($userSettingsValues.send_delay) { - pushToast({ - timeout: 30_000, - children: { - component: ThunkToast, - props: {thunk}, - }, + const thunk = publishThunk({ + relays: [url], + event: makeEvent(MESSAGE, template), + delay: $userSettingsValues.send_delay, }) - } - clearParent() - clearShare() - clearEventToEdit() + if ($userSettingsValues.send_delay) { + pushToast({ + timeout: 30_000, + children: { + component: ThunkToast, + props: {thunk}, + }, + }) + } + } finally { + clearParent() + clearShare() + clearEventToEdit() + } } const onScroll = () => { -- 2.52.0 From 7914c07b58a2e277ddda6c6e0ad68dac7db51dff Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 14 Feb 2026 21:27:36 -0500 Subject: [PATCH 12/20] Space search --- src/app/core/requests.ts | 47 ++ src/lib/html.ts | 10 +- src/routes/spaces/[relay]/[h]/+page.svelte | 479 ++++++++++++++++++-- src/routes/spaces/[relay]/chat/+page.svelte | 125 ++++- 4 files changed, 627 insertions(+), 34 deletions(-) diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index abfecfc6..6c45ec70 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -131,8 +131,55 @@ export const makeFeed = ({ insertEvent(event) } + const reveal = (id: string, targetEvent?: TrustedEvent) => { + const current = get(events) + + if (current.find(e => e.id === id)) { + return true + } + + const queued = get(buffer) + const index = queued.findIndex(e => e.id === id) + + if (index === -1) { + const event = targetEvent || repository.getEvent(id) + + if (event && matchFilters(filters, event)) { + insertEvent(event) + + const next = get(events) + + if (next.find(e => e.id === id)) { + return true + } + + const queuedNext = get(buffer) + const queuedIndex = queuedNext.findIndex(e => e.id === id) + + if (queuedIndex > -1) { + const count = Math.max(30, queuedIndex + 1) + const chunk = queuedNext.splice(0, count) + + events.update($events => [...$events, ...chunk]) + + return true + } + } + + return false + } + + const count = Math.max(30, index + 1) + const chunk = queued.splice(0, count) + + events.update($events => [...$events, ...chunk]) + + return true + } + return { events, + reveal, cleanup: () => { scroller.stop() controller.abort() diff --git a/src/lib/html.ts b/src/lib/html.ts index adfd6aca..cc8e1b4f 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -118,6 +118,10 @@ export const scrollToEvent = async (id: string, attempts = 3): Promise return true } else if (elements.length > 0) { + if (attempts <= 0) { + return false + } + const lastElement = last(elements) if (lastElement && !isIntersecting(lastElement)) { @@ -126,11 +130,7 @@ export const scrollToEvent = async (id: string, attempts = 3): Promise await sleep(300) - if (attempts > 0) { - return scrollToEvent(id, attempts - 1) - } else { - return false - } + return scrollToEvent(id, attempts - 1) } return false diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 53dd5624..957159b3 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -1,56 +1,73 @@ -- 2.52.0 From 0bd14a9401f0e607112a81af6adf735298bafe01 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 16 Feb 2026 09:03:00 -0800 Subject: [PATCH 13/20] Refactor SpaceSearch into its own component --- AGENTS.md | 3 +- src/app.css | 4 + src/app/components/SpaceMenuButton.svelte | 2 +- src/app/components/SpaceSearch.svelte | 168 ++++++++ src/app/core/requests.ts | 47 -- src/lib/components/PageContent.svelte | 2 +- src/routes/spaces/[relay]/[h]/+page.svelte | 450 +------------------- src/routes/spaces/[relay]/chat/+page.svelte | 131 +----- 8 files changed, 195 insertions(+), 612 deletions(-) create mode 100644 src/app/components/SpaceSearch.svelte diff --git a/AGENTS.md b/AGENTS.md index 74bbf0ec..f1aa7e3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,7 +157,7 @@ src/ - Derive all other data inside the component from identifiers - Example: Don't pass `members` prop, derive it from `h` inside component -**Code Style:** +**CRITICAL Code Style Guidelines:** - **No `null`** - only use `undefined` - Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components @@ -168,6 +168,7 @@ src/ - When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax. - When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss - Do not define svelte event handlers inline, instead name them and put them in the script section of templates +- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly. ## Common Tasks diff --git a/src/app.css b/src/app.css index aaddd0cd..09fbae83 100644 --- a/src/app.css +++ b/src/app.css @@ -402,6 +402,10 @@ progress[value]::-webkit-progress-value { @apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)]; } +.ct { + @apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)]; +} + /* Keyboard open state adjustments */ body.keyboard-open .cb { diff --git a/src/app/components/SpaceMenuButton.svelte b/src/app/components/SpaceMenuButton.svelte index 87e40d20..b5c0e7c5 100644 --- a/src/app/components/SpaceMenuButton.svelte +++ b/src/app/components/SpaceMenuButton.svelte @@ -13,7 +13,7 @@ const openMenu = () => pushDrawer(SpaceMenu, {url}) - + {#if show} + +
    +
    +
    + Search + +
    + +
    + {#if !term} +

    + {h ? "Search for messages in this room." : "Search for messages across this space."} +

    + {:else if eventsByAge.size === 0} +

    No results found.

    + {:else} +
    + {#each eventsByAge as [key, events] (key)} +
    +

    + {#if key === "day"} + Last 24 Hours + {:else if key === "week"} + Last 7 Days + {:else} + Older + {/if} +

    +
    + {#each events as event (event.id)} + + {/each} +
    +
    + {/each} +
    + {/if} +
    +
    +
    + {/if} +
    diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index 6c45ec70..abfecfc6 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -131,55 +131,8 @@ export const makeFeed = ({ insertEvent(event) } - const reveal = (id: string, targetEvent?: TrustedEvent) => { - const current = get(events) - - if (current.find(e => e.id === id)) { - return true - } - - const queued = get(buffer) - const index = queued.findIndex(e => e.id === id) - - if (index === -1) { - const event = targetEvent || repository.getEvent(id) - - if (event && matchFilters(filters, event)) { - insertEvent(event) - - const next = get(events) - - if (next.find(e => e.id === id)) { - return true - } - - const queuedNext = get(buffer) - const queuedIndex = queuedNext.findIndex(e => e.id === id) - - if (queuedIndex > -1) { - const count = Math.max(30, queuedIndex + 1) - const chunk = queuedNext.splice(0, count) - - events.update($events => [...$events, ...chunk]) - - return true - } - } - - return false - } - - const count = Math.max(30, index + 1) - const chunk = queued.splice(0, count) - - events.update($events => [...$events, ...chunk]) - - return true - } - return { events, - reveal, cleanup: () => { scroller.stop() controller.abort() diff --git a/src/lib/components/PageContent.svelte b/src/lib/components/PageContent.svelte index e29379ee..84483d6d 100644 --- a/src/lib/components/PageContent.svelte +++ b/src/lib/components/PageContent.svelte @@ -12,7 +12,7 @@ const className = cx( props.class, - "scroll-container cw cb fixed top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden", + "scroll-container cw cb ct fixed z-feature overflow-y-auto overflow-x-hidden", ) diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 957159b3..201add1c 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -1,37 +1,23 @@ @@ -395,7 +275,10 @@ Chat {/snippet} {#snippet action()} - +
    + + +
    {/snippet}
    -- 2.52.0 From b42d3bf8d8d08d469b181aa63460cf7b673dce1b Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 16 Feb 2026 11:34:46 -0800 Subject: [PATCH 14/20] Use compressorjs-next --- package.json | 2 +- pnpm-lock.yaml | 26 ++++++-------------------- src/app/components/SpaceSearch.svelte | 3 +-- src/lib/html.ts | 4 ++-- 4 files changed, 10 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index ca4cf686..49243051 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@welshman/signer": "^0.8.4", "@welshman/store": "^0.8.4", "@welshman/util": "^0.8.4", - "compressorjs": "^1.2.1", + "compressorjs-next": "^1.1.2", "daisyui": "^4.12.24", "date-picker-svelte": "^2.17.0", "dotenv": "^16.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d343e776..6658938e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,9 +110,9 @@ importers: '@welshman/util': specifier: ^0.8.4 version: 0.8.4(@noble/curves@1.9.7)(@welshman/lib@0.8.4)(nostr-tools@2.20.0(typescript@5.9.3)) - compressorjs: - specifier: ^1.2.1 - version: 1.2.1 + compressorjs-next: + specifier: ^1.1.2 + version: 1.1.2 daisyui: specifier: ^4.12.24 version: 4.12.24(postcss@8.5.6) @@ -2048,9 +2048,6 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - blueimp-canvas-to-blob@3.29.0: - resolution: {integrity: sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==} - boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2211,8 +2208,8 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} - compressorjs@1.2.1: - resolution: {integrity: sha512-+geIjeRnPhQ+LLvvA7wxBQE5ddeLU7pJ3FsKFWirDw6veY3s9iLxAQEw7lXGHnhCJvBujEQWuNnGzZcvCvdkLQ==} + compressorjs-next@1.1.2: + resolution: {integrity: sha512-5nwrVCR3+kSd4cwIzQEB72W4d+uHQ9so8U2C+WBr74DFoG34FM9CXoNZMsCnCTUDhmDKJ/3aI4Di1+QKF8LFow==} concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -3001,10 +2998,6 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-blob@2.1.0: - resolution: {integrity: sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==} - engines: {node: '>=6'} - is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -6909,8 +6902,6 @@ snapshots: binary-extensions@2.3.0: {} - blueimp-canvas-to-blob@3.29.0: {} - boolbase@1.0.0: {} bplist-creator@0.1.0: @@ -7073,10 +7064,7 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 - compressorjs@1.2.1: - dependencies: - blueimp-canvas-to-blob: 3.29.0 - is-blob: 2.1.0 + compressorjs-next@1.1.2: {} concat-map@0.0.1: {} @@ -8004,8 +7992,6 @@ snapshots: dependencies: binary-extensions: 2.3.0 - is-blob@2.1.0: {} - is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 diff --git a/src/app/components/SpaceSearch.svelte b/src/app/components/SpaceSearch.svelte index 8f24e474..c5557bcb 100644 --- a/src/app/components/SpaceSearch.svelte +++ b/src/app/components/SpaceSearch.svelte @@ -7,7 +7,6 @@ import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl" import {fly} from "@lib/transition" - import PageContent from "@lib/components/PageContent.svelte" import Button from "@lib/components/Button.svelte" import Icon from "@lib/components/Icon.svelte" import {deriveEventsForUrl} from "@app/core/state" @@ -92,7 +91,7 @@ const onRoomSearchResultClick = (event: TrustedEvent) => { close() - void goToEvent(event, {keepFocus: true}) + goToEvent(event, {keepFocus: true}) } diff --git a/src/lib/html.ts b/src/lib/html.ts index cc8e1b4f..44552506 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -140,13 +140,13 @@ export const compressFile = async ( file: File | Blob, options: Record = {}, ): Promise => { - const {default: Compressor} = await import("compressorjs") + const {default: Compressor} = await import("compressorjs-next") return new Promise((resolve, _reject) => { new Compressor(file, { maxWidth: 2048, maxHeight: 2048, - convertSize: 10 * 1024 * 1024, + convertTypes: ["image/png"], ...options, success: result => resolve(result as File), error: e => { -- 2.52.0 From 88e44f5111a534a5fe8ba35a5068d1ece2c0248f Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 16 Feb 2026 12:39:21 -0800 Subject: [PATCH 15/20] Fix duplicate ids in chat --- src/app/core/state.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 0b0eab4e..c5a4dcc2 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -450,7 +450,10 @@ export const chatsById = call(() => { const pubkeys = getChatPubkeysFromEvent(event) const id = makeChatId(pubkeys) const chat = chatsById.get(id) - const messages = sortBy(e => -e.created_at, append(event, chat?.messages || [])) + const messages = sortBy( + e => -e.created_at, + uniqBy(e => e.id, append(event, chat?.messages || [])), + ) const last_activity = Math.max(chat?.last_activity || 0, event.created_at) const updatedChat = addSearchText({id, pubkeys, messages, last_activity}) -- 2.52.0 From 1e9a25e434490319368fa4ffb379f4391331a449 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 16 Feb 2026 12:48:42 -0800 Subject: [PATCH 16/20] Make createScroller honor reverse param --- src/app/components/ReactionSummary.svelte | 4 ++-- src/lib/html.ts | 7 +++++-- src/routes/settings/+layout.svelte | 12 +++++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/app/components/ReactionSummary.svelte b/src/app/components/ReactionSummary.svelte index 876a905c..fad73460 100644 --- a/src/app/components/ReactionSummary.svelte +++ b/src/app/components/ReactionSummary.svelte @@ -140,7 +140,7 @@ data-tip={tooltip} class={cx( reactionClass, - "flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal", + "flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal bg-alt", { tooltip: !noTooltip && !isMobile, "border-neutral-content/20": !isOwn, @@ -162,7 +162,7 @@ data-tip={tooltip} class={cx( reactionClass, - "flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal", + "flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full font-normal bg-alt", { tooltip: !noTooltip && !isMobile, "border-neutral-content/20": !isOwn, diff --git a/src/lib/html.ts b/src/lib/html.ts index 44552506..2abdcd7c 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -47,9 +47,12 @@ export const createScroller = ({ if (container) { // While we have empty space, fill it const {scrollY, innerHeight} = window - const {scrollHeight, scrollTop} = container + const {scrollHeight, scrollTop, clientHeight} = container + const viewHeight = clientHeight || innerHeight const offset = Math.abs(scrollTop || scrollY) - const shouldLoad = offset + innerHeight + threshold > scrollHeight + const shouldLoad = reverse + ? offset < threshold + : offset + viewHeight + threshold > scrollHeight // Only trigger loading the first time we reach the threshold if (shouldLoad) { diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte index b64f76df..4e8f4b9b 100644 --- a/src/routes/settings/+layout.svelte +++ b/src/routes/settings/+layout.svelte @@ -46,13 +46,11 @@ Alerts
    - {#if Capacitor.getPlatform() !== "ios"} -
    - - Wallet - -
    - {/if} +
    + + Wallet + +
    Relays -- 2.52.0 From 66c576a9acf65d5692d3aa823524bbdd02c111b4 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 16 Feb 2026 13:31:43 -0800 Subject: [PATCH 17/20] Add forward scrolling to makeMakeFeed --- README.md | 2 +- src/app/core/requests.ts | 120 ++++++++++++------ src/routes/spaces/[relay]/[h]/+page.svelte | 37 ++++-- src/routes/spaces/[relay]/chat/+page.svelte | 62 +++++---- .../spaces/[relay]/classifieds/+page.svelte | 2 +- src/routes/spaces/[relay]/goals/+page.svelte | 2 +- .../spaces/[relay]/threads/+page.svelte | 2 +- 7 files changed, 150 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 51731e26..29e880ec 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ If you're deploying a custom version of flotilla, be sure to remove the `plausib ## Development -See [./CONTRIBUTING.md](CONTRIBUTING.md). +See [CONTRIBUTING.md](AGENTS.md). ## Deployment diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index abfecfc6..92bf1a34 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -9,9 +9,11 @@ import { sortBy, now, on, + between, isDefined, filterVals, fromPairs, + HOUR, } from "@welshman/lib" import { EVENT_TIME, @@ -23,9 +25,8 @@ import { getRelaysFromList, } from "@welshman/util" import type {TrustedEvent, Filter, List} from "@welshman/util" -import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds" import {load, request} from "@welshman/net" -import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app" +import {repository, loadRelay, tracker} from "@welshman/app" import {createScroller} from "@lib/html" import {daysBetween} from "@lib/util" import {getEventsForUrl} from "@app/core/state" @@ -36,55 +37,61 @@ export const makeFeed = ({ url, filters, element, - onExhausted, + onBackwardExhausted, + onForwardExhausted, + at = now(), }: { url: string filters: Filter[] element: HTMLElement - onExhausted?: () => void + onBackwardExhausted?: () => void + onForwardExhausted?: () => void + at?: number }) => { - const seen = new Set() + const interval = int(12, HOUR) const controller = new AbortController() - const buffer = writable([]) const events = writable([]) + let buffer: TrustedEvent[] = [] + let backwardWindow = [at - interval, at] + let forwardWindow = [at, at + interval] + const insertEvent = (event: TrustedEvent) => { let handled = false - if (seen.has(event.id)) { - return - } + if (between([backwardWindow[0], forwardWindow[1]], event.created_at)) { + const $events = get(events) - events.update($events => { for (let i = 0; i < $events.length; i++) { - if ($events[i].id === event.id) return $events - if ($events[i].created_at < event.created_at) { + if ($events[i].created_at > event.created_at) { + events.set(insertAt(i, event, $events)) handled = true - return insertAt(i, event, $events) + break } } - return $events - }) - - if (!handled) { - buffer.update($buffer => { - for (let i = 0; i < $buffer.length; i++) { - if ($buffer[i].id === event.id) return $buffer - if ($buffer[i].created_at < event.created_at) return insertAt(i, event, $buffer) + if (!handled) { + events.set([...$events, event]) + } + } else { + for (let i = 0; i < buffer.length; i++) { + if (buffer[i].created_at > event.created_at) { + buffer.splice(i, 0, event) + handled = true + break } + } - return [...$buffer, event] - }) + if (!handled) { + buffer.push(event) + } } - - seen.add(event.id) } const unsubscribers = [ on(repository, "update", ({added, removed}) => { if (removed.size > 0) { - buffer.update($buffer => $buffer.filter(e => !removed.has(e.id))) + buffer = buffer.filter(e => !removed.has(e.id)) events.update($events => $events.filter(e => !removed.has(e.id))) } @@ -105,24 +112,56 @@ export const makeFeed = ({ }), ] - const ctrl = makeFeedController({ - useWindowing: true, - signal: controller.signal, - feed: makeIntersectionFeed(makeRelayFeed(url), feedFromFilters(filters)), - onExhausted, - }) + const loadTimeframe = (since: number, until: number) => { + request({ + relays: [url], + autoClose: true, + signal: controller.signal, + filters: filters.map(filter => ({...filter, since, until})), + }) + } - const scroller = createScroller({ + const backwardScroller = createScroller({ element, delay: 300, - threshold: 10_000, - onScroll: async () => { - const $buffer = get(buffer) + threshold: 5000, + onScroll: () => { + const [since, until] = backwardWindow - events.update($events => [...$events, ...$buffer.splice(0, 30)]) + backwardWindow = [since - interval, since] - if ($buffer.length < 100) { - ctrl.load(100) + for (const event of buffer.splice(0)) { + insertEvent(event) + } + + if (until > now() - int(2, YEAR)) { + loadTimeframe(since, until) + } else if (!buffer.some(e => e.created_at < at)) { + backwardScroller.stop() + onBackwardExhausted?.() + } + }, + }) + + const forwardScroller = createScroller({ + element, + reverse: true, + delay: 300, + threshold: 5000, + onScroll: () => { + const [since, until] = forwardWindow + + forwardWindow = [until, until + interval] + + for (const event of buffer.splice(0)) { + insertEvent(event) + } + + if (until < now()) { + loadTimeframe(since, until) + } else if (!buffer.some(e => e.created_at > at)) { + forwardScroller.stop() + onForwardExhausted?.() } }, }) @@ -134,8 +173,9 @@ export const makeFeed = ({ return { events, cleanup: () => { - scroller.stop() controller.abort() + forwardScroller.stop() + backwardScroller.stop() unsubscribers.forEach(call) }, } diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 201add1c..8768938b 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -186,11 +186,20 @@ const scrollToNewMessages = () => document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"}) - const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"}) + const scrollToBottom = () => { + if ($page.url.searchParams.get("at")) { + at = now() + start() + } else { + element?.scrollTo({top: 0, behavior: "smooth"}) + } + } let joining = $state(false) let leaving = $state(false) - let loadingEvents = $state(true) + let loadingBackward = $state(true) + let loadingForward = $state(true) + let at = $state(parseInt($page.url.searchParams.get("at") || String(now()))) let share = $state(popKey("share")) let parent: TrustedEvent | undefined = $state() let element: HTMLElement | undefined = $state() @@ -221,7 +230,7 @@ const adjustedLastChecked = lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked - for (const event of $events.toReversed()) { + for (const event of $events) { if (seen.has(event.id)) { continue } @@ -272,11 +281,15 @@ cleanup?.() const feed = makeFeed({ + at, url, element: element!, filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}], - onExhausted: () => { - loadingEvents = false + onBackwardExhausted: () => { + loadingBackward = false + }, + onForwardExhausted: () => { + loadingForward = false }, }) @@ -319,14 +332,11 @@ start() return () => { + cleanup() observer.unobserve(chatCompose!) observer.unobserve(dynamicPadding!) } }) - - onDestroy(() => { - cleanup?.() - }) @@ -373,6 +383,11 @@
    {:else} + {#if loadingForward} +

    + Looking for messages... +

    + {/if} {#each elements as { type, id, value, showPubkey } (id)} {#if type === "new-messages"}
    - {#if loadingEvents} - Looking for messages... + {#if loadingBackward} + Looking for messages... {:else} End of message history {/if} diff --git a/src/routes/spaces/[relay]/chat/+page.svelte b/src/routes/spaces/[relay]/chat/+page.svelte index 5a233166..5c98c2dc 100644 --- a/src/routes/spaces/[relay]/chat/+page.svelte +++ b/src/routes/spaces/[relay]/chat/+page.svelte @@ -124,9 +124,18 @@ const scrollToNewMessages = () => document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"}) - const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"}) + const scrollToBottom = () => { + if ($page.url.searchParams.get("at")) { + at = now() + start() + } else { + element?.scrollTo({top: 0, behavior: "smooth"}) + } + } - let loadingEvents = $state(true) + let loadingBackward = $state(true) + let loadingForward = $state(true) + let at = $state(parseInt($page.url.searchParams.get("at") || String(now()))) let share = $state(popKey("share")) let parent: TrustedEvent | undefined = $state() let element: HTMLElement | undefined = $state() @@ -157,7 +166,7 @@ const adjustedLastChecked = lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked - for (const event of $events.toReversed()) { + for (const event of $events) { if (seen.has(event.id)) { continue } @@ -204,6 +213,26 @@ return elements }) + const start = () => { + cleanup?.() + + const feed = makeFeed({ + at, + url, + element: element!, + filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}], + onBackwardExhausted: () => { + loadingBackward = false + }, + onForwardExhausted: () => { + loadingForward = false + }, + }) + + events = feed.events + cleanup = feed.cleanup + } + const onEscape = () => { clearParent() clearShare() @@ -238,29 +267,13 @@ observer.observe(chatCompose!) observer.observe(dynamicPadding!) - - const feed = makeFeed({ - url, - element: element!, - filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}], - onExhausted: () => { - loadingEvents = false - }, - }) - - events = feed.events - cleanup = feed.cleanup + start() return () => { cleanup() controller.abort() observer.unobserve(chatCompose!) observer.unobserve(dynamicPadding!) - - // Sveltekit calls onDestroy at the beginning of the page load for some reason - setTimeout(() => { - setChecked($page.url.pathname) - }, 800) } }) @@ -284,6 +297,11 @@
    + {#if loadingForward} +

    + Looking for messages... +

    + {/if} {#each elements as { type, id, value, showPubkey } (id)} {#if type === "new-messages"}
    - {#if loadingEvents} - Looking for messages... + {#if loadingBackward} + Looking for messages... {:else} End of message history {/if} diff --git a/src/routes/spaces/[relay]/classifieds/+page.svelte b/src/routes/spaces/[relay]/classifieds/+page.svelte index a274ddbe..bbca8fb0 100644 --- a/src/routes/spaces/[relay]/classifieds/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/+page.svelte @@ -49,7 +49,7 @@ url, element: element!, filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])], - onExhausted: () => { + onBackwardExhausted: () => { loading = false }, }) diff --git a/src/routes/spaces/[relay]/goals/+page.svelte b/src/routes/spaces/[relay]/goals/+page.svelte index bd9c6a44..d1475ce8 100644 --- a/src/routes/spaces/[relay]/goals/+page.svelte +++ b/src/routes/spaces/[relay]/goals/+page.svelte @@ -48,7 +48,7 @@ url, element: element!, filters: [{kinds: [ZAP_GOAL]}, makeCommentFilter([ZAP_GOAL])], - onExhausted: () => { + onBackwardExhausted: () => { loading = false }, }) diff --git a/src/routes/spaces/[relay]/threads/+page.svelte b/src/routes/spaces/[relay]/threads/+page.svelte index adddc5e0..a4079b1b 100644 --- a/src/routes/spaces/[relay]/threads/+page.svelte +++ b/src/routes/spaces/[relay]/threads/+page.svelte @@ -49,7 +49,7 @@ url, element: element!, filters: [{kinds: [THREAD]}, makeCommentFilter([THREAD])], - onExhausted: () => { + onBackwardExhausted: () => { loading = false }, }) -- 2.52.0 From 781102628edb253d39d4d1162a477005a174dfdb Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 16 Feb 2026 14:45:54 -0800 Subject: [PATCH 18/20] Simplify goToEvent --- src/app/util/routes.ts | 26 +++++++------- src/lib/html.ts | 38 +-------------------- src/routes/spaces/[relay]/[h]/+page.svelte | 2 +- src/routes/spaces/[relay]/chat/+page.svelte | 2 +- 4 files changed, 17 insertions(+), 51 deletions(-) diff --git a/src/app/util/routes.ts b/src/app/util/routes.ts index 9726625c..28424bb4 100644 --- a/src/app/util/routes.ts +++ b/src/app/util/routes.ts @@ -2,11 +2,10 @@ import type {Page} from "@sveltejs/kit" import {get} from "svelte/store" import * as nip19 from "nostr-tools/nip19" import {goto} from "$app/navigation" -import {nthEq, sleep} from "@welshman/lib" +import {nthEq} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {getAddress} from "@welshman/util" import {tracker, loadRelay} from "@welshman/app" -import {scrollToEvent} from "@lib/html" import {identity} from "@welshman/lib" import { getTagValue, @@ -62,6 +61,14 @@ export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(u export const makeSpaceChatPath = (url: string) => makeRoomPath(url, "chat") +export const makeMessagePath = (url: string, event: TrustedEvent) => { + const h = getTagValue(ROOM, event.tags) + const path = h ? makeRoomPath(url, h) : makeSpaceChatPath(url) + const qp = new URLSearchParams({at: String(event.created_at)}) + + return path + "?" + qp.toString() +} + export const makeGoalPath = (url: string, id?: string) => makeSpacePath(url, "goals", id) export const makeThreadPath = (url: string, id?: string) => makeSpacePath(url, "threads", id) @@ -92,27 +99,22 @@ export const getPrimaryNavItemIndex = ($page: Page) => { } } -export const goToEvent = async (event: TrustedEvent, options: Record = {}) => { +export const goToEvent = (event: TrustedEvent, options: Record = {}) => { const urls = Array.from(tracker.getRelays(event.id)) - const path = await getEventPath(event, urls) + const path = getEventPath(event, urls) if (path.includes("://")) { window.open(path) } else { goto(path, options) - - await sleep(300) - await scrollToEvent(event.id) } } -export const getEventPath = async (event: TrustedEvent, urls: string[]) => { +export const getEventPath = (event: TrustedEvent, urls: string[]) => { if (DM_KINDS.includes(event.kind)) { return makeChatPath([event.pubkey, ...getPubkeyTagValues(event.tags)]) } - const h = getTagValue(ROOM, event.tags) - if (urls.length > 0) { const url = urls[0] @@ -133,7 +135,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => { } if (event.kind === MESSAGE) { - return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat") + return makeMessagePath(url, event) } const address = event.tags.find(nthEq(0, "A"))?.[1] @@ -150,7 +152,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => { } if (parseInt(kind) === MESSAGE) { - return h ? makeRoomPath(url, h) : makeSpacePath(url, "chat") + return makeMessagePath(url, event) } } diff --git a/src/lib/html.ts b/src/lib/html.ts index 2abdcd7c..640cae70 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -1,4 +1,4 @@ -import {sleep, last, randomId} from "@welshman/lib" +import {sleep, randomId} from "@welshman/lib" export {preventDefault, stopPropagation} from "svelte/legacy" export const copyToClipboard = (text: string) => { @@ -103,42 +103,6 @@ export const isIntersecting = async (element: Element) => observer.observe(element) }) -export const scrollToEvent = async (id: string, attempts = 3): Promise => { - const element = document.querySelector(`[data-event="${id}"]`) as any - const elements = Array.from(document.querySelectorAll("[data-event]")) - - if (element) { - element.scrollIntoView({behavior: "smooth", block: "center"}) - element.style = "filter: brightness(1.5); transition-property: all; transition-duration: 400ms;" - - setTimeout(() => { - element.style = "transition-property: all; transition-duration: 300ms;" - }, 800) - - setTimeout(() => { - element.style = "" - }, 800 + 400) - - return true - } else if (elements.length > 0) { - if (attempts <= 0) { - return false - } - - const lastElement = last(elements) - - if (lastElement && !isIntersecting(lastElement)) { - lastElement.scrollIntoView({behavior: "smooth", block: "center"}) - } - - await sleep(300) - - return scrollToEvent(id, attempts - 1) - } - - return false -} - export const compressFile = async ( file: File | Blob, options: Record = {}, diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 8768938b..c3d019cd 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -1,5 +1,5 @@ + +{#key $page.url.searchParams.get("at")} + +{/key} diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index a64b798c..b5fca7ed 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -2,6 +2,7 @@ import {onMount, tick} from "svelte" import {readable} from "svelte/store" import {page} from "$app/stores" + import {goto} from "$app/navigation" import type {Readable} from "svelte/store" import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app" import {now, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" @@ -60,6 +61,7 @@ const room = deriveRoom(url, h) const shouldProtect = canEnforceNip70(url) const membershipStatus = deriveUserRoomMembershipStatus(url, h) + const at = $derived(parseInt($page.url.searchParams.get("at") || String(now()))) const showRoomDetail = () => pushModal(RoomDetail, {url, h}) @@ -166,7 +168,7 @@ } const manageScrollPosition = () => { - showScrollButton = Math.abs(element?.scrollTop || 0) > 1500 + showScrollButton = Boolean(at) || Math.abs(element?.scrollTop || 0) > 1500 const newMessages = document.getElementById("new-messages") @@ -210,8 +212,7 @@ const scrollToBottom = () => { if ($page.url.searchParams.get("at")) { - at = now() - start() + goto($page.url.pathname, {replaceState: true}) } else { element?.scrollTo({top: 0, behavior: "smooth"}) } @@ -223,7 +224,6 @@ let isProgrammaticScroll = $state(false) let loadingBackward = $state(true) let loadingForward = $state(true) - let at = $state(parseInt($page.url.searchParams.get("at") || String(now()))) let share = $state(popKey("share")) let parent: TrustedEvent | undefined = $state() let element: HTMLElement | undefined = $state() @@ -353,6 +353,7 @@ observer.observe(chatCompose!) observer.observe(dynamicPadding!) + start() return () => { diff --git a/src/routes/spaces/[relay]/chat/+page.svelte b/src/routes/spaces/[relay]/chat/+page.svelte index 60a07433..e41ff886 100644 --- a/src/routes/spaces/[relay]/chat/+page.svelte +++ b/src/routes/spaces/[relay]/chat/+page.svelte @@ -1,6 +1,7 @@