Perf optimizations for chat rooms

This commit is contained in:
Jon Staab 2026-07-27 17:13:14 -07:00
parent 8953b0e04d
commit 6fed1019df
11 changed files with 128 additions and 72 deletions

15
.agents/settings.json Normal file
View file

@ -0,0 +1,15 @@
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"statusMessage": "Checking added comments",
"command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && git diff HEAD -U0 -- src | grep -E '^\\+' | grep -v '^+++' | grep -E '//|/\\*|\\*/|<!--' | sed 's/^+[[:space:]]*//' | jq -R -s 'select(length > 0) | {systemMessage: (\"Comment lines added to src/ — check each against the Cleanup Pass rule in AGENTS.md. Only genuinely surprising things: a workaround, an external constraint, an invariant not visible from the code. Not restating the next line, not justifying an ordinary decision.\\n\\n\" + .)}' 2>/dev/null || true"
}
]
}
]
}
}

View file

@ -129,6 +129,7 @@ Conventions below.
- Use `type Props` instead of interface when defining props for svelte components.
- When a component's value/prop shape mirrors a subset of an existing type, derive it with `Pick`/`Partial` and `export` that type from the component's `<script module>` (e.g. a `Values` type) for callers to import, instead of re-enumerating its sub-properties.
- Avoid pass-through functions except when the wrapper is part of an abstraction. `x => y()` is not ok, but `x => this.impl.y()` is ok, for example.
- Use `call` from `@welshman/lib` instead of an IIFE: `const x = call(() => {...})`, not `const x = (() => {...})()`. It reads left-to-right and drops the wrapping parens and the leading-semicolon hazard. `call` ignores extra arguments, so it also works directly as a callback — `unsubscribers.forEach(call)`.
- For durations and timestamps, use `@welshman/lib`'s time helpers and constants (`MINUTE`, `HOUR`, `DAY`, `WEEK`, `MONTH`, `YEAR`) rather than raw milliseconds: `int(5, MINUTE)` for a duration in seconds, `ago(5, MINUTE)` for a past timestamp, `now()` for the current one, and `ms()`/`ms(int(...))` only where a browser API needs milliseconds. So `checkedAt < ago(5, MINUTE)` instead of `checkedAt < Date.now() - 300_000`. Write these count-first (`int(3, MONTH)`, `ago(2, WEEK)`) — the declared parameter order is `(unit, count)`, but the product is the same either way and every call site in this repo reads count-first. Nostr timestamps are seconds, so prefer `now()` over `Date.now()` for anything stored on an event.
- When declaring variables in a svelte component, the order should generally be: props, constants derived from props/state, functions declared with `const`, mutable variables declared with `let`, effects, onMount. This order may vary due to dependencies, but should generally be adhered to.

View file

@ -45,7 +45,7 @@
}
const onKeyDown = (e: Event) => {
if (instance.onKeyDown(e)) {
if (instance?.onKeyDown(e)) {
e.preventDefault()
}
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {Router} from "@welshman/router"
import {LOCALE, secondsToDate} from "@welshman/lib"
import {call, LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {displayRelayUrl, toNostrURI} from "@welshman/util"
import {tracker} from "@welshman/app"
@ -31,13 +31,13 @@
const nprofile1 = toNostrURI(nip19.nprofileEncode({pubkey: event.pubkey, relays}))
const npub1 = nip19.npubEncode(event.pubkey)
const seenOn = tracker.getRelays(event.id)
const json = (() => {
const json = call(() => {
try {
return JSON.stringify(JSON.parse(event.content), null, 2)
} catch {
return event.content
}
})()
})
const copyLink = () => clip(nprofile1)
const copyPubkey = () => clip(npub1)
const copyJson = () => clip(json)

View file

@ -46,7 +46,7 @@
}
const onKeyDown = (e: Event) => {
if (instance.onKeyDown(e)) {
if (instance?.onKeyDown(e)) {
e.preventDefault()
}
}

View file

@ -13,6 +13,14 @@
const onClick = () => popover?.hide()
const onShow = () => {
visible = true
}
const onHidden = () => {
visible = false
}
const onMouseMove = ({clientX, clientY}: any) => {
if (popover) {
const {x, y, width, height} = popover.popper.getBoundingClientRect()
@ -24,16 +32,17 @@
}
let popover: Instance | undefined = $state()
let visible = $state(false)
</script>
<svelte:document onmousemove={onMouseMove} />
<svelte:document onmousemove={visible ? onMouseMove : undefined} />
<Button onclick={open} class="button button-xs button-neutral join-item">
<Tippy
bind:popover
component={RoomItemMenu}
props={{url, event, onClick}}
params={{trigger: "manual", interactive: true}}>
params={{trigger: "manual", interactive: true, onShow, onHidden}}>
<Icon icon={MenuDots} size={4} />
</Tippy>
</Button>

View file

@ -1,18 +1,6 @@
import {repository, tracker} from "@welshman/app"
import {writable} from "svelte/store"
import {
batch,
between,
call,
insertAt,
int,
now,
on,
sortBy,
uniqBy,
MONTH,
YEAR,
} from "@welshman/lib"
import {batch, between, call, int, now, on, sortBy, uniqBy, MONTH, YEAR} from "@welshman/lib"
import {EVENT_TIME, getAddress, getTagValue, matchFilters} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util"
import {mergeRepositoryUpdates, request} from "@welshman/net"
@ -74,22 +62,25 @@ export const makeFeed = ({
}
if (visible.length > 0) {
visible.sort((a, b) => a.created_at - b.created_at)
events.update($events => {
for (const event of visible) {
let inserted = false
for (let i = 0; i < $events.length; i++) {
if ($events[i].created_at > event.created_at) {
$events = insertAt(i, event, $events)
inserted = true
break
}
}
if (!inserted) {
$events = [...$events, event]
const merged: TrustedEvent[] = []
let i = 0
let j = 0
while (i < $events.length && j < visible.length) {
if ($events[i].created_at <= visible[j].created_at) {
merged.push($events[i++])
} else {
merged.push(visible[j++])
}
}
return $events
while (i < $events.length) merged.push($events[i++])
while (j < visible.length) merged.push(visible[j++])
return merged
})
}
}
@ -106,6 +97,22 @@ export const makeFeed = ({
insertEvents(drained)
}
const onTrackedId = batch(150, (ids: string[]) => {
const matching: TrustedEvent[] = []
for (const id of new Set(ids)) {
const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) {
matching.push(event)
}
}
if (matching.length > 0) {
insertEvents(matching)
}
})
const unsubscribers = [
on(
repository,
@ -135,11 +142,7 @@ export const makeFeed = ({
),
on(tracker, "add", (id: string, url: string) => {
if (relays.includes(url)) {
const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) {
insertEvents([event])
}
onTrackedId(id)
}
}),
]
@ -255,35 +258,51 @@ export const makeCalendarFeed = ({
if (valid.length === 0) return
for (const event of valid) {
seen.add(event.id)
}
valid.sort((a, b) => getStart(a) - getStart(b))
events.update($events => {
for (const event of valid) {
seen.add(event.id)
// Calendar events are addressable, so a new version supersedes the old one
const superseded = new Set(valid.map(getAddress))
const kept = $events.filter(e => !superseded.has(getAddress(e)))
const merged: TrustedEvent[] = []
let i = 0
let j = 0
const start = getStart(event)
const address = getAddress(event)
let handled = false
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) {
handled = true
break
}
if (getStart($events[i]) > start) {
$events = insertAt(i, event, $events)
handled = true
break
}
}
if (!handled) {
$events = [...$events.filter(e => getAddress(e) !== address), event]
while (i < kept.length && j < valid.length) {
if (getStart(kept[i]) <= getStart(valid[j])) {
merged.push(kept[i++])
} else {
merged.push(valid[j++])
}
}
return $events
while (i < kept.length) merged.push(kept[i++])
while (j < valid.length) merged.push(valid[j++])
return merged
})
}
const onTrackedId = batch(150, (ids: string[]) => {
const matching: TrustedEvent[] = []
for (const id of new Set(ids)) {
const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) {
matching.push(event)
}
}
if (matching.length > 0) {
insertEvents(matching)
}
})
const unsubscribers = [
on(
repository,
@ -308,11 +327,7 @@ export const makeCalendarFeed = ({
),
on(tracker, "add", (id: string, url: string) => {
if (relays.includes(url)) {
const event = repository.getEvent(id)
if (event && matchFilters(filters, event)) {
insertEvents([event])
}
onTrackedId(id)
}
}),
]

View file

@ -379,7 +379,7 @@ export const sync = (): StorageSync => {
deferredTimers.push(timeout)
}
const ready = (async () => {
const ready = call(async () => {
await db.connect()
await Promise.all([loadCriticalEvents(), loadCriticalRelays()])
@ -408,7 +408,7 @@ export const sync = (): StorageSync => {
scheduleDeferred(async () => {
addUnsubscriber(await initWrapManager())
})
})()
})
const unsubscribe = () => {
stopped = true

View file

@ -15,6 +15,14 @@
popover?.hide()
}
const onShow = () => {
visible = true
}
const onHidden = () => {
visible = false
}
const onMouseMove = throttle(300, ({clientX, clientY}: MouseEvent) => {
if (popover) {
const {x, y, width, height} = popover.popper.getBoundingClientRect()
@ -26,9 +34,10 @@
})
let popover: Instance | undefined = $state()
let visible = $state(false)
</script>
<svelte:document onmousemove={onMouseMove} />
<svelte:document onmousemove={visible ? onMouseMove : undefined} />
<Button onclick={open} class={props.class}>
<Tippy
@ -36,7 +45,7 @@
class="flex"
component={EmojiPicker}
props={{onClick}}
params={{trigger: "manual", interactive: true, ...tippyParams}}>
params={{trigger: "manual", interactive: true, ...tippyParams, onShow, onHidden}}>
{@render props.children?.()}
</Tippy>
</Button>

View file

@ -2,7 +2,8 @@
import "tippy.js/animations/shift-away.css"
import tippy from "tippy.js"
import {onMount, mount} from "svelte"
import type {Instance} from "tippy.js"
import {onMount, mount, unmount} from "svelte"
import {isMobile} from "@lib/html"
let {
@ -35,12 +36,19 @@
appendTo: document.querySelector(".tippy-target")!,
trigger: isMobile ? "click" : "mouseenter focus",
...params,
})
onShow: (tippyInstance: Instance) => {
instance ??= mount(component, {target, props: mountedProps})
instance = mount(component, {target, props: mountedProps})
return params.onShow?.(tippyInstance)
},
})
return () => {
popover?.destroy()
if (instance) {
unmount(instance)
}
}
})
</script>

View file

@ -40,8 +40,7 @@
@import "./tooltip.css";
/* SocketStatusIndicator builds its class as bg-{status.theme}, which the source
scanner can't see. These are the themes deriveSocketStatus can return. Kept
below every @import, since an @import after another at-rule is invalid. */
scanner can't see. Must stay below every @import to keep those valid. */
@source inline("bg-success bg-warning bg-error bg-gray-500");
@config "../../../tailwind.config.js";