mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
feat(composer): morph the avatar between typing group and pill on partition flips
The one strip transition that can't be an in-place relabel — an agent's first promotion from the merged typing group into a status pill — played as exit-here/enter-there: the avatar dropped out of the overlapping group while a new pill faded in beside it. Give both avatar homes a matching motion layoutId so the avatar MORPHS from the group into the new pill (geometry keeps the hop short: a new pill appends rightmost, directly adjacent to the trailing typing slot). - desktop/src/features/channels/ui/composerStripAvatarLayout.ts: new leaf module — ComposerStripAvatarScopeContext (per-strip namespace), useComposerStripAvatarLayoutId, and the shared morph spring. layoutIds are app-global and the channel strip + thread panel strip can be mounted at once, so ids bake in a per-strip scope string. Motion's own LayoutGroup id namespacing is deliberately NOT used: LayoutGroup re-measures every layout-animating member whenever any member updates, which turned each pill label ticker resize into a group-wide onLayoutAnimationStart storm that permanently held the pills' deferred label swaps (caught by the hover-freeze e2e test) - desktop/src/features/channels/ui/BotActivityBar.tsx: strip provides the scope via React.useId(); the pill avatar is wrapped in a motion.div carrying the shared-element id (dropped under reduced motion) - desktop/src/features/messages/ui/TypingIndicatorRow.tsx: avatar extracted into a TypingAvatar sub-component that reads the scope from context — no new props, and typing rows rendered outside a strip (context null) are unaffected - ChannelComposerActivityRow.tsx / ChannelPane.tsx: no call-site wiring needed beyond the strip itself (context flows through the typingIndicator slot) E2E: full pill/typing strip set passes (10/10 smoke), including the hover-freeze membership test that failed under the LayoutGroup approach. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -19,6 +19,11 @@ import {
|
||||
import { Shimmer } from "@/shared/ui/Shimmer";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { ComposerLiveActivityFeed } from "./ComposerLiveActivityFeed";
|
||||
import {
|
||||
COMPOSER_STRIP_AVATAR_MORPH_TRANSITION,
|
||||
ComposerStripAvatarScopeContext,
|
||||
useComposerStripAvatarLayoutId,
|
||||
} from "./composerStripAvatarLayout";
|
||||
import {
|
||||
deriveActivityPillLabel,
|
||||
deriveAgentWorkingOrder,
|
||||
@@ -255,6 +260,7 @@ function BotActivityAgentPill({
|
||||
const pillKey = agent.pubkey.toLowerCase();
|
||||
const open = hover.activePubkey === pillKey;
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const avatarMorphLayoutId = useComposerStripAvatarLayoutId(agent.pubkey);
|
||||
const transcript = useAgentTranscript(true, agent.pubkey);
|
||||
const headline = React.useMemo(
|
||||
() => deriveActivityPillLabel({ channelId, transcript }),
|
||||
@@ -325,12 +331,22 @@ function BotActivityAgentPill({
|
||||
|
||||
const pillContent = (
|
||||
<>
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="!h-5 !w-5 shrink-0 text-3xs"
|
||||
displayName={agent.name}
|
||||
size="xs"
|
||||
/>
|
||||
{/* Shared-element home: matches the typing group's avatar layoutId so
|
||||
a group→pill promotion morphs the avatar across (see
|
||||
composerStripAvatarLayout). Under reduced motion the id is dropped
|
||||
and the avatar mounts in place. */}
|
||||
<motion.div
|
||||
className="shrink-0"
|
||||
layoutId={shouldReduceMotion ? undefined : avatarMorphLayoutId}
|
||||
transition={COMPOSER_STRIP_AVATAR_MORPH_TRANSITION}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="!h-5 !w-5 text-3xs"
|
||||
displayName={agent.name}
|
||||
size="xs"
|
||||
/>
|
||||
</motion.div>
|
||||
{/* Ticker viewport: 16px tall with a matching 16px line box (leading-4
|
||||
overrides the button's leading-none). Inter's ascent+descent ink is
|
||||
~1.21em (~14.5px at text-xs) — taller than a leading-none line box —
|
||||
@@ -557,7 +573,9 @@ function AnimatedPillSlot({
|
||||
* compressing every pill into an unreadable sliver or wrapping the
|
||||
* fixed-height row. The scroll viewport bleeds across the mounting row's
|
||||
* gutter so the clip line and its fades sit at the container's visual
|
||||
* edges, never mid-row on a pill.
|
||||
* edges, never mid-row on a pill. The strip provides a per-instance avatar
|
||||
* shared-element scope (composerStripAvatarLayout) so an agent's avatar
|
||||
* morphs between the typing group and a pill on partition flips.
|
||||
*/
|
||||
export function BotActivityComposerAction({
|
||||
agents,
|
||||
@@ -569,6 +587,10 @@ export function BotActivityComposerAction({
|
||||
}: BotActivityBarProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const hover = useStripHoverPopover();
|
||||
// Namespaces the avatar shared-element ids to THIS strip: the channel
|
||||
// composer strip and the thread panel strip can be mounted at once, and a
|
||||
// morph must never travel between them (see composerStripAvatarLayout).
|
||||
const avatarScopeId = React.useId();
|
||||
// The MEMBERSHIP freeze engages as soon as the cursor is anywhere over the
|
||||
// bar — not only once a card opens — so pills can't enter/exit and slide
|
||||
// the strip under a cursor that is still traveling toward one. It also
|
||||
@@ -670,132 +692,134 @@ export function BotActivityComposerAction({
|
||||
const itemCount = orderedAgents.length + (typingIndicator == null ? 0 : 1);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: hover-only hold — keyboard focus drives the same hold via the pill triggers.
|
||||
<div
|
||||
// Full-bleed scroll viewport: -mx-5 cancels the mounting row's px-5
|
||||
// gutter (ChannelComposerActivityRow and the thread panel's
|
||||
// THREAD_PANEL_COMPOSER_GUTTER_CLASS — change together) and the
|
||||
// scroller re-adds it as internal padding. Overflowing pills then clip
|
||||
// at the CONTAINER's visual edge — where the edge fades sit — instead
|
||||
// of getting cut at the padded content box, 20px shy of the edge with
|
||||
// a dead gutter after the fade. At rest nothing changes: the padding
|
||||
// keeps the first pill aligned to the gutter.
|
||||
className="relative -mx-5 min-w-0 flex-1"
|
||||
// Exposes the membership hold to the e2e suite: width pins no longer
|
||||
// engage on bar hover alone, so tests need this to know the hold is
|
||||
// armed before mutating membership.
|
||||
data-hold={holdActive ? "true" : undefined}
|
||||
data-testid="bot-activity-strip"
|
||||
onMouseEnter={() => setBarHovered(true)}
|
||||
onMouseLeave={() => setBarHovered(false)}
|
||||
>
|
||||
{/* layoutScroll keeps the slot layout animations correct while the
|
||||
<ComposerStripAvatarScopeContext.Provider value={avatarScopeId}>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover-only hold — keyboard focus drives the same hold via the pill triggers. */}
|
||||
<div
|
||||
// Full-bleed scroll viewport: -mx-5 cancels the mounting row's px-5
|
||||
// gutter (ChannelComposerActivityRow and the thread panel's
|
||||
// THREAD_PANEL_COMPOSER_GUTTER_CLASS — change together) and the
|
||||
// scroller re-adds it as internal padding. Overflowing pills then clip
|
||||
// at the CONTAINER's visual edge — where the edge fades sit — instead
|
||||
// of getting cut at the padded content box, 20px shy of the edge with
|
||||
// a dead gutter after the fade. At rest nothing changes: the padding
|
||||
// keeps the first pill aligned to the gutter.
|
||||
className="relative -mx-5 min-w-0 flex-1"
|
||||
// Exposes the membership hold to the e2e suite: width pins no longer
|
||||
// engage on bar hover alone, so tests need this to know the hold is
|
||||
// armed before mutating membership.
|
||||
data-hold={holdActive ? "true" : undefined}
|
||||
data-testid="bot-activity-strip"
|
||||
onMouseEnter={() => setBarHovered(true)}
|
||||
onMouseLeave={() => setBarHovered(false)}
|
||||
>
|
||||
{/* layoutScroll keeps the slot layout animations correct while the
|
||||
strip is scrolled — motion measures positions relative to the
|
||||
scroll offset instead of jumping. The negative-margin/padding pair
|
||||
gives focus rings and pill shadows room inside the clip box
|
||||
without changing the row's height. px-5 restores the row gutter
|
||||
inside the full-bleed viewport (see the wrapper above). */}
|
||||
<motion.div
|
||||
className="-my-1 flex items-center overflow-x-auto overscroll-x-contain px-5 py-1 scrollbar-none [&::-webkit-scrollbar]:hidden"
|
||||
data-testid="bot-activity-strip-scroller"
|
||||
layoutScroll
|
||||
onScroll={updateFades}
|
||||
ref={scrollerRef}
|
||||
>
|
||||
<div
|
||||
// The overflow-fade hook resize-observes this wrapper as its proxy
|
||||
// for content size. min-w-max keeps it from collapsing to the
|
||||
// scroller's width (it is a shrinkable flex item), so late content
|
||||
// growth — label tickers swap AFTER a resize settles — still
|
||||
// changes an observed box and refreshes the fades. A lone item
|
||||
// instead gets min-w-0 so it can shrink with the container
|
||||
// (shrinkToFit) rather than overflow into scroll.
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
itemCount === 1 ? "min-w-0" : "min-w-max",
|
||||
)}
|
||||
<motion.div
|
||||
className="-my-1 flex items-center overflow-x-auto overscroll-x-contain px-5 py-1 scrollbar-none [&::-webkit-scrollbar]:hidden"
|
||||
data-testid="bot-activity-strip-scroller"
|
||||
layoutScroll
|
||||
onScroll={updateFades}
|
||||
ref={scrollerRef}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{orderedAgents.map((agent, index) => {
|
||||
const guardsOpenCard =
|
||||
activeCardIndex !== -1 && index <= activeCardIndex;
|
||||
return (
|
||||
<div
|
||||
// The overflow-fade hook resize-observes this wrapper as its proxy
|
||||
// for content size. min-w-max keeps it from collapsing to the
|
||||
// scroller's width (it is a shrinkable flex item), so late content
|
||||
// growth — label tickers swap AFTER a resize settles — still
|
||||
// changes an observed box and refreshes the fades. A lone item
|
||||
// instead gets min-w-0 so it can shrink with the container
|
||||
// (shrinkToFit) rather than overflow into scroll.
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
itemCount === 1 ? "min-w-0" : "min-w-max",
|
||||
)}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{orderedAgents.map((agent, index) => {
|
||||
const guardsOpenCard =
|
||||
activeCardIndex !== -1 && index <= activeCardIndex;
|
||||
return (
|
||||
<AnimatedPillSlot
|
||||
freezeLayout={guardsOpenCard}
|
||||
key={agent.pubkey}
|
||||
shouldReduceMotion={Boolean(shouldReduceMotion)}
|
||||
shrinkToFit={itemCount === 1}
|
||||
>
|
||||
{(isMoving) => (
|
||||
<BotActivityAgentPill
|
||||
agent={agent}
|
||||
avatarUrl={
|
||||
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ??
|
||||
null
|
||||
}
|
||||
channelId={channelId}
|
||||
holdLabelSwap={isMoving}
|
||||
hover={hover}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
pinWidth={guardsOpenCard}
|
||||
profiles={profiles}
|
||||
/>
|
||||
)}
|
||||
</AnimatedPillSlot>
|
||||
);
|
||||
})}
|
||||
{typingIndicator == null ? null : (
|
||||
<AnimatedPillSlot
|
||||
freezeLayout={guardsOpenCard}
|
||||
key={agent.pubkey}
|
||||
// The typing group trails every pill, so its movement can
|
||||
// never displace an open card (anchored at or left of it).
|
||||
freezeLayout={false}
|
||||
key="typing"
|
||||
shouldReduceMotion={Boolean(shouldReduceMotion)}
|
||||
shrinkToFit={itemCount === 1}
|
||||
>
|
||||
{(isMoving) => (
|
||||
<BotActivityAgentPill
|
||||
agent={agent}
|
||||
avatarUrl={
|
||||
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ??
|
||||
null
|
||||
}
|
||||
channelId={channelId}
|
||||
holdLabelSwap={isMoving}
|
||||
hover={hover}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
pinWidth={guardsOpenCard}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{() => (
|
||||
<div
|
||||
className={cn(
|
||||
// Cap like the pills (which use max-w-50 each) so a
|
||||
// long "X, Y, and N more are typing" label truncates
|
||||
// instead of inflating the scroll extent.
|
||||
"flex h-7 min-w-0 max-w-64 items-center",
|
||||
// Composer-edge alignment when the typing group leads
|
||||
// the strip (no pills): 0.75rem/1rem composer padding
|
||||
// + 1px border. Mirrors the standalone row's old
|
||||
// inset, and now animates via the slot's layout spring
|
||||
// when pills come and go.
|
||||
orderedAgents.length === 0 && "pl-3.25 sm:pl-4.25",
|
||||
)}
|
||||
data-testid="bot-activity-typing-slot"
|
||||
>
|
||||
{typingIndicator}
|
||||
</div>
|
||||
)}
|
||||
</AnimatedPillSlot>
|
||||
);
|
||||
})}
|
||||
{typingIndicator == null ? null : (
|
||||
<AnimatedPillSlot
|
||||
// The typing group trails every pill, so its movement can
|
||||
// never displace an open card (anchored at or left of it).
|
||||
freezeLayout={false}
|
||||
key="typing"
|
||||
shouldReduceMotion={Boolean(shouldReduceMotion)}
|
||||
shrinkToFit={itemCount === 1}
|
||||
>
|
||||
{() => (
|
||||
<div
|
||||
className={cn(
|
||||
// Cap like the pills (which use max-w-50 each) so a
|
||||
// long "X, Y, and N more are typing" label truncates
|
||||
// instead of inflating the scroll extent.
|
||||
"flex h-7 min-w-0 max-w-64 items-center",
|
||||
// Composer-edge alignment when the typing group leads
|
||||
// the strip (no pills): 0.75rem/1rem composer padding
|
||||
// + 1px border. Mirrors the standalone row's old
|
||||
// inset, and now animates via the slot's layout spring
|
||||
// when pills come and go.
|
||||
orderedAgents.length === 0 && "pl-3.25 sm:pl-4.25",
|
||||
)}
|
||||
data-testid="bot-activity-typing-slot"
|
||||
>
|
||||
{typingIndicator}
|
||||
</div>
|
||||
)}
|
||||
</AnimatedPillSlot>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Edge fades: obvious "more pills off view" affordance. Rendered only
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Edge fades: obvious "more pills off view" affordance. Rendered only
|
||||
for a side that actually has clipped content, so they never dim a
|
||||
fully visible strip. They sit at the full-bleed viewport's edges —
|
||||
the container's visual edges — and w-12 spans the 20px gutter plus
|
||||
a soft zone over the pills scrolling under it. */}
|
||||
{fades.start ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-12 bg-linear-to-r from-background via-background/60 to-transparent"
|
||||
data-testid="bot-activity-strip-fade-start"
|
||||
/>
|
||||
) : null}
|
||||
{fades.end ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-12 bg-linear-to-l from-background via-background/60 to-transparent"
|
||||
data-testid="bot-activity-strip-fade-end"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{fades.start ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-12 bg-linear-to-r from-background via-background/60 to-transparent"
|
||||
data-testid="bot-activity-strip-fade-start"
|
||||
/>
|
||||
) : null}
|
||||
{fades.end ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-12 bg-linear-to-l from-background via-background/60 to-transparent"
|
||||
data-testid="bot-activity-strip-fade-end"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ComposerStripAvatarScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Per-strip namespace for the composer strip's avatar shared-element ids.
|
||||
*
|
||||
* The same agent avatar has two possible homes inside a strip — the merged
|
||||
* typing group and a status pill — and a partition flip (typing agent
|
||||
* promoted to a pill) should MORPH the avatar between them via a matching
|
||||
* motion `layoutId` instead of exiting one and entering the other.
|
||||
*
|
||||
* layoutIds are app-global, and the channel composer strip and the thread
|
||||
* panel strip can be mounted at once, so the ids must be namespaced per
|
||||
* strip instance — two simultaneously mounted elements sharing a layoutId
|
||||
* would fight over the "lead" and a morph could travel between strips.
|
||||
* Motion's own namespacing tool (LayoutGroup id) is deliberately NOT used:
|
||||
* LayoutGroup re-measures every layout-animating member whenever any member
|
||||
* updates, which turns each pill label ticker resize into a group-wide
|
||||
* onLayoutAnimationStart storm that permanently holds the pills' deferred
|
||||
* label swaps. A per-strip scope string baked into the id gives the
|
||||
* isolation without the group re-measurement semantics.
|
||||
*
|
||||
* BotActivityComposerAction provides the scope (its React.useId());
|
||||
* the pill and TypingIndicatorRow read it through
|
||||
* useComposerStripAvatarLayoutId. Outside a strip the context is null and
|
||||
* avatars render without a layoutId.
|
||||
*/
|
||||
export const ComposerStripAvatarScopeContext = React.createContext<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
/**
|
||||
* Shared-element layout id for an agent's avatar within one strip scope.
|
||||
* Exposed for tests; components should prefer the hook below.
|
||||
*/
|
||||
export function composerStripAvatarLayoutId(scope: string, pubkey: string) {
|
||||
return `bot-activity-avatar-${scope}-${pubkey.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The avatar's shared-element layout id in the enclosing composer strip, or
|
||||
* undefined when rendered outside a strip (no morph counterpart exists).
|
||||
*/
|
||||
export function useComposerStripAvatarLayoutId(
|
||||
pubkey: string,
|
||||
): string | undefined {
|
||||
const scope = React.useContext(ComposerStripAvatarScopeContext);
|
||||
return scope === null
|
||||
? undefined
|
||||
: composerStripAvatarLayoutId(scope, pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout spring for the avatar morph, shared by both morph endpoints
|
||||
* (whichever end MOUNTS drives the transition). Quicker than the strip's
|
||||
* slot reorder spring — the morph travels the short hop between the
|
||||
* trailing typing group and the adjacent new pill and should read as one
|
||||
* quick slide.
|
||||
*/
|
||||
export const COMPOSER_STRIP_AVATAR_MORPH_TRANSITION = {
|
||||
layout: { type: "spring", duration: 0.45, bounce: 0.2 },
|
||||
} as const;
|
||||
@@ -1,5 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import {
|
||||
COMPOSER_STRIP_AVATAR_MORPH_TRANSITION,
|
||||
useComposerStripAvatarLayoutId,
|
||||
} from "@/features/channels/ui/composerStripAvatarLayout";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
@@ -52,6 +57,47 @@ function formatTypingLabel(names: string[]) {
|
||||
return `${names[0]}, ${names[1]}, and ${names.length - 2} more are typing...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One typer's avatar in the overlapping set. Inside a composer activity
|
||||
* strip, the avatar carries that strip's shared-element layout id (see
|
||||
* composerStripAvatarLayout) so an agent's avatar MORPHS between the merged
|
||||
* typing group and its status pill on partition flips instead of exiting
|
||||
* one and entering the other. Outside a strip (context null) or under
|
||||
* reduced motion the id is dropped and the avatar mounts in place.
|
||||
*/
|
||||
function TypingAvatar({
|
||||
avatarUrl,
|
||||
label,
|
||||
overlap,
|
||||
pubkey,
|
||||
}: {
|
||||
avatarUrl: string | null;
|
||||
label: string;
|
||||
overlap: boolean;
|
||||
pubkey: string;
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const morphLayoutId = useComposerStripAvatarLayoutId(pubkey);
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative h-5 w-5 shrink-0 rounded-lg ring-1 ring-background",
|
||||
overlap && "-ml-1.5",
|
||||
)}
|
||||
data-testid="message-typing-avatar"
|
||||
layoutId={shouldReduceMotion ? undefined : morphLayoutId}
|
||||
transition={COMPOSER_STRIP_AVATAR_MORPH_TRANSITION}
|
||||
>
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
label={label}
|
||||
className="h-5 w-5 text-3xs"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TypingIndicatorRow({
|
||||
channel,
|
||||
className,
|
||||
@@ -85,27 +131,15 @@ export function TypingIndicatorRow({
|
||||
{labels.length > 0 && (
|
||||
<div className="flex min-w-0 w-full items-center gap-2">
|
||||
<div className="flex shrink-0 items-center">
|
||||
{typingPubkeys.map((pubkey, index) => {
|
||||
const profile = profiles?.[pubkey.toLowerCase()];
|
||||
const label = labels[index] ?? truncatePubkey(pubkey);
|
||||
return (
|
||||
<div
|
||||
key={pubkey}
|
||||
className={cn(
|
||||
"relative h-5 w-5 shrink-0 rounded-lg 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 text-3xs"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{typingPubkeys.map((pubkey, index) => (
|
||||
<TypingAvatar
|
||||
key={pubkey}
|
||||
avatarUrl={profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null}
|
||||
label={labels[index] ?? truncatePubkey(pubkey)}
|
||||
overlap={index > 0}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user