feat: typing indicator avatars + stable name order (#267)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Taylor Ho
2026-04-08 11:45:00 -10:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 94ad235a0a
commit 063ce331dd
3 changed files with 106 additions and 10 deletions
@@ -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"
>
<div className="mx-auto flex w-full max-w-4xl items-center">
<div className="mx-auto flex w-full max-w-4xl items-center gap-2">
<div className="flex flex-shrink-0 items-center">
{typingPubkeys.map((pubkey, index) => {
const profile = profiles?.[pubkey];
const label = labels[index] ?? pubkey.slice(0, 8);
return (
<div
key={pubkey}
className={`relative h-5 w-5 flex-shrink-0 rounded-full ring-1 ring-background${index > 0 ? " -ml-1.5" : ""}`}
data-testid="message-typing-avatar"
>
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
label={label}
className="h-5 w-5 rounded-full text-[8px]"
iconClassName="h-3 w-3"
/>
</div>
);
})}
</div>
<p
className="truncate text-sm text-muted-foreground"
data-testid="message-typing-indicator-label"
@@ -9,7 +9,8 @@ import {
KIND_TYPING_INDICATOR,
} from "@/shared/constants/kinds";
type TypingState = Record<string, number>;
type TypingEntry = { expiresAt: number; firstSeenAt: number };
type TypingState = Record<string, TypingEntry>;
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],
);
+66
View File
@@ -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,
}) => {