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..cf1e47387 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"; @@ -91,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 ? ( + + ) : ( + + )} + ) : ( ) diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index b2769981a..7a3c518cd 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -736,6 +736,42 @@ background: var(--affordance-accent-prominent); } +/* 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; + height: 22px; + flex: 0 0 22px; + 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(--text-subtle); +} +@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..3beb6f9ad --- /dev/null +++ b/src/bundled/channels/DmTypingBadge.test.tsx @@ -0,0 +1,288 @@ +// @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 { RelayEvent } from "../../features/relay/events"; +import type { LiveCallbacks } from "../../features/relay/live"; +import { createRelaySession } from "../../features/relay/session"; +import { + keypair, + profile, + 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; +const ROOT = "f".repeat(64); + +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...", + ); +}); + +/** 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(), + bot = keypair(), + carol = 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, bot.pubkey, carol.pubkey], + }, + { + id: "other", + name: "Other", + 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 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, + bot.pubkey, + carol.pubkey, + ]), + ), + ), + ); + const pulse = ( + key: Key, + channelId: string, + { + at = NOW, + root, + kind = 20002, + }: { at?: number; root?: string; kind?: number } = {}, + ) => + signed(key, { + kind, + content: kind === 9 ? "hi" : "", + created_at: at, + tags: [["h", channelId], ...(root ? [["e", root, "", "reply"]] : [])], + }); + 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. + receive(pulse(viewer, "dm"), pulse(viewer, "group"), pulse(alice, "stream")); + expect(allTyping()).toEqual([]); + + const idle = [commits.get("other"), commits.get("stream")]; + 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); + // 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")); + expect(typing("dm")).toBeInTheDocument(); + expect(typing("other")).toBeNull(); + + // Group DM: thread and top-level scopes combine; one signer appears once. + 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...`, + ); + // 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 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(); + expect(identity("group")).not.toBeNull(); + + // 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. + receive(pulse(alice, "dm")); + expect(allTyping()).toEqual([]); + receive(pulse(alice, "dm", { at: NOW + 8 })); + expect(typing("dm")).toBeInTheDocument(); + act(() => live.state({ status: "retrying", routes: [] })); + expect(allTyping()).toEqual([]); + + 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 new file mode 100644 index 000000000..53f41151d --- /dev/null +++ b/src/bundled/channels/DmTypingBadge.tsx @@ -0,0 +1,80 @@ +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"; + +// 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 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. + 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 ? ( + + {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( + 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 children; + // Reuse already available names; optional typing must not trigger profile reads. + const label = formatTypingLabel( + humans.map((pubkey) => + resolveName(pubkey, profiles.get(pubkey)?.name ?? pubkey.slice(0, 10)), + ), + ); + return ( + + + + + + ); +}