Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/browser-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,11 @@ cursor responses for explicit paging tests; accidentally entering that path is n
valid resize setup. `upper()` establishes above-bottom reading with at most four
real wheel gestures, requiring progress and settled distance >400px. It does not
measure exact wheel displacement. Partial-input and blocked-input controls guard
that setup; same-ID/Y <4px and bottom <4px assertions remain unchanged. No retries
that setup; same-ID/Y <4px and bottom <4px assertions remain unchanged. Anchor
capture prefers a whole paragraph, falling back to the first intersecting row
when tall messages leave only clipped paragraphs. A deterministic helper control
covers that geometry, whole-paragraph preference, offscreen rejection, and rejection
of an actual anchor displacement. No retries
or additional WebKit exclusions are used. The underlying Linux WebKit single-wheel
shortfall remains unattributed; this setup change does not fix or explain it.

Expand Down
21 changes: 21 additions & 0 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,24 @@ Exhausted attempts, unsupported pauses, other route/connection failures and
unfinished finite roster/head failures still show a warning and recovery action.
This presentation policy does not increase quotas or guarantee that another
client sharing the account cannot cause a refusal.

## Receive-only typing

Typing indicators show recent activity from another participant in the current
channel or thread. They identify the signer, including agents; they do not imply
online presence, ongoing agent execution, or a promise of an answer.

The session owns this temporary state through `session.typing`. Shared conversation
composers use the same indicator so channel and thread views agree about who is
active and where. Names reuse already loaded profiles; displaying activity does
not start extra reads or connections.

A message clears its author's preceding activity in that conversation. Brief
post-message suppression prevents late activity from immediately bringing the
indicator back; otherwise silence lets it expire. Only current live activity can
activate it, never fetched history. Losing access, disconnecting, clearing the
cache or replacing the session clears it too, so stale activity cannot carry into
another conversation or account.

Typing stays out of message history, unread counts and persistent storage. This
is receive-only: opening or using a composer does not publish typing activity.
13 changes: 13 additions & 0 deletions src/features/messages/MessageComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TypingIndicator } from "./TypingIndicator";
import { ArrowUp, X } from "lucide-react";
import {
useEffect,
Expand Down Expand Up @@ -297,6 +298,11 @@ function Composer({
if (!outbox?.supports(9))
return (
<footer className={styles.composer}>
<TypingIndicator
session={session}
channelId={channelId}
threadRootId={threadRootId}
/>
This relay connection supports reading only.
</footer>
);
Expand All @@ -311,6 +317,13 @@ function Composer({
send();
}}
>
{!disabled && (
<TypingIndicator
session={session}
channelId={channelId}
threadRootId={threadRootId}
/>
)}
<label className="sr-only" htmlFor={inputId}>
{label}
</label>
Expand Down
11 changes: 11 additions & 0 deletions src/features/messages/Messages.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -710,3 +710,14 @@ button.avatar:focus-visible,
height: 100%;
object-fit: cover;
}

.typing {
color: var(--text-muted);
font-size: calc(12px * var(--buzz-text-scale, 1));
line-height: 1.5;
height: 1lh;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
43 changes: 43 additions & 0 deletions src/features/messages/TypingIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useSyncExternalStore } from "react";
import type { RelaySession } from "../relay/session";
import styles from "./Messages.module.css";

/** Shared presentation only. Mounting more consumers creates no relay work. */
export function TypingIndicator({
session,
channelId,
threadRootId,
}: {
session: RelaySession;
channelId: string;
threadRootId?: string | undefined;
}) {
const entries = useSyncExternalStore(
session.typing.subscribe,
session.typing.snapshot,
);
const profiles = useSyncExternalStore(
session.profiles.subscribe,
session.profiles.snapshot,
);
const matching = entries.filter(
(entry) =>
entry.channelId === channelId && entry.threadRootId === threadRootId,
);
// Reuse already available names; optional typing must not trigger profile reads.
const names = matching
.slice(0, 3)
.map(({ pubkey }) => profiles.get(pubkey)?.name ?? pubkey.slice(0, 10));
const others = matching.length - names.length;
return (
<div className={styles.typing}>
{matching.length > 0 && (
<span role="status" aria-label="Typing activity">
{names.join(", ")}
{others > 0 ? ` and ${others} others` : ""}
{matching.length === 1 ? " is typing…" : " are typing…"}
</span>
)}
</div>
);
}
115 changes: 114 additions & 1 deletion src/features/relay/live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
subscribeRelayTraffic,
type LiveCallbacks,
} from "./live";
import { keypair, message, signed } from "./testing";
import { keypair, message, roster, signed, scriptedTransport } from "./testing";
import { createRelaySession } from "./session";
class Socket {
readyState = 1;
onmessage?: (event: { data: string }) => Promise<void>;
Expand Down Expand Up @@ -696,3 +697,115 @@ it("observer route is optional, live-only at dispatch/retry, separately fenced a
h.owner.dispose();
expect(vi.getTimerCount()).toBe(0);
});

it("admits signed typing only on its authenticated channel route, without extra subscriptions", async () => {
vi.useFakeTimers();
const h = setup();
await h.first.auth();
await vi.advanceTimersByTimeAsync(750);
const requests = h.first.requests();
expect(requests).toHaveLength(4);
const route = requests[2];
assert.exists(route);
expect(route[2].kinds).toContain(20002);
const event = signed(keypair(), {
kind: 20002,
content: "",
tags: [["h", "a"]],
});
await h.first.receive(["EVENT", requests[0]?.[1], event]);
await h.first.receive(["EVENT", requests[3]?.[1], event]);
expect(h.callbacks.receive).not.toHaveBeenCalled();
await h.first.receive(["EVENT", route[1], event]);
expect(h.callbacks.receive).toHaveBeenCalledExactlyOnceWith([event], {
channelId: "a",
phase: "replay",
});
await h.first.receive([
"EVENT",
route[1],
{ ...event, sig: "0".repeat(128) },
]);
expect(h.callbacks.receive).toHaveBeenCalledTimes(1);
h.owner.dispose();
expect(vi.getTimerCount()).toBe(0);
});

it("keeps misrouted activity out of accessible conversations; session rejects ambiguous scope", async () => {
vi.useFakeTimers();
const h = setup();
const relay = keypair();
const wire = scriptedTransport(h.key.pubkey, relay.pubkey);
const owner = createRelaySession({
...wire.transport,
subscribe(callbacks) {
h.callbacks.receive.mockImplementation(callbacks.receive);
h.callbacks.state.mockImplementation(callbacks.state);
return h.owner;
},
});
// Establish both accessible channels before starting their live routes.
h.callbacks.receive([
roster(relay, "a", [h.key.pubkey]),
roster(relay, "b", [h.key.pubkey]),
]);
await h.first.auth();
await vi.advanceTimersByTimeAsync(750);
const requests = h.first.requests();
const a = requests.find((r) => r[2]["#h"]?.includes("a"));
const b = requests.find((r) => r[2]["#h"]?.includes("b"));
assert.exists(a);
assert.exists(b);
const agent = keypair();
const activity = (tags: string[][]) =>
signed(agent, {
kind: 20002,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags,
});
const pulse = activity([["h", "b"]]);
const snapshot = owner.session.typing.snapshot;
for (const route of [requests[0], requests[1], a]) {
await h.first.receive(["EVENT", route?.[1], pulse]);
expect(snapshot()).toEqual([]);
}
for (const tags of [
[],
[["h"]],
[["h", "bad channel"]],
[["h", "denied"]],
[
["h", "b"],
["h", "b"],
],
[
["h", "b"],
["h", "a"],
],
[
["h", "b"],
["e", "bad", "", "reply"],
],
]) {
const event = activity(tags);
await h.first.receive(["EVENT", b[1], event]);
expect(snapshot()).toEqual([]);
// Even a host that has already discarded route metadata cannot activate these.
h.callbacks.receive([event]);
expect(snapshot()).toEqual([]);
}
await h.first.receive(["EVENT", b[1], pulse]);
expect(snapshot()).toEqual([{ channelId: "b", pubkey: agent.pubkey }]);
// Once route metadata is gone, the session can only use the event's own scope.
const other = activity([["h", "a"]]);
await h.first.receive(["EVENT", b[1], other]);
expect(snapshot()).toHaveLength(1);
h.callbacks.receive([other]);
expect(snapshot()).toEqual([
{ channelId: "b", pubkey: agent.pubkey },
{ channelId: "a", pubkey: agent.pubkey },
]);
owner.dispose();
expect(vi.getTimerCount()).toBe(0);
});
14 changes: 13 additions & 1 deletion src/features/relay/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ type Route = {
quotaRetries: number;
deadline?: ReturnType<typeof setTimeout>;
};
const CHANNEL_KINDS = [9, 40002, 40099, 40003, 5, 9005, 7, 39000, 39002, 39005];
const CHANNEL_KINDS = [
9, 40002, 40099, 40003, 5, 9005, 7, 39000, 39002, 39005, 20002,
];
/** One authenticated socket, independently established channel routes and two explicit globals.
* Recent replay is opportunistic: finite reads own catch-up and history bounds. */
export function subscribeRelayTraffic(
Expand Down Expand Up @@ -458,6 +460,16 @@ export function subscribeRelayTraffic(
fail(route, "Relay supplied invalid live traffic");
return;
}
// Preserve route consistency before receive() discards the subscription ID.
// The typing owner separately checks scope shape and channel access.
if (
incoming.kind === 20002 &&
(!route.channelId ||
!incoming.tags.some(
([name, value]) => name === "h" && value === route.channelId,
))
)
return;
if (route.status === "pending") route.count++;
if (route.id === "observer") {
if (
Expand Down
42 changes: 39 additions & 3 deletions src/features/relay/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
browserReadStateStorage,
type ReadStateStorage,
} from "./read-state-storage";
import { createTyping } from "./typing";
import { createUnread } from "./unread";
import type { IncomingListener, IncomingMessage } from "./incoming";
import { objectBody } from "./body";
Expand Down Expand Up @@ -84,6 +85,14 @@ export function createRelaySession(
else listener();
};
let canAccess: (id: string) => boolean = () => true;
const typing = createTyping(
transport?.viewer ?? "",
(id) =>
!closed &&
canAccess(id) &&
channels.queries.list().channels.some((channel) => channel.id === id),
notify,
);
const recent = new ByteLru<{ event: RelayEvent; revision: number }>(
4096,
8 * 1024 * 1024,
Expand Down Expand Up @@ -159,6 +168,7 @@ export function createRelaySession(
revoking++;
try {
accessEpoch++;
typing.clear();
// Filters cannot tell us ownership of broad/ID/reference reads. Infrequent
// authoritative access loss cancels them all, not merely explicit #h reads.
requests.invalidate();
Expand Down Expand Up @@ -240,10 +250,13 @@ export function createRelaySession(
events.some((event) => [39000, 39002].includes(event.kind))
)
channels.acceptDiscovery(events);
// Ephemeral typing and observer telemetry never enter retained content views.
const visible = events
.filter((event) => event.kind !== OBSERVER_KIND)
.filter((event) => event.kind !== OBSERVER_KIND && event.kind !== 20002)
.filter(visibility(events));
const epoch = accessEpoch;
typing.accept(visible);
if (closed || epoch !== accessEpoch) return [];
profiling.measure(
"events.reconcile",
events[0]?.id ?? "empty",
Expand Down Expand Up @@ -589,6 +602,7 @@ export function createRelaySession(
incomingListeners.delete(listener);
};
},
typing: typing.capability,
unread: unread.capability,
sidebarPreferences: sidebarPreferences.queries,
live,
Expand Down Expand Up @@ -964,9 +978,28 @@ export function createRelaySession(
)
)
refreshRoster();
const visible = accept(events);
if (closed || !candidates.size || !provenance?.channelId) return;
const epoch = accessEpoch;
const generation = liveGeneration;
const visible = accept(events);
// Completion subscribers can synchronously clear, revoke or retire this
// live delivery. Do not admit its remaining pulses into the new lifetime.
if (
!closed &&
epoch === accessEpoch &&
generation === liveGeneration &&
liveSnapshot.status === "connected"
)
typing.accept(
events.filter((event) => event.kind === 20002),
true,
);
if (
closed ||
epoch !== accessEpoch ||
!candidates.size ||
!provenance?.channelId
)
return;
const delivered = new Set<string>();
const incoming: readonly IncomingMessage[] = Object.freeze(
visible.flatMap((event) => {
Expand Down Expand Up @@ -996,6 +1029,7 @@ export function createRelaySession(
state(snapshot) {
if (closed) return;
activity.state(snapshot);
if (snapshot.status !== "connected") typing.clear();
if (
snapshot.status !== "connected" &&
liveSnapshot.status === "connected"
Expand Down Expand Up @@ -1059,6 +1093,7 @@ export function createRelaySession(
accessEpoch++;
cacheClearEpoch++;
activity.clear();
typing.clear();
sidebarPreferences.clear();
// New windows must not yield to or receive errors from retired owners.
catchups.clear();
Expand All @@ -1076,6 +1111,7 @@ export function createRelaySession(
},
dispose() {
closed = true;
typing.dispose();
lifetime.abort();
activity.dispose();
sidebarPreferences.dispose();
Expand Down
Loading
Loading