From f7d2b1b53d9869abad0f8d10a08509dc200498fd Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Fri, 25 Sep 2026 14:42:01 -0700 Subject: [PATCH 1/4] feat(channels): show remote typing on sidebar dm rows Render animated typing dots on 1:1 and group DM rows from the session typing store, labelled with the block/buzz composer typing copy. Typing in any DM scope, including threads, counts once per signer. Rows select a per-channel key so unrelated typing changes do not re-render them. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau Signed-off-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- .../channels/ChannelSidebarItem.test.tsx | 3 + src/bundled/channels/ChannelSidebarItem.tsx | 42 ++-- src/bundled/channels/Channels.module.css | 28 +++ src/bundled/channels/DmTypingBadge.test.tsx | 192 ++++++++++++++++++ src/bundled/channels/DmTypingBadge.tsx | 65 ++++++ 5 files changed, 312 insertions(+), 18 deletions(-) create mode 100644 src/bundled/channels/DmTypingBadge.test.tsx create mode 100644 src/bundled/channels/DmTypingBadge.tsx diff --git a/src/bundled/channels/ChannelSidebarItem.test.tsx b/src/bundled/channels/ChannelSidebarItem.test.tsx index 66d418bab..adae15e33 100644 --- a/src/bundled/channels/ChannelSidebarItem.test.tsx +++ b/src/bundled/channels/ChannelSidebarItem.test.tsx @@ -17,6 +17,8 @@ import { ChannelSidebarItem } from "./ChannelSidebarItem"; afterEach(cleanup); +const noTyping = Object.freeze([]); + function owner(profiles = new Map()) { const listeners = new Set<() => void>(); let snapshot: UnreadSnapshot = { @@ -40,6 +42,7 @@ function owner(profiles = new Map()) { snapshot: () => profiles, subscribe: () => () => {}, }, + typing: { snapshot: () => noTyping, subscribe: () => () => {} }, unread: { snapshot: () => snapshot, subscribe: (_target: unknown, listener: () => void) => { diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index fb4de51f1..9845a0f9f 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -12,6 +12,7 @@ import { ChatCircleIcon } from "../../shared/design-system/icons/index"; import { channelIcon } from "../../features/channels/channel-icon"; import { ChannelActivityPopover } from "./ChannelActivityPopover"; import { ChannelSidebarRow } from "./ChannelSidebarRow"; +import { DmTypingBadge } from "./DmTypingBadge"; import { usePresenceStatus } from "../../features/presence/react"; import { UnreadBadge } from "./UnreadBadge"; import styles from "./Channels.module.css"; @@ -133,25 +134,30 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ ) } badge={ - - - - - {working && ( - + <> + {channel.channelType === "dm" && ( + )} - + + + + + {working && ( + + )} + + } wrapSelect={(trigger) => { const activity = ( diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index b2769981a..7d23cb6c6 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -736,6 +736,34 @@ background: var(--affordance-accent-prominent); } +.typing { + display: inline-flex; + flex-shrink: 0; + gap: var(--space-half); +} +.typing > span { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--affordance-accent-prominent); +} +@keyframes typing-pulse { + 50% { + opacity: 0.35; + } +} +@media (prefers-reduced-motion: no-preference) { + .typing > span { + animation: typing-pulse 1.4s ease-in-out infinite; + } + .typing > span:nth-child(2) { + animation-delay: 0.2s; + } + .typing > span:nth-child(3) { + animation-delay: 0.4s; + } +} + /* Match the adjacent small avatars without creating image/profile demand. */ .dmAvatar { display: inline-grid; diff --git a/src/bundled/channels/DmTypingBadge.test.tsx b/src/bundled/channels/DmTypingBadge.test.tsx new file mode 100644 index 000000000..e49922d95 --- /dev/null +++ b/src/bundled/channels/DmTypingBadge.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; +import { Profiler } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import type { LiveCallbacks } from "../../features/relay/live"; +import { createRelaySession } from "../../features/relay/session"; +import { + keypair, + roster, + scriptedTransport, + signed, + type Key, +} from "../../features/relay/testing"; +import { ChannelSidebarItem } from "./ChannelSidebarItem"; +import { formatTypingLabel } from "./DmTypingBadge"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const NOW = 1_800_000_000; + +it("formats the exact base composer copy", () => { + expect(formatTypingLabel(["A"])).toBe("A is typing..."); + expect(formatTypingLabel(["A", "B"])).toBe("A and B are typing..."); + expect(formatTypingLabel(["A", "B", "C"])).toBe("A, B, and C are typing..."); + expect(formatTypingLabel(["A", "B", "C", "D", "E"])).toBe( + "A, B, and 3 others are typing...", + ); +}); + +it("shows live remote typing on the matching 1:1 and group DM rows only", () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW * 1000); + const viewer = keypair(), + relay = keypair(), + alice = keypair(), + bob = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + let live!: LiveCallbacks; + const owner = createRelaySession({ + ...wire.transport, + subscribe(callbacks) { + live = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + }); + const channels: ChannelSummary[] = [ + { + id: "dm", + name: "Alice", + channelType: "dm", + participants: [alice.pubkey], + }, + { + id: "group", + name: "Group", + channelType: "dm", + participants: [alice.pubkey, bob.pubkey], + }, + { + id: "other", + name: "Other", + channelType: "dm", + participants: [bob.pubkey], + }, + { id: "stream", name: "Stream", channelType: "stream" }, + ]; + const commits = new Map(); + const renderRows = (selected?: string) => + channels.map((channel) => ( +
+ commits.set(id, (commits.get(id) ?? 0) + 1)} + > + {}} + draft={false} + draftSelected={false} + sessions={undefined} + onSelect={() => {}} + onNewSession={() => {}} + onOpenThread={() => {}} + /> + +
+ )); + const view = render(
{renderRows()}
); + live.state({ status: "connected", routes: [] }); + act(() => + live.receive( + channels.map((channel) => + roster(relay, channel.id, [viewer.pubkey, alice.pubkey, bob.pubkey]), + ), + ), + ); + const pulse = ( + key: Key, + channelId: string, + at = NOW, + root?: string, + kind = 20002, + ) => + signed(key, { + kind, + content: kind === 9 ? "hi" : "", + created_at: at, + tags: [["h", channelId], ...(root ? [["e", root, "", "reply"]] : [])], + }); + const name = (key: Key) => key.pubkey.slice(0, 10); + const typing = (row: string) => + within(screen.getByTestId(row)).queryByRole("img", { + name: /typing\.\.\.$/, + }); + const allTyping = () => screen.queryAllByRole("img", { name: /typing/ }); + + // Self events and regular channel rows never show sidebar typing. + act(() => + live.receive([ + pulse(viewer, "dm"), + pulse(viewer, "group"), + pulse(alice, "stream"), + ]), + ); + expect(allTyping()).toEqual([]); + + const idle = [commits.get("other"), commits.get("stream")]; + act(() => live.receive([pulse(alice, "dm")])); + // Only the affected row re-renders. + expect([commits.get("other"), commits.get("stream")]).toEqual(idle); + const badge = typing("dm"); + expect(badge).toHaveAttribute("aria-label", `${name(alice)} is typing...`); + expect(badge).toHaveAttribute("title", `${name(alice)} is typing...`); + expect(allTyping()).toHaveLength(1); + + // Switching the selected conversation keeps typing on its own row. + view.rerender(
{renderRows("other")}
); + expect(typing("dm")).toBeInTheDocument(); + expect(typing("other")).toBeNull(); + + // Group DM: thread and top-level scopes combine; one signer appears once. + const root = "f".repeat(64); + act(() => + live.receive([ + pulse(alice, "group"), + pulse(alice, "group", NOW, root), + pulse(bob, "group", NOW, root), + ]), + ); + expect(typing("group")).toHaveAttribute( + "aria-label", + `${name(alice)} and ${name(bob)} are typing...`, + ); + // Alice's thread message clears only that scope; her top-level typing remains. + act(() => live.receive([pulse(alice, "group", NOW, root, 9)])); + expect(typing("group")).toHaveAttribute( + "aria-label", + `${name(alice)} and ${name(bob)} are typing...`, + ); + // Bob's thread message stops Bob only. + act(() => live.receive([pulse(bob, "group", NOW, root, 9)])); + expect(typing("group")).toHaveAttribute( + "aria-label", + `${name(alice)} is typing...`, + ); + + // Expiry: visible until eight seconds after the last signed pulse. + act(() => vi.advanceTimersByTime(7_999)); + expect(typing("dm")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1)); + expect(allTyping()).toEqual([]); + + // Stale pulses are ignored; disconnect clears live typing. + act(() => live.receive([pulse(alice, "dm", NOW)])); + expect(allTyping()).toEqual([]); + act(() => live.receive([pulse(alice, "dm", NOW + 8)])); + expect(typing("dm")).toBeInTheDocument(); + act(() => live.state({ status: "retrying", routes: [] })); + expect(allTyping()).toEqual([]); + + view.unmount(); + owner.dispose(); +}); diff --git a/src/bundled/channels/DmTypingBadge.tsx b/src/bundled/channels/DmTypingBadge.tsx new file mode 100644 index 000000000..7a66f30cd --- /dev/null +++ b/src/bundled/channels/DmTypingBadge.tsx @@ -0,0 +1,65 @@ +import { useSyncExternalStore } from "react"; +import { useChannelIdentityNames } from "../../features/identity-names/react"; +import type { RelaySession } from "../../features/relay/session"; +import styles from "./Channels.module.css"; + +// Exact block/buzz composer typing copy (TypingIndicatorRow formatTypingLabel). +export function formatTypingLabel(names: readonly string[]) { + if (names.length === 1) return `${names[0]} is typing...`; + if (names.length === 2) return `${names[0]} and ${names[1]} are typing...`; + if (names.length === 3) + return `${names[0]}, ${names[1]}, and ${names[2]} are typing...`; + return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`; +} + +/** Remote typing anywhere in a DM, including its threads, from the session store. */ +export function DmTypingBadge({ + session, + channelId, +}: { + session: RelaySession; + channelId: string; +}) { + // Select a per-row primitive so unrelated typing changes do not re-render + // this row. Pubkeys are hex; a signer typing in several scopes appears once. + const typing = useSyncExternalStore(session.typing.subscribe, () => { + const pubkeys = new Set(); + for (const entry of session.typing.snapshot()) + if (entry.channelId === channelId) pubkeys.add(entry.pubkey); + return [...pubkeys].join(","); + }); + const pubkeys = typing ? typing.split(",") : []; + // Idle rows read no names or profiles. + return pubkeys.length ? ( + + ) : null; +} + +function TypingDots({ + session, + channelId, + pubkeys, +}: { + session: RelaySession; + channelId: string; + pubkeys: readonly string[]; +}) { + const resolveName = useChannelIdentityNames(session, channelId); + const profiles = useSyncExternalStore( + session.profiles.subscribe, + session.profiles.snapshot, + ); + // Reuse already available names; optional typing must not trigger profile reads. + const label = formatTypingLabel( + pubkeys.map((pubkey) => + resolveName(pubkey, profiles.get(pubkey)?.name ?? pubkey.slice(0, 10)), + ), + ); + return ( + + + + + + ); +} From 4aaa33fee1b6b6bb508126b27b93caa46138bc4b Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Fri, 25 Sep 2026 14:52:54 -0700 Subject: [PATCH 2/4] fix(channels): exclude known agents from dm typing badge Known agents are represented by the Agent working signal, so the DM row typing label names humans only. Classification uses already-loaded profiles in the active renderer; unclassified signers keep the pubkey fallback until a loaded profile marks them as agents. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau Signed-off-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- src/bundled/channels/DmTypingBadge.test.tsx | 200 ++++++++++++++------ src/bundled/channels/DmTypingBadge.tsx | 8 +- 2 files changed, 151 insertions(+), 57 deletions(-) diff --git a/src/bundled/channels/DmTypingBadge.test.tsx b/src/bundled/channels/DmTypingBadge.test.tsx index e49922d95..f6c49e089 100644 --- a/src/bundled/channels/DmTypingBadge.test.tsx +++ b/src/bundled/channels/DmTypingBadge.test.tsx @@ -4,10 +4,12 @@ import { act, cleanup, render, screen, within } from "@testing-library/react"; import { Profiler } from "react"; import { afterEach, expect, it, vi } from "vitest"; import type { ChannelSummary } from "../../features/relay/contracts"; +import type { RelayEvent } from "../../features/relay/events"; import type { LiveCallbacks } from "../../features/relay/live"; import { createRelaySession } from "../../features/relay/session"; import { keypair, + profile, roster, scriptedTransport, signed, @@ -22,6 +24,7 @@ afterEach(() => { }); const NOW = 1_800_000_000; +const ROOT = "f".repeat(64); it("formats the exact base composer copy", () => { expect(formatTypingLabel(["A"])).toBe("A is typing..."); @@ -32,13 +35,16 @@ it("formats the exact base composer copy", () => { ); }); -it("shows live remote typing on the matching 1:1 and group DM rows only", () => { +/** Real session typing store, fed through live callbacks, rendered in real rows. */ +function fixture() { vi.useFakeTimers(); vi.setSystemTime(NOW * 1000); const viewer = keypair(), relay = keypair(), alice = keypair(), - bob = keypair(); + bob = keypair(), + bot = keypair(), + carol = keypair(); const wire = scriptedTransport(viewer.pubkey, relay.pubkey); let live!: LiveCallbacks; const owner = createRelaySession({ @@ -59,7 +65,7 @@ it("shows live remote typing on the matching 1:1 and group DM rows only", () => id: "group", name: "Group", channelType: "dm", - participants: [alice.pubkey, bob.pubkey], + participants: [alice.pubkey, bob.pubkey, bot.pubkey, carol.pubkey], }, { id: "other", @@ -67,48 +73,61 @@ it("shows live remote typing on the matching 1:1 and group DM rows only", () => channelType: "dm", participants: [bob.pubkey], }, + { id: "agent", name: "Bot", channelType: "dm", participants: [bot.pubkey] }, { id: "stream", name: "Stream", channelType: "stream" }, ]; const commits = new Map(); - const renderRows = (selected?: string) => - channels.map((channel) => ( -
- commits.set(id, (commits.get(id) ?? 0) + 1)} - > - {}} - draft={false} - draftSelected={false} - sessions={undefined} - onSelect={() => {}} - onNewSession={() => {}} - onOpenThread={() => {}} - /> - -
- )); - const view = render(
{renderRows()}
); + const rows = (selected?: string) => ( +
+ {channels.map((channel) => ( +
+ commits.set(id, (commits.get(id) ?? 0) + 1)} + > + {}} + draft={false} + draftSelected={false} + sessions={undefined} + onSelect={() => {}} + onNewSession={() => {}} + onOpenThread={() => {}} + /> + +
+ ))} +
+ ); + const view = render(rows()); live.state({ status: "connected", routes: [] }); act(() => live.receive( channels.map((channel) => - roster(relay, channel.id, [viewer.pubkey, alice.pubkey, bob.pubkey]), + roster(relay, channel.id, [ + viewer.pubkey, + alice.pubkey, + bob.pubkey, + bot.pubkey, + carol.pubkey, + ]), ), ), ); const pulse = ( key: Key, channelId: string, - at = NOW, - root?: string, - kind = 20002, + { + at = NOW, + root, + kind = 20002, + }: { at?: number; root?: string; kind?: number } = {}, ) => signed(key, { kind, @@ -116,25 +135,60 @@ it("shows live remote typing on the matching 1:1 and group DM rows only", () => created_at: at, tags: [["h", channelId], ...(root ? [["e", root, "", "reply"]] : [])], }); - const name = (key: Key) => key.pubkey.slice(0, 10); + const receive = (...events: RelayEvent[]) => act(() => live.receive(events)); + /** Another consumer loads profiles; the badge itself never acquires them. */ + const loadProfiles = async (events: RelayEvent[]) => { + const done = owner.session.profiles.ensure(events.map((e) => e.pubkey)); + await act(() => vi.advanceTimersByTimeAsync(0)); + const read = wire.pending.findIndex((entry) => + entry.filters.some((filter) => filter.kinds?.includes(0)), + ); + expect(read).toBeGreaterThanOrEqual(0); + wire.pending.splice(read, 1)[0]?.respond(events); + await act(() => done); + }; const typing = (row: string) => within(screen.getByTestId(row)).queryByRole("img", { name: /typing\.\.\.$/, }); const allTyping = () => screen.queryAllByRole("img", { name: /typing/ }); + return { + keys: { viewer, alice, bob, bot, carol }, + live, + owner, + view, + rows, + commits, + pulse, + receive, + loadProfiles, + typing, + allTyping, + }; +} +const name = (key: Key) => key.pubkey.slice(0, 10); + +it("shows live remote typing on the matching 1:1 and group DM rows only", () => { + const { + keys, + live, + owner, + view, + rows, + commits, + pulse, + receive, + typing, + allTyping, + } = fixture(); + const { viewer, alice, bob } = keys; // Self events and regular channel rows never show sidebar typing. - act(() => - live.receive([ - pulse(viewer, "dm"), - pulse(viewer, "group"), - pulse(alice, "stream"), - ]), - ); + receive(pulse(viewer, "dm"), pulse(viewer, "group"), pulse(alice, "stream")); expect(allTyping()).toEqual([]); const idle = [commits.get("other"), commits.get("stream")]; - act(() => live.receive([pulse(alice, "dm")])); + receive(pulse(alice, "dm")); // Only the affected row re-renders. expect([commits.get("other"), commits.get("stream")]).toEqual(idle); const badge = typing("dm"); @@ -143,35 +197,36 @@ it("shows live remote typing on the matching 1:1 and group DM rows only", () => expect(allTyping()).toHaveLength(1); // Switching the selected conversation keeps typing on its own row. - view.rerender(
{renderRows("other")}
); + view.rerender(rows("other")); expect(typing("dm")).toBeInTheDocument(); expect(typing("other")).toBeNull(); // Group DM: thread and top-level scopes combine; one signer appears once. - const root = "f".repeat(64); - act(() => - live.receive([ - pulse(alice, "group"), - pulse(alice, "group", NOW, root), - pulse(bob, "group", NOW, root), - ]), + receive( + pulse(alice, "group"), + pulse(alice, "group", { root: ROOT }), + pulse(bob, "group", { root: ROOT }), ); expect(typing("group")).toHaveAttribute( "aria-label", `${name(alice)} and ${name(bob)} are typing...`, ); - // Alice's thread message clears only that scope; her top-level typing remains. - act(() => live.receive([pulse(alice, "group", NOW, root, 9)])); + // Each completion clears only its own scope: Alice's top-level message leaves + // her thread scope (and thread-only Bob) visible. + receive(pulse(alice, "group", { kind: 9 })); expect(typing("group")).toHaveAttribute( "aria-label", `${name(alice)} and ${name(bob)} are typing...`, ); - // Bob's thread message stops Bob only. - act(() => live.receive([pulse(bob, "group", NOW, root, 9)])); + // Bob's thread message clears thread-only Bob; Alice's thread scope remains. + receive(pulse(bob, "group", { root: ROOT, kind: 9 })); expect(typing("group")).toHaveAttribute( "aria-label", `${name(alice)} is typing...`, ); + // Alice's thread message clears her last scope. + receive(pulse(alice, "group", { root: ROOT, kind: 9 })); + expect(typing("group")).toBeNull(); // Expiry: visible until eight seconds after the last signed pulse. act(() => vi.advanceTimersByTime(7_999)); @@ -180,9 +235,9 @@ it("shows live remote typing on the matching 1:1 and group DM rows only", () => expect(allTyping()).toEqual([]); // Stale pulses are ignored; disconnect clears live typing. - act(() => live.receive([pulse(alice, "dm", NOW)])); + receive(pulse(alice, "dm")); expect(allTyping()).toEqual([]); - act(() => live.receive([pulse(alice, "dm", NOW + 8)])); + receive(pulse(alice, "dm", { at: NOW + 8 })); expect(typing("dm")).toBeInTheDocument(); act(() => live.state({ status: "retrying", routes: [] })); expect(allTyping()).toEqual([]); @@ -190,3 +245,38 @@ it("shows live remote typing on the matching 1:1 and group DM rows only", () => view.unmount(); owner.dispose(); }); + +it("labels human typing only; known agents keep the Agent working signal", async () => { + const { keys, owner, view, pulse, receive, loadProfiles, typing } = fixture(); + const { alice, bot, carol } = keys; + await loadProfiles([ + profile(bot, { name: "Bot", is_agent: true }), + profile(alice, { name: "Alice" }), + ]); + + // Agent-only typing: no typing badge, working signal preserved. + receive(pulse(bot, "agent")); + expect(typing("agent")).toBeNull(); + expect( + within(screen.getByTestId("agent")).getByRole("img", { + name: "Agent working", + }), + ).toBeInTheDocument(); + + // Mixed group: the label names humans only. + receive(pulse(bot, "group"), pulse(alice, "group")); + expect(typing("group")).toHaveAttribute("aria-label", "Alice is typing..."); + + // An unclassified signer keeps the pubkey fallback until its loaded profile + // identifies an agent, then leaves the label reactively. + receive(pulse(carol, "group", { root: ROOT })); + expect(typing("group")).toHaveAttribute( + "aria-label", + `Alice and ${name(carol)} are typing...`, + ); + await loadProfiles([profile(carol, { name: "Carol", is_agent: true })]); + expect(typing("group")).toHaveAttribute("aria-label", "Alice is typing..."); + + view.unmount(); + owner.dispose(); +}); diff --git a/src/bundled/channels/DmTypingBadge.tsx b/src/bundled/channels/DmTypingBadge.tsx index 7a66f30cd..ea206b4ef 100644 --- a/src/bundled/channels/DmTypingBadge.tsx +++ b/src/bundled/channels/DmTypingBadge.tsx @@ -12,7 +12,7 @@ export function formatTypingLabel(names: readonly string[]) { return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`; } -/** Remote typing anywhere in a DM, including its threads, from the session store. */ +/** Remote human typing anywhere in a DM, including its threads, from the session store. */ export function DmTypingBadge({ session, channelId, @@ -49,9 +49,13 @@ function TypingDots({ session.profiles.subscribe, session.profiles.snapshot, ); + // Human-only: known agents are represented by the Agent working signal. + // Unclassified signers stay visible until a loaded profile marks them. + const humans = pubkeys.filter((pubkey) => !profiles.get(pubkey)?.isAgent); + if (!humans.length) return null; // Reuse already available names; optional typing must not trigger profile reads. const label = formatTypingLabel( - pubkeys.map((pubkey) => + humans.map((pubkey) => resolveName(pubkey, profiles.get(pubkey)?.name ?? pubkey.slice(0, 10)), ), ); From 700079aa1605663db5b12e9d8b85734a477d132b Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 09:13:54 -0700 Subject: [PATCH 3/4] feat(channels): show dm typing dots in place of the avatar Render the sidebar typing indicator over the DM identity slot, replacing the avatar or group count while a remote human types, instead of beside the unread/working stack. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- src/bundled/channels/ChannelSidebarItem.tsx | 98 ++++++++++----------- src/bundled/channels/Channels.module.css | 7 +- src/bundled/channels/DmTypingBadge.test.tsx | 6 ++ src/bundled/channels/DmTypingBadge.tsx | 21 +++-- 4 files changed, 77 insertions(+), 55 deletions(-) diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index 9845a0f9f..cf1e47387 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -92,32 +92,37 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ channel={channel} dmVisualSpacing={channel.channelType === "dm"} icon={ - channel.channelType === "dm" && channel.participants?.length === 1 ? ( - - - - ) : channel.channelType === "dm" && - (channel.participants?.length ?? 0) > 1 ? ( - + channel.channelType === "dm" ? ( + + {channel.participants?.length === 1 ? ( + + + + ) : (channel.participants?.length ?? 0) > 1 ? ( + + ) : ( + + )} + ) : ( ) @@ -134,30 +139,25 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ ) } badge={ - <> - {channel.channelType === "dm" && ( - - )} - - - - - {working && ( - - )} + + + - + {working && ( + + )} + } wrapSelect={(trigger) => { const activity = ( diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index 7d23cb6c6..dd6ee1aed 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -736,9 +736,14 @@ background: var(--affordance-accent-prominent); } +/* Occupies the DM identity box while it replaces the avatar or count. */ .typing { display: inline-flex; - flex-shrink: 0; + width: 22px; + height: 22px; + flex: 0 0 22px; + align-items: center; + justify-content: center; gap: var(--space-half); } .typing > span { diff --git a/src/bundled/channels/DmTypingBadge.test.tsx b/src/bundled/channels/DmTypingBadge.test.tsx index f6c49e089..3beb6f9ad 100644 --- a/src/bundled/channels/DmTypingBadge.test.tsx +++ b/src/bundled/channels/DmTypingBadge.test.tsx @@ -195,6 +195,11 @@ it("shows live remote typing on the matching 1:1 and group DM rows only", () => expect(badge).toHaveAttribute("aria-label", `${name(alice)} is typing...`); expect(badge).toHaveAttribute("title", `${name(alice)} is typing...`); expect(allTyping()).toHaveLength(1); + // The dots replace the DM identity (avatar or count) while typing. + const identity = (row: string) => + screen.getByTestId(row).querySelector("[data-dm-identity]"); + expect(identity("dm")).toBeNull(); + expect(identity("group")).not.toBeNull(); // Switching the selected conversation keeps typing on its own row. view.rerender(rows("other")); @@ -227,6 +232,7 @@ it("shows live remote typing on the matching 1:1 and group DM rows only", () => // Alice's thread message clears her last scope. receive(pulse(alice, "group", { root: ROOT, kind: 9 })); expect(typing("group")).toBeNull(); + expect(identity("group")).not.toBeNull(); // Expiry: visible until eight seconds after the last signed pulse. act(() => vi.advanceTimersByTime(7_999)); diff --git a/src/bundled/channels/DmTypingBadge.tsx b/src/bundled/channels/DmTypingBadge.tsx index ea206b4ef..53f41151d 100644 --- a/src/bundled/channels/DmTypingBadge.tsx +++ b/src/bundled/channels/DmTypingBadge.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from "react"; +import { useSyncExternalStore, type ReactNode } from "react"; import { useChannelIdentityNames } from "../../features/identity-names/react"; import type { RelaySession } from "../../features/relay/session"; import styles from "./Channels.module.css"; @@ -12,13 +12,18 @@ export function formatTypingLabel(names: readonly string[]) { return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`; } -/** Remote human typing anywhere in a DM, including its threads, from the session store. */ +/** + * Remote human typing anywhere in a DM, including its threads, from the session + * store. While someone types, the dots replace the DM identity (Slack-style). + */ export function DmTypingBadge({ session, channelId, + children, }: { session: RelaySession; channelId: string; + children: ReactNode; }) { // Select a per-row primitive so unrelated typing changes do not re-render // this row. Pubkeys are hex; a signer typing in several scopes appears once. @@ -31,18 +36,24 @@ export function DmTypingBadge({ const pubkeys = typing ? typing.split(",") : []; // Idle rows read no names or profiles. return pubkeys.length ? ( - - ) : null; + + {children} + + ) : ( + children + ); } function TypingDots({ session, channelId, pubkeys, + children, }: { session: RelaySession; channelId: string; pubkeys: readonly string[]; + children: ReactNode; }) { const resolveName = useChannelIdentityNames(session, channelId); const profiles = useSyncExternalStore( @@ -52,7 +63,7 @@ function TypingDots({ // Human-only: known agents are represented by the Agent working signal. // Unclassified signers stay visible until a loaded profile marks them. const humans = pubkeys.filter((pubkey) => !profiles.get(pubkey)?.isAgent); - if (!humans.length) return null; + if (!humans.length) return children; // Reuse already available names; optional typing must not trigger profile reads. const label = formatTypingLabel( humans.map((pubkey) => From 7b3eea2bb8bd7265a00745e7b4ebaf5b2f0ab138 Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 09:16:16 -0700 Subject: [PATCH 4/4] fix(channels): use subtle chip roles for dm typing dots Match the participant-count chip in the DM identity slot: subtle affordance background with subtle text-colored dots instead of the accent color. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- src/bundled/channels/Channels.module.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index dd6ee1aed..7a3c518cd 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -736,7 +736,8 @@ background: var(--affordance-accent-prominent); } -/* Occupies the DM identity box while it replaces the avatar or count. */ +/* Occupies the DM identity box while it replaces the avatar or count; shares + the participant-count chip's subtle roles. */ .typing { display: inline-flex; width: 22px; @@ -745,12 +746,14 @@ align-items: center; justify-content: center; gap: var(--space-half); + border-radius: var(--radius-pill); + background: var(--affordance-subtle); } .typing > span { width: 4px; height: 4px; border-radius: 50%; - background: var(--affordance-accent-prominent); + background: var(--text-subtle); } @keyframes typing-pulse { 50% {