Perf optimizations for chat rooms
This commit is contained in:
parent
8953b0e04d
commit
6fed1019df
11 changed files with 128 additions and 72 deletions
15
.agents/settings.json
Normal file
15
.agents/settings.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
}
|
||||
|
||||
const onKeyDown = (e: Event) => {
|
||||
if (instance.onKeyDown(e)) {
|
||||
if (instance?.onKeyDown(e)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
}
|
||||
|
||||
const onKeyDown = (e: Event) => {
|
||||
if (instance.onKeyDown(e)) {
|
||||
if (instance?.onKeyDown(e)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
127
src/app/feeds.ts
127
src/app/feeds.ts
|
|
@ -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)
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue