218 lines
7.5 KiB
TypeScript
218 lines
7.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { Plus, Type, X } from 'lucide-react';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip';
|
|
import { HumanAvatar } from '@/components/human-avatar';
|
|
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
|
import { cn } from '@/lib/utils';
|
|
import { resolveHumanDisplay } from '@/lib/humans';
|
|
|
|
interface ReactionBarProps {
|
|
reactions: Reactions;
|
|
currentHumanId: string;
|
|
humans?: Human[];
|
|
onToggle: (key: string) => void;
|
|
onOpenTextReaction: () => void;
|
|
}
|
|
|
|
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
|
|
|
function getReactorNames(humanIds: string[], humans?: Human[]): string {
|
|
return humanIds
|
|
.map((id) => resolveHumanDisplay(id, humans).displayName)
|
|
.join(', ');
|
|
}
|
|
|
|
function getReactorList(
|
|
humanIds: string[],
|
|
humans: Human[] | undefined,
|
|
currentHumanId: string,
|
|
): { id: string; label: string; isMine: boolean }[] {
|
|
return humanIds.map((id) => ({
|
|
id,
|
|
label: resolveHumanDisplay(id, humans).displayName,
|
|
isMine: id === currentHumanId,
|
|
}));
|
|
}
|
|
|
|
export function ReactionBar({
|
|
reactions,
|
|
currentHumanId,
|
|
humans,
|
|
onToggle,
|
|
onOpenTextReaction,
|
|
}: ReactionBarProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const activeEmojis = REACTION_EMOJIS.filter(
|
|
(emoji) => reactions?.[emoji] && reactions[emoji].length > 0,
|
|
);
|
|
|
|
const activeTextReactions = Object.keys(reactions ?? {}).filter(
|
|
(key) => !EMOJI_SET.has(key) && (reactions?.[key]?.length ?? 0) > 0,
|
|
);
|
|
|
|
const handleToggle = (key: string) => {
|
|
onToggle(key);
|
|
setExpanded(false);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col items-end gap-1.5">
|
|
{/* Emoji reaction pills */}
|
|
{activeEmojis.map((emoji) => {
|
|
const reactors = reactions?.[emoji] ?? [];
|
|
const isMine = reactors.includes(currentHumanId);
|
|
return (
|
|
<Tooltip key={emoji}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleToggle(emoji);
|
|
}}
|
|
className={cn(
|
|
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
|
|
isMine
|
|
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
|
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
|
)}
|
|
>
|
|
<span className="text-sm">{emoji}</span>
|
|
<span className="text-muted-foreground">{reactors.length}</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" className="text-xs">
|
|
{getReactorNames(reactors, humans)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
|
|
{/* Text reaction pills */}
|
|
{activeTextReactions.map((text) => {
|
|
const reactors = reactions?.[text] ?? [];
|
|
const isMine = reactors.includes(currentHumanId);
|
|
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
|
const reactorList = getReactorList(reactors, humans, currentHumanId);
|
|
return (
|
|
<Tooltip key={text}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleToggle(text);
|
|
}}
|
|
className={cn(
|
|
'flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors',
|
|
isMine
|
|
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
|
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
|
)}
|
|
>
|
|
<HumanAvatar
|
|
size="xs"
|
|
className="shrink-0"
|
|
avatarObjectId={firstReactor.avatarObjectId}
|
|
initials={firstReactor.initials}
|
|
fallbackClassName="bg-muted text-muted-foreground text-[9px] font-medium"
|
|
/>
|
|
<span className="truncate text-foreground">{text}</span>
|
|
{reactors.length > 1 && (
|
|
<span className="text-muted-foreground shrink-0">
|
|
{reactors.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="left"
|
|
className="max-w-[260px] space-y-1.5 text-xs"
|
|
>
|
|
<div className="font-medium">“{text}”</div>
|
|
<ul className="flex flex-col gap-0.5 opacity-80">
|
|
{reactorList.map((r) => (
|
|
<li
|
|
key={r.id}
|
|
className={cn(r.isMine && 'font-medium opacity-100')}
|
|
>
|
|
{r.label}
|
|
{r.isMine && <span className="ml-1 opacity-60">(you)</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="border-t border-current/15 pt-1 text-[10px] opacity-60">
|
|
{isMine ? 'Click to remove' : 'Click to add yours'}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
|
|
{/* Picker / actions */}
|
|
{expanded ? (
|
|
<div className="bg-card/80 border-border flex flex-col items-center gap-0.5 rounded-full border px-0.5 py-1.5 backdrop-blur-sm">
|
|
{REACTION_EMOJIS.map((emoji) => {
|
|
if (activeEmojis.includes(emoji)) return null;
|
|
return (
|
|
<button
|
|
key={emoji}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleToggle(emoji);
|
|
}}
|
|
className="hover:bg-accent rounded-full px-0.5 py-1 text-sm transition-colors"
|
|
>
|
|
{emoji}
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExpanded(false);
|
|
}}
|
|
className="hover:bg-accent flex size-5 items-center justify-center rounded-full transition-colors"
|
|
>
|
|
<X className="text-muted-foreground size-3" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-1">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onOpenTextReaction();
|
|
}}
|
|
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
|
>
|
|
<Type className="text-muted-foreground size-3" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" className="text-xs">
|
|
Quick reply{' '}
|
|
<kbd className="bg-muted ml-1 rounded px-1 font-mono text-[10px]">
|
|
R
|
|
</kbd>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExpanded(true);
|
|
}}
|
|
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
|
>
|
|
<Plus className="text-muted-foreground size-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|