diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index c330915f6..d7a2f88d6 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -4,6 +4,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { Channel } from "@/shared/api/types"; type TypingIndicatorRowProps = { @@ -75,7 +76,27 @@ export function TypingIndicatorRow({ className="bg-background/95 px-4 py-2 sm:px-6" data-testid="message-typing-indicator" > -
+
+
+ {typingPubkeys.map((pubkey, index) => { + const profile = profiles?.[pubkey]; + const label = labels[index] ?? pubkey.slice(0, 8); + return ( +
0 ? " -ml-1.5" : ""}`} + data-testid="message-typing-avatar" + > + +
+ ); + })} +

; +type TypingEntry = { expiresAt: number; firstSeenAt: number }; +type TypingState = Record; const TYPING_INDICATOR_TTL_MS = 8_000; const TYPING_PRUNE_INTERVAL_MS = 1_000; @@ -19,9 +20,9 @@ function pruneTypingState(state: TypingState, now = Date.now()) { let changed = false; const next: TypingState = {}; - for (const [pubkey, expiresAt] of Object.entries(state)) { - if (expiresAt > now) { - next[pubkey] = expiresAt; + for (const [pubkey, entry] of Object.entries(state)) { + if (entry.expiresAt > now) { + next[pubkey] = entry; continue; } @@ -83,10 +84,18 @@ export function useChannelTyping( return; } - setTypingByPubkey((current) => ({ - ...pruneTypingState(current), - [typingPubkey]: Date.now() + TYPING_INDICATOR_TTL_MS, - })); + const now = Date.now(); + setTypingByPubkey((current) => { + const pruned = pruneTypingState(current, now); + const existing = pruned[typingPubkey]; + return { + ...pruned, + [typingPubkey]: { + expiresAt: now + TYPING_INDICATOR_TTL_MS, + firstSeenAt: existing?.firstSeenAt ?? now, + }, + }; + }); }); // biome-ignore lint/correctness/useExhaustiveDependencies: channel changes should clear local typing state @@ -185,7 +194,7 @@ export function useChannelTyping( return useMemo( () => Object.entries(typingByPubkey) - .sort((left, right) => right[1] - left[1]) + .sort((left, right) => left[1].firstSeenAt - right[1].firstSeenAt) .map(([pubkey]) => pubkey), [typingByPubkey], ); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 3227201c9..9c7dc77fb 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -230,6 +230,72 @@ test("shows and clears typing indicators for active channel bots", async ({ await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0); }); +test("typing indicator shows avatars and maintains stable name order", async ({ + page, +}) => { + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.waitForTimeout(300); + + // Alice starts typing first + await page.evaluate((pubkey) => { + window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({ + channelName: "general", + pubkey, + }); + }, TEST_IDENTITIES.alice.pubkey); + + await expect(page.getByTestId("message-typing-indicator")).toBeVisible(); + await expect( + page.getByTestId("message-typing-indicator-label"), + ).toContainText("alice is typing"); + + // Verify avatar is rendered for the typing user + const avatars = page + .getByTestId("message-typing-indicator") + .locator("[data-testid='message-typing-avatar']"); + await expect(avatars).toHaveCount(1); + + // Bob starts typing second + await page.evaluate((pubkey) => { + window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({ + channelName: "general", + pubkey, + }); + }, TEST_IDENTITIES.bob.pubkey); + + await expect( + page.getByTestId("message-typing-indicator-label"), + ).toContainText("alice and bob are typing"); + await expect(avatars).toHaveCount(2); + + // Alice re-broadcasts — order should stay "alice and bob", not flip + await page.evaluate((pubkey) => { + window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({ + channelName: "general", + pubkey, + }); + }, TEST_IDENTITIES.alice.pubkey); + + await expect( + page.getByTestId("message-typing-indicator-label"), + ).toContainText("alice and bob are typing"); + + // Bob re-broadcasts — order should still stay "alice and bob" + await page.evaluate((pubkey) => { + window.__SPROUT_E2E_EMIT_MOCK_TYPING__?.({ + channelName: "general", + pubkey, + }); + }, TEST_IDENTITIES.bob.pubkey); + + await expect( + page.getByTestId("message-typing-indicator-label"), + ).toContainText("alice and bob are typing"); +}); + test("sidebar shows unread indicator for newly active channels", async ({ page, }) => {