diff --git a/dev/relay-broker-fixture.test.mjs b/dev/relay-broker-fixture.test.mjs index 044703b8..81379bdf 100644 --- a/dev/relay-broker-fixture.test.mjs +++ b/dev/relay-broker-fixture.test.mjs @@ -177,7 +177,7 @@ test("actual fixture fails an unclassified 503 even with no console event", asyn fixture(async (app) => { await publication(app, false); }), - ).rejects.toThrow("Unclassified presence publication 503"); + ).rejects.toThrow("Unclassified presence publication 404/503"); }); test("same endpoint disposal console cannot hide a separate unclassified response", async () => { @@ -186,7 +186,7 @@ test("same endpoint disposal console cannot hide a separate unclassified respons console503(page, await publication(app, true)); await publication(app, false); }), - ).rejects.toThrow("Unclassified presence publication 503"); + ).rejects.toThrow("Unclassified presence publication 404/503"); }); test("one classified response permits one console diagnostic", async () => { @@ -232,10 +232,197 @@ test.each([ req.emit("end"); res.end(body); expect(() => evidence.assertPublications()).toThrow( - "Unclassified presence publication 503", + "Unclassified presence publication 404/503", ); }); +async function retiredPublication(app, community = "primary") { + const headers = { Origin: app.origin, "Content-Type": "application/json" }; + const controller = new AbortController(); + let streamId; + try { + const response = await fetch(`${app.origin}/api/relay/primary/stream`, { + method: "POST", + headers, + body: JSON.stringify({ channels: [] }), + signal: controller.signal, + }); + expect(response.status).toBe(200); + streamId = response.headers.get("x-buzz-live-id"); + } finally { + controller.abort(); + } + await until(() => + app.report.brokerRequests.some( + (record) => record.url.endsWith("/stream") && record.close, + ), + ); + const endpoint = `${app.origin}/api/relay/${community}/stream-presence-publish`; + const response = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ streamId, status: "online" }), + signal: AbortSignal.timeout(3000), + }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: "Live stream no longer available", + }); + expect(app.report.presencePublications).toEqual([]); + return { endpoint, streamId }; +} + +const console404 = (page, url) => + page.emit("console", { + type: () => "error", + text: () => + "Failed to load resource: the server responded with a status of 404 (Not Found)", + location: () => ({ url }), + }); + +test.each([false, true])( + "actual fixture accounts an already-retired publication 404 (console %s)", + async (console) => { + await fixture(async (app, page) => { + const { endpoint, streamId } = await retiredPublication(app); + expect(app.report.presencePublicationResponses).toEqual([ + expect.objectContaining({ + url: endpoint, + streamId, + status: 404, + disposed: false, + retired: true, + body: { error: "Live stream no longer available" }, + }), + ]); + if (console) console404(page, endpoint); + }); + }, +); + +test("retirement in another community cannot classify a real publication 404", async () => { + await expect( + fixture(async (app) => { + await retiredPublication(app, "secondary"); + }), + ).rejects.toThrow("Unclassified presence publication 404/503"); +}); + +test.each([404, 503])( + "retired 404 console cannot hide an unclassified %s at the same endpoint", + async (status) => { + await expect( + fixture(async (app, page) => { + const { endpoint } = await retiredPublication(app); + console404(page, endpoint); + if (status === 503) await publication(app, false); + else { + const response = await fetch(endpoint, { + method: "POST", + headers: { Origin: app.origin, "Content-Type": "application/json" }, + body: JSON.stringify({ + streamId: "0".repeat(32), + status: "online", + }), + }); + expect(response.status).toBe(404); + await response.text(); + } + }), + ).rejects.toThrow("Unclassified presence publication 404/503"); + }, +); + +test.each(["duplicate", "wrong status", "wrong endpoint"])( + "retired publication accounting rejects %s console evidence", + async (failure) => { + await expect( + fixture(async (app, page) => { + const { endpoint } = await retiredPublication(app); + if (failure === "duplicate") { + console404(page, endpoint); + console404(page, endpoint); + } else if (failure === "wrong status") console503(page, endpoint); + else console404(page, `${endpoint}/other`); + }), + ).rejects.toThrow(); + }, +); + +// The real fixture tests above prove production wiring. These controlled response +// boundaries cover impossible/malformed evidence without altering broker behavior. +test.each([ + { name: "current stream", retirement: "never" }, + { name: "unknown stream", requestId: "b".repeat(32) }, + { name: "malformed stream ID", requestId: "bad", streamId: "bad" }, + { name: "different relay", streamPath: "/api/relay/secondary/stream" }, + { name: "retirement during upload", retirement: "after arrival" }, + { name: "retirement after response", retirement: "after response" }, + { name: "missing body", body: undefined }, + { name: "malformed JSON", body: "not-json" }, + { name: "wrong error", body: JSON.stringify({ error: "other" }) }, + { + name: "extra field", + body: JSON.stringify({ + error: "Live stream no longer available", + accepted: true, + }), + }, + { name: "truncated response", truncated: true }, +])("publication 404 fails closed for $name", (options) => { + const report = { brokerRequests: [] }; + const evidence = brokerEvidence(report, new Set()); + const streamId = options.streamId ?? "a".repeat(32); + const request = (url) => { + const req = new EventEmitter(); + req.url = url; + req.headers = { host: "127.0.0.1:1234" }; + return req; + }; + const response = () => { + const res = new EventEmitter(); + res.statusCode = 200; + res.writeHead = () => {}; + res.end = () => {}; + res.getHeader = () => undefined; + return res; + }; + const stream = response(); + evidence.middleware( + request(options.streamPath ?? "/api/relay/primary/stream"), + stream, + () => {}, + ); + stream.writeHead(200, { "X-Buzz-Live-ID": streamId }); + const retirement = options.retirement ?? "before arrival"; + if (retirement === "before arrival") stream.emit("close"); + const req = request("/api/relay/primary/stream-presence-publish"); + const res = response(); + evidence.middleware(req, res, () => {}); + if (retirement === "after arrival") stream.emit("close"); + req.emit("data", JSON.stringify({ streamId: options.requestId ?? streamId })); + req.emit("end"); + res.statusCode = 404; + if (options.truncated) res.emit("close"); + else + res.end( + Object.hasOwn(options, "body") + ? options.body + : JSON.stringify({ error: "Live stream no longer available" }), + ); + if (retirement === "after response") stream.emit("close"); + expect(report.presencePublicationResponses).toHaveLength(1); + expect(() => evidence.assertPublications()).toThrow( + "Unclassified presence publication 404/503", + ); + expect( + evidence.consoleFilter()( + "Failed to load resource: the server responded with a status of 404 (Not Found)", + "http://127.0.0.1:1234/api/relay/primary/stream-presence-publish", + ), + ).toBe(false); +}); + test("passive completion evidence preserves Server-Timing and distinguishes an unfinished close", async () => { const report = { brokerRequests: [] }; const evidence = brokerEvidence(report, new Set()); diff --git a/docs/browser-testing.md b/docs/browser-testing.md index 0ad69e29..c7e91c2c 100644 --- a/docs/browser-testing.md +++ b/docs/browser-testing.md @@ -40,7 +40,8 @@ bin/pnpm test:browser tests/browser/layout.spec.mjs --no-deps bin/pnpm test:browser --no-deps --workers=1 ``` -The default local gate runs `channel-opening.spec.mjs` and `scroll.spec.mjs` first, +The default local gate runs `channel-opening.spec.mjs`, `scroll.spec.mjs`, +`presence-contention.spec.mjs`, and `presence-control.spec.mjs` first, one browser/worker at a time, through the `chromium-measurements` → `webkit-measurements` dependency chain. Only then may functional journeys run with two workers. This preserves timing/heap samples without unrelated browser @@ -198,6 +199,37 @@ The separate `channel-opening.test.ts` exercises catch-up ownership and terminal retry states through the production session. A held-response reproducer establishes a failure mechanism; it does not on its own identify a live incident's cause. +## Presence contention controls + +`presence-contention.spec.mjs` forces a real signed presence conflict through the +session directory, verified reader, and production broker, then holds the upstream +snapshot. Immediately afterward it submits the actual composer or opens a cold +channel. The foreground host request must arrive within **200ms** of snapshot +start (inside the old 500ms residual pacing window); broker admission must be +under **100ms**, with host-arrival-to-upstream under **150ms**. These generous +regression ceilings detect the inherited pacing interval, not universal zero-cost +service. Send keeps the snapshot pending; cold navigation can abort it but must +not inherit its consumed credit. Optimistic message text is not a send receipt. + +`presence-control.spec.mjs` repeats both journeys with only the directory and +publisher disabled in a test build. It preserves normal reader, broker, signer, +outbox, cache and connection behavior and asserts that no presence traffic occurs. +Both files run serially in each measurement engine. Evidence separates runner-clock +host/upstream timestamps, observed browser request events, browser resource timing, +Server-Timing admission/auth/network, and the exported production read/write profiler. +These clocks must not be subtracted across domains. + +The opt-in policy relay numerically enforces audited reference defaults: shared +API **300/min**, WS REQ/EVENT **50/5s**, plus **60 EVENT/min**. Counters are shared +across sockets for the same community/viewer; HTTP publications share the API +counter with snapshots. `dev/policy-relay.test.mjs` runs once under Vitest (no browser) and deliberately exceeds each budget +and requires correlated rejection, community isolation, and window expiry. This +positive control prevents an empty refusal list from masquerading as enforcement. +The short browser journeys do not saturate the client's maximum envelope or prove +capacity isolation; colocated transport tests cover those contracts. Other devices, +lower deployed quotas, CPU contention, SQL/Redis cost, and native GUI remain outside +this offline model. + ## DM label recovery `dm-labels.spec.mjs` builds the actual page and uses the production broker with diff --git a/docs/presence.md b/docs/presence.md new file mode 100644 index 00000000..bdb0c8b3 --- /dev/null +++ b/docs/presence.md @@ -0,0 +1,170 @@ +# Presence + +`session.presence` is a volatile current-value directory, separate from message +retention, generic event reconciliation, and the durable outbox. Its values are +`online`, `away`, `offline`, and `unknown`. Unknown is not evidence of being offline. + +Message bylines distinguish status without color: Online is a filled circle, Away +is a filled square, Offline is a hollow circle, and Unknown has a dotted outline. +The profile panel also displays the status text; all indicators have an accessible label. + +## Ownership and traffic + +The session requires explicit transport `presence: true` and live support before +starting observation or publishing. The production broker supplies both snapshot +admission and shared-socket controls. Ordinary direct-signed reads remain supported, +but that alternate adapter does not enable partial presence through its generic +subscription. Unsupported transports stay Unknown and start no publisher/renewals. + +- A timeline or thread owns one demand handle for its viewport plus a 160px margin; + the profile panel owns its selected author. Avatar indicators only subscribe to + per-author values. Repeated authors share work. Without IntersectionObserver, + demand falls back to the bounded rendered window, not all retained history. +- A session accepts at most 256 unique demanded authors and 64 surface handles. + Over-cap demand is rejected, not broadened. Empty or hidden demand removes its + observer route and snapshot work; it does not stop the availability publisher. +- Presence shares the existing authenticated socket. Author interests have a + one-second minimum REQ interval, at most two overlapping presence routes, and + share the total 1,024 subscription-slot ceiling with normal routes. Presence + updates do not restart the message stream and yield to foreground work. Two + presence slots remain reserved; channel capacity is 1,020, or 1,019 when the + independent Agent Activity observer is enabled. +- One background snapshot owner uses the existing verified reader. Reads have a + five-second cooldown after completion or cancellation, not enqueue time: shared + reader/broker queue delays must not compress successive actual reads. Repeated + changes coalesce without moving an already scheduled deadline. Stable demand + uses one 60–65 second backstop; failures wait at least 60 seconds and honor longer + relay retry advice. These are per-session budgets, not a fleet-wide rate limit. +- Same-status renewals do not trigger a read or UI notification per heartbeat. + Conflicting evidence becomes unknown and requests one rate-bounded confirmation. + No separate socket, per-avatar polling, heartbeat journal, or durable replay. + +## Foreground isolation + +Optional presence does not spend ordinary dispatch credit or occupy ordinary +capacity. Limits are additive, not a claim that the old total concurrency is +unchanged: + +| Boundary | Ordinary work | Optional presence | +| --- | --- | --- | +| Host/principal HTTP starts | 500ms minimum | 5s minimum snapshot interval | +| Session reader | 3 active, at most 1 background; 128 distinct pending | 1 snapshot | +| Development broker | 6 process-wide inflight | 1 process-wide snapshot | +| Host/principal WS starts | 250ms minimum REQ interval | 1s REQ / 5s EVENT minimum | +| Live setup per stream | 4 pending ordinary routes | 1 candidate presence route | + +The shared host principal is keyed by relay origin and viewer, not by stream. +Foreground wins ready HTTP ties; presence setup/publication yields to foreground +setup. Two presence wires (confirmed plus candidate) remain inside the existing +1,024 subscription ceiling. Ordinary HTTP preparation and queue budgets stay 128; +one separate optional preparation lease spans signing, fetch/body and verification. +A cancelled consumer cannot free unresolved underlying optional work. The analogous +WS publication lease stays owned until signing settles, fences late completion, +and never makes ordinary signing wait behind the optional lease. + +Snapshot classification requires exactly one filter with only `kinds`, `authors` +and `limit`: kind `[20001]`, 1–256 unique lowercase full 64-hex authors, and limit +exactly their count. Priority headers do not grant optional admission, and reader +normalization cannot upgrade malformed requests into that class. + +Server API cooldown remains shared by ordinary and optional HTTP work; WS cooldown +remains shared across ordinary/presence work and streams. HTTP and WS quota families +remain independent. Presence must not bypass a real shared quota rejection. + +The combined-budget test drives eight production broker transports over real local +HTTP for a full minute. The existing modeled relay charges all attempts across +callers and enforces first-call-anchored quota windows. Assertions allow at most +134 API charges/min, 27 combined REQ/EVENT charges per 5s, and 13 presence EVENTs/min +including setup and drain; lower throughput bounds prevent a vacuous pass. Exact +500ms/250ms/1s/5s start clocks are tested deterministically at their admission owners. +This bounds the modeled client-owned traffic, not requests from other devices or hosts. It is below the inspected reference defaults +(API300/min, WS50/5s, Messages60/min), not a deployed configuration guarantee. The +normal publisher still renews only every 60–65s, not every 5s. No local scheduler can +promise zero CPU, signer-provider, network, SQL/Redis cost or end-user latency. + +## Authority and lifecycle + +Live presence subjects come from the verified event author. Only a verified +relay-authored snapshot can use its single requested `p` tag as subject. A +successful bounded snapshot's omission means no current entry for that subject; +failed or obsolete reads do not establish offline. Snapshot `created_at` is the +relay's synthesis time, not heartbeat age or remaining lease duration. + +Per-author lifetime/revision and session generation guards fence conflicts, +removal/re-add, visibility changes, access/cache clearing, and disposal. They do +not create a globally ordered snapshot/stream protocol. Exact expiry and perfectly +current status cannot be inferred from the existing wire contract. + +Presence-only setup retries are bounded. After four failed attempts, unchanged +nonempty demand can remain Unknown across an automatic socket reconnect. One +explicit Live Retry resets that exhaustion whether authenticated or disconnected; +recovery still requires fresh authentication/EOSE and respects shared cooldowns. +Replacing or losing the browser broker stream invalidates its separate presence +readiness; ordinary connected frames cannot restore it. + +## Activity and publishing + +One app-level activity detector measures **Buzz input**, not operating-system +idle. Ten minutes without input produces Away (sampled every 30 seconds). +Window blur or switching communities is not Away. Each connected community/viewer +publisher sends its current status after startup, on status transitions, and +roughly every 60–65 seconds. It keeps one in-flight write and the latest desired +status; missed renewals are not replayed. Acceptance requires the matching socket +`OK`, not merely handing bytes to the broker. + +If stream disposal wins an in-flight publication's settlement, the development +broker preserves that typed cause as `code: "presence_owner_disposed"` on its +existing **503 / Presence publication unconfirmed** response. This describes why +confirmation stopped, not whether an EVENT was sent or accepted. Earlier rejection, +deadline, socket reset, caller abort, or local failure cannot be relabeled by later +disposal. No new wait, retry, round trip, or successful browser outcome is added. + +Browser fixtures record each publication 404/503 at the host response boundary, +including when browser cancellation hides its body or console diagnostic. A 503 +is classified only by the explicit disposal code with the unconfirmed body. A 404 +requires the exact missing-stream rejection and evidence that this same relay/stream +was already retired when the request arrived, not later during teardown. Neither +classification means delivery. All other recorded 404/503s fail independently of +console output. Each classified response permits at most one diagnostic matching +its endpoint and status, and all responses remain in the evidence. Host request +completion records distinguish `finish` from `close` and retain status/Server-Timing; +neither closing a response nor handing bytes to the host establishes relay delivery. + +Optional same-origin Web Locks coordinate one publisher per community/viewer; +BroadcastChannel shares recent local input. Unsupported hosts can publish once per +window. A frozen browser leader can miss renewals; neither mechanism coordinates +other devices. Connected retained communities continue renewing after navigation. +Ordinary teardown never publishes identity-wide Offline on another device's behalf. + +## Backend limits + +This client changes no relay storage or aggregation semantics. The audited relay +still performs a community lifecycle SQL lookup for a WS heartbeat, access queries +for HTTP snapshots, and SQL/pool work for `REQ limit:0`. Snapshots also require Redis +reads and relay signatures. Author scoping reduces delivered traffic/client work; +it does not remove candidate subscription matching or all backend work. + +The relay aggregates by identity with last-arrival-wins status, not device leases. +Pod-local final disconnect can clear another device's entry without offline fanout. +Removing these costs and ambiguities requires separate server work with preserved +authorization/lifecycle fencing; this implementation does not claim zero SQL, +zero polling, exact expiry, or distributed-device correctness. + +## Validation shape + +Owner tests live in `src/features/presence/` and relay presence tests. Browser +journeys are `tests/browser/presence.spec.mjs` (observer lifecycle and real same-origin +lock/activity handoff) and `presence-integration.spec.mjs` (production conversation, +broker, snapshots, conflict repair, and route teardown). The paired +`presence-contention.spec.mjs` / `presence-control.spec.mjs` measure real composer +send and cold-open admission with a held snapshot versus equivalent no-presence +owners. Colocated reader, broker and live tests separately hold capacity/signing +work; `dev/relay-broker-fixture.test.mjs` exercises combined eight-caller budgets; browser +journeys alone do not establish those bounds. `dev/policy-relay.test.mjs` proves that +the numerical quota fixture rejects deliberate overload. Channel-opening measurements +retain their existing warm-switch budget. + +These browser tests use isolated identities and modeled relay policy. Passing +native compilation/tests is not native GUI acceptance; none of these establishes +deployed SQL/Redis load or production latency. See [the contribution workflow](contributing.md) +for batch gates and [browser testing](browser-testing.md) for measurement limits. diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 8dce9f23..34643b54 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -62,6 +62,12 @@ locally authored event is **not proof of relay acceptance**. Signature-verified membership, bounds and persistence. Domain folds can consume local payloads, but must not let them manufacture relay-authored authority. +## Presence + +`session.presence` exposes volatile per-author status and surface-owned demand, +using the same authenticated socket and verified background reader without message +retention or outbox replay. See [presence ownership, traffic budgets and limits](presence.md). + ## Community emoji `session.emoji` owns the current community's kind-30030 `d=buzz:custom-emoji` diff --git a/src/app/services.test.ts b/src/app/services.test.ts index ff070391..cfdbcca2 100644 --- a/src/app/services.test.ts +++ b/src/app/services.test.ts @@ -121,8 +121,17 @@ async function openCommunities() { function expectHostStopped() { expect(signals.every((signal) => signal.aborted)).toBe(true); for (const stream of streams) expect(stream.close).toHaveBeenCalledTimes(1); - expect(document.addEventListener).toHaveBeenCalledTimes(3); - expect(document.removeEventListener).toHaveBeenCalledTimes(3); + // Three retained service visibility listeners plus one shared activity detector. + const added = vi.mocked(document.addEventListener).mock.calls; + const removed = vi.mocked(document.removeEventListener).mock.calls; + expect(added).toHaveLength(8); + expect(removed).toHaveLength(added.length); + for (const [type, listener] of added) + expect(removed.some(([name, fn]) => name === type && fn === listener)).toBe( + true, + ); + for (const type of ["pointerdown", "pointermove", "keydown", "wheel"]) + expect(added.filter(([name]) => name === type)).toHaveLength(1); expect(services.pages.snapshot()).toHaveLength(0); } diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 4259775a..ac5a5083 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -1,3 +1,7 @@ +import { + PresenceIndicator, + usePresenceDemand, +} from "../../features/presence/react"; import { useEffect, useMemo, @@ -45,6 +49,7 @@ function ProfileDetails({ pubkey: string; context: PanelProps["context"]; }) { + usePresenceDemand(session.presence, pubkey); const selection = useMemo( () => selectProfiles(session.profiles, [pubkey]), [session.profiles, pubkey], @@ -101,6 +106,7 @@ function ProfileDetails({ size="large" />

{name}

+ {profile?.about &&

{profile.about}

} {context?.canOpen(activity) && ( diff --git a/src/features/communities/service.ts b/src/features/communities/service.ts index ee7951ca..f7b8acd9 100644 --- a/src/features/communities/service.ts +++ b/src/features/communities/service.ts @@ -1,4 +1,5 @@ // FOUNDATION: Client identity and membership selection outlive community query sessions. +import { createPresenceActivity } from "../presence/activity"; import { Context } from "@deepseek-ai/cordis"; import { provideRelay, type RelayData } from "../relay/service"; import { connectBrokerTransport } from "../relay/transport"; @@ -22,6 +23,7 @@ const empty = (): Saved => ({ selected: null, }); export function createCommunities(ctx: Context, live: boolean) { + const activity = live ? createPresenceActivity() : undefined; let state: ClientSnapshot = { ...empty(), status: live ? "loading" : "unavailable", @@ -72,8 +74,10 @@ export function createCommunities(ctx: Context, live: boolean) { const acquire = (id: string) => { let session = sessions.get(id); if (!session) { - session = provideRelay(newScope(), (signal) => - connectBrokerTransport("", signal, id), + session = provideRelay( + newScope(), + (signal) => connectBrokerTransport("", signal, id), + activity?.activity, ); sessions.set(id, session); session.subscribe(() => { @@ -185,6 +189,7 @@ export function createCommunities(ctx: Context, live: boolean) { ctx.effect(() => () => { disposed = true; controller.abort(); + activity?.dispose(); listeners.clear(); relayListeners.clear(); return Promise.all(scopes.map((scope) => scope.fiber.dispose())); diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx index db09202f..fbbbb8e4 100644 --- a/src/features/messages/ChannelTimeline.test.tsx +++ b/src/features/messages/ChannelTimeline.test.tsx @@ -92,6 +92,12 @@ vi.mock("react", async (original) => ({ } }, })); +// These shallow geometry tests do not implement browser observer APIs. +// Presence's real observer/render lifecycle runs in tests/browser/presence.spec.mjs. +vi.mock("../presence/react", async (original) => ({ + ...(await original()), + usePresenceSurface: vi.fn(), +})); vi.mock("../relay/react", () => ({ useRowProfiles: () => new Map(), })); diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 9db2483c..522230ac 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -1,4 +1,5 @@ // biome-ignore-all lint/a11y/noNoninteractiveTabindex: The history region must support keyboard scrolling. +import { usePresenceSurface } from "../presence/react"; import { MembershipRow } from "./MembershipRow"; import { membershipRows } from "./membership-rows"; import type { ConversationExtensions } from "../conversation/contracts"; @@ -115,6 +116,7 @@ function Timeline({ const [focusedMessageId, setFocusedMessageId] = useState(); const focusedIndex = rows.findIndex((row) => row.id === focusedMessageId); const scroller = useRef(null); + usePresenceSurface(queries.presence, scroller); const handle = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); const width = size.width; @@ -488,6 +490,7 @@ function Timeline({ key={row.id} row={row} unread={queries.unread} + presence={queries.presence} extensions={extensions} profile={profiles.get(row.authorId)} participantProfiles={profiles} diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 9cc74e38..58083fc7 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -1,3 +1,5 @@ +import { PresenceIndicator } from "../presence/react"; +import type { PresenceQueries } from "../presence/directory"; import { memo, useCallback, useSyncExternalStore } from "react"; import type { UnreadCapability } from "../relay/unread"; import { profileTarget } from "../profiles/target"; @@ -13,6 +15,7 @@ import { usesLargeEmojiPresentation } from "./emoji-size"; export type MessageRowProps = { row: ChannelMessage; + presence?: PresenceQueries | undefined; unread?: UnreadCapability | undefined; extensions?: ConversationExtensions | undefined; profile: Profile | undefined; @@ -27,6 +30,7 @@ export type MessageRowProps = { export const MessageRow = memo(function MessageRow({ row, + presence, unread, extensions, profile, @@ -58,7 +62,7 @@ export const MessageRow = memo(function MessageRow({ const AvatarTag = clickable ? "button" : "div"; const emojiOnly = usesLargeEmojiPresentation(row.content, row.emoji); return ( -
+
{day && (
@@ -93,6 +97,9 @@ export const MessageRow = memo(function MessageRow({
{name} + {presence && ( + + )}