mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Claude Opus 4.6
parent
94ad235a0a
commit
063ce331dd
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user