+
+ {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,
}) => {