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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user