Files
Compass/web/components/chat/message-actions.tsx
2026-03-01 16:55:19 +01:00

133 lines
4.6 KiB
TypeScript

import {EllipsisHorizontalIcon, PencilIcon, TrashIcon} from '@heroicons/react/24/outline'
import {FaceSmileIcon} from '@heroicons/react/24/solid'
import {JSONContent} from '@tiptap/react'
import clsx from 'clsx'
import {PrivateChatMessage} from 'common/chat-message'
import {Dispatch, SetStateAction, useEffect, useRef, useState} from 'react'
import {toast} from 'react-hot-toast'
import DropdownMenu, {DropdownItem} from 'web/components/comments/dropdown-menu'
import {useClickOutside} from 'web/hooks/use-click-outside'
import {useIsMobile} from 'web/hooks/use-is-mobile'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
import {updateReactionUI} from 'web/lib/supabase/chat-messages'
import {handleReaction} from 'web/lib/util/message-reactions'
const REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '👎']
export function MessageActions(props: {
message: {
id: number
userId: string
content: JSONContent
isEdited?: boolean
reactions?: Record<string, boolean>
}
onRequestEdit?: () => void
setMessages?: Dispatch<SetStateAction<PrivateChatMessage[] | undefined>>
className?: string
// If provided, when this key changes, the emoji picker will open
openEmojiPickerKey?: number
// If true, hide the trigger menu button and only render the picker anchor
hideTrigger?: boolean
}) {
const {message, onRequestEdit, className, setMessages, openEmojiPickerKey, hideTrigger} = props
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const emojiPickerRef = useRef<HTMLDivElement>(null)
const user = useUser()
const isOwner = user?.id === message.userId
const isMobile = useIsMobile()
const t = useT()
useClickOutside(emojiPickerRef, () => {
setShowEmojiPicker(false)
})
// Open emoji picker when external key changes
useEffect(() => {
if (openEmojiPickerKey !== undefined) {
setShowEmojiPicker(true)
}
}, [openEmojiPickerKey])
const handleDelete = async () => {
if (!confirm(t('messages.delete_confirm', 'Are you sure you want to delete this message?')))
return
const messageId = message.id
try {
await api('delete-message', {messageId})
toast.success(t('messages.deleted', 'Message deleted'))
setMessages?.((prevMessages) => {
if (!prevMessages) return prevMessages
return prevMessages.filter((m) => m.id !== messageId)
})
} catch (error) {
toast.error(t('messages.delete_failed', 'Failed to delete message'))
console.error(error)
}
}
return (
<div className={clsx('flex items-center gap-1', className)}>
{showEmojiPicker && (
<div
ref={emojiPickerRef}
className={clsx(
'absolute mb-2 rounded-lg bg-canvas-100 p-2 shadow-lg pr-10 z-10 max-w-[250px]',
isMobile ? 'left-1/2 transform -translate-x-1/2' : isOwner ? 'right-20' : 'left-20',
)}
>
<div className="grid grid-cols-6 gap-8">
{REACTIONS.map((reaction) => (
<button
key={reaction}
className="text-2xl hover:scale-125"
onClick={async () => {
setShowEmojiPicker(false)
updateReactionUI(message, user, reaction, setMessages)
await handleReaction(reaction, message.id)
}}
>
{reaction}
</button>
))}
</div>
</div>
)}
{!hideTrigger && (
<DropdownMenu
items={
[
isOwner && {
name: t('messages.action.edit', 'Edit'),
icon: <PencilIcon className="h-4 w-4" />,
onClick: onRequestEdit,
},
isOwner && {
name: t('messages.action.delete', 'Delete'),
icon: <TrashIcon className="h-4 w-4" />,
onClick: handleDelete,
},
{
name: t('messages.action.add_reaction', 'Add Reaction'),
icon: <FaceSmileIcon className="h-4 w-4" />,
onClick: () => {
setShowEmojiPicker(!showEmojiPicker)
},
},
].filter(Boolean) as DropdownItem[]
}
closeOnClick={true}
icon={<EllipsisHorizontalIcon className="h-5 w-5 text-gray-500" />}
menuWidth="w-40"
className="text-ink-500 hover:text-ink-700 rounded-full p-1 hover:bg-canvas-50"
/>
)}
{/*{message.isEdited && (*/}
{/* <span className="text-xs text-gray-400">{t('messages.edited', 'edited')}</span>*/}
{/*)}*/}
</div>
)
}