126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { cn } from '@/lib/utils';
|
|
import { SmilePlus } from 'lucide-react';
|
|
import { client } from '@/client/client.gen';
|
|
|
|
interface ReactionGroup {
|
|
emoji: string;
|
|
count: number;
|
|
reacted_by_me: boolean;
|
|
users: string[];
|
|
}
|
|
|
|
interface RoomMessageReactionsProps {
|
|
roomId: string;
|
|
messageId: string;
|
|
className?: string;
|
|
}
|
|
|
|
const COMMON_EMOJIS = [
|
|
'👍', '👎', '❤️', '😂', '😮', '😢', '🎉', '🚀',
|
|
'✅', '⭐', '🔥', '💯', '👀', '🙏', '💪', '🤔',
|
|
];
|
|
|
|
export function RoomMessageReactions({
|
|
roomId,
|
|
messageId,
|
|
className,
|
|
}: RoomMessageReactionsProps) {
|
|
const [reactions, setReactions] = useState<ReactionGroup[]>([]);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadReactions();
|
|
}, [messageId]);
|
|
|
|
const loadReactions = useCallback(async () => {
|
|
try {
|
|
const resp = await client.get({
|
|
url: `/api/rooms/${roomId}/messages/${messageId}/reactions`,
|
|
});
|
|
const data = (resp.data as any)?.data as { reactions: ReactionGroup[] } | undefined;
|
|
if (data) {
|
|
setReactions(data.reactions);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load reactions:', err);
|
|
}
|
|
}, [roomId, messageId]);
|
|
|
|
const handleReaction = useCallback(async (emoji: string) => {
|
|
try {
|
|
const existingReaction = reactions.find(r => r.emoji === emoji);
|
|
|
|
if (existingReaction?.reacted_by_me) {
|
|
await client.delete({
|
|
url: `/api/rooms/${roomId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`,
|
|
});
|
|
} else {
|
|
await client.post({
|
|
url: `/api/rooms/${roomId}/messages/${messageId}/reactions`,
|
|
body: { emoji },
|
|
});
|
|
}
|
|
|
|
await loadReactions();
|
|
} catch (err) {
|
|
console.error('Failed to update reaction:', err);
|
|
} finally {
|
|
setIsOpen(false);
|
|
}
|
|
}, [roomId, messageId, reactions, loadReactions]);
|
|
|
|
// Compact inline reaction bar
|
|
return (
|
|
<div className={cn('flex items-center gap-0.5', className)}>
|
|
{reactions.map((reaction) => (
|
|
<button
|
|
key={reaction.emoji}
|
|
onClick={() => handleReaction(reaction.emoji)}
|
|
className={cn(
|
|
'inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition-colors',
|
|
reaction.reacted_by_me
|
|
? 'border-primary/50 bg-primary/10 text-primary'
|
|
: 'border-border bg-muted/20 text-muted-foreground hover:bg-muted/40'
|
|
)}
|
|
title={`${reaction.count} reaction${reaction.count !== 1 ? 's' : ''}`}
|
|
>
|
|
<span>{reaction.emoji}</span>
|
|
<span className="font-medium">{reaction.count}</span>
|
|
</button>
|
|
))}
|
|
|
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
<PopoverTrigger className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/50 hover:bg-accent hover:text-foreground">
|
|
<SmilePlus className="h-3 w-3" />
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-2" align="start">
|
|
<EmojiPicker onEmojiSelect={handleReaction} />
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface EmojiPickerProps {
|
|
onEmojiSelect: (emoji: string) => void;
|
|
}
|
|
|
|
function EmojiPicker({ onEmojiSelect }: EmojiPickerProps) {
|
|
return (
|
|
<div className="grid grid-cols-8 gap-1">
|
|
{COMMON_EMOJIS.map((emoji) => (
|
|
<button
|
|
key={emoji}
|
|
onClick={() => onEmojiSelect(emoji)}
|
|
className="flex h-7 w-7 items-center justify-center rounded-md text-base hover:bg-accent transition-colors"
|
|
title={emoji}
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|