Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef, useCallback } from 'react' | |
| import { collabApi } from '../../api/client' | |
| import { useSettingsStore } from '../../store/settingsStore' | |
| import { useCanDo } from '../../store/permissionsStore' | |
| import { useTripStore } from '../../store/tripStore' | |
| import { addListener, removeListener } from '../../api/websocket' | |
| import { useTranslation } from '../../i18n' | |
| import { useToast } from '../shared/Toast' | |
| export function useCollabChat(tripId: any, currentUser: any) { | |
| const { t } = useTranslation() | |
| const toast = useToast() | |
| const is12h = useSettingsStore(s => s.settings.time_format) === '12h' | |
| const can = useCanDo() | |
| const trip = useTripStore((s) => s.trip) | |
| const canEdit = can('collab_edit', trip) | |
| const [messages, setMessages] = useState([]) | |
| const [loading, setLoading] = useState(true) | |
| const [hasMore, setHasMore] = useState(false) | |
| const [loadingMore, setLoadingMore] = useState(false) | |
| const [text, setText] = useState('') | |
| const [replyTo, setReplyTo] = useState(null) | |
| const [hoveredId, setHoveredId] = useState(null) | |
| const [sending, setSending] = useState(false) | |
| const [showEmoji, setShowEmoji] = useState(false) | |
| const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y } | |
| const [deletingIds, setDeletingIds] = useState(new Set()) | |
| const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]) | |
| useEffect(() => { | |
| return () => { deleteTimersRef.current.forEach(clearTimeout) } | |
| }, []) | |
| const containerRef = useRef(null) | |
| const messagesRef = useRef(messages) | |
| messagesRef.current = messages | |
| const scrollRef = useRef(null) | |
| const textareaRef = useRef(null) | |
| const emojiBtnRef = useRef(null) | |
| const isAtBottom = useRef(true) | |
| const scrollToBottom = useCallback((behavior = 'auto') => { | |
| const el = scrollRef.current | |
| if (!el) return | |
| requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior })) | |
| }, []) | |
| const checkAtBottom = useCallback(() => { | |
| const el = scrollRef.current | |
| if (!el) return | |
| isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 | |
| }, []) | |
| /* ββ load messages ββ */ | |
| useEffect(() => { | |
| let cancelled = false | |
| setLoading(true) | |
| collabApi.getMessages(tripId).then(data => { | |
| if (cancelled) return | |
| const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m) | |
| setMessages(msgs) | |
| setHasMore(msgs.length >= 100) | |
| setLoading(false) | |
| setTimeout(() => scrollToBottom(), 30) | |
| }).catch(() => { if (!cancelled) setLoading(false) }) | |
| return () => { cancelled = true } | |
| }, [tripId, scrollToBottom]) | |
| /* ββ load more ββ */ | |
| const handleLoadMore = useCallback(async () => { | |
| if (loadingMore || messages.length === 0) return | |
| setLoadingMore(true) | |
| const el = scrollRef.current | |
| const prevHeight = el ? el.scrollHeight : 0 | |
| try { | |
| const data = await collabApi.getMessages(tripId, messages[0]?.id) | |
| const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m) | |
| if (older.length === 0) { setHasMore(false) } | |
| else { | |
| setMessages(prev => [...older, ...prev]) | |
| setHasMore(older.length >= 100) | |
| requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight }) | |
| } | |
| } catch {} finally { setLoadingMore(false) } | |
| }, [tripId, loadingMore, messages]) | |
| /* ββ websocket ββ */ | |
| useEffect(() => { | |
| const handler = (event) => { | |
| if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) { | |
| setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message]) | |
| if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30) | |
| } | |
| if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) { | |
| setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m)) | |
| if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) | |
| } | |
| if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) { | |
| setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m)) | |
| } | |
| } | |
| addListener(handler) | |
| return () => removeListener(handler) | |
| }, [tripId, scrollToBottom]) | |
| /* ββ auto-resize textarea ββ */ | |
| const handleTextChange = useCallback((e) => { | |
| setText(e.target.value) | |
| const ta = textareaRef.current | |
| if (ta) { | |
| ta.style.height = 'auto' | |
| const h = Math.min(ta.scrollHeight, 100) | |
| ta.style.height = h + 'px' | |
| ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden' | |
| } | |
| }, []) | |
| /* ββ send ββ */ | |
| const handleSend = useCallback(async () => { | |
| const body = text.trim() | |
| if (!body || sending) return | |
| setSending(true) | |
| try { | |
| const payload: { text: string; reply_to?: number } = { text: body } | |
| if (replyTo) payload.reply_to = replyTo.id | |
| const data = await collabApi.sendMessage(tripId, payload) | |
| if (data?.message) { | |
| setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message]) | |
| } | |
| setText(''); setReplyTo(null); setShowEmoji(false) | |
| if (textareaRef.current) textareaRef.current.style.height = 'auto' | |
| isAtBottom.current = true | |
| setTimeout(() => scrollToBottom('smooth'), 50) | |
| } catch { toast.error(t('common.error')) } finally { setSending(false) } | |
| }, [text, sending, replyTo, tripId, scrollToBottom, toast, t]) | |
| const handleKeyDown = useCallback((e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } | |
| }, [handleSend]) | |
| const handleDelete = useCallback(async (msgId) => { | |
| const msg = messages.find(m => m.id === msgId) | |
| requestAnimationFrame(() => { | |
| setDeletingIds(prev => new Set(prev).add(msgId)) | |
| }) | |
| const timer = setTimeout(async () => { | |
| try { | |
| await collabApi.deleteMessage(tripId, msgId) | |
| setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m)) | |
| } catch { toast.error(t('common.error')) } | |
| setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s }) | |
| }, 400) | |
| deleteTimersRef.current.push(timer) | |
| }, [tripId, toast, t]) | |
| const handleReact = useCallback(async (msgId, emoji) => { | |
| setReactMenu(null) | |
| try { | |
| const data = await collabApi.reactMessage(tripId, msgId, emoji) | |
| setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m)) | |
| } catch { toast.error(t('common.error')) } | |
| }, [tripId, toast, t]) | |
| const handleEmojiSelect = useCallback((emoji) => { | |
| setText(prev => prev + emoji) | |
| textareaRef.current?.focus() | |
| }, []) | |
| const isOwn = (msg) => String(msg.user_id) === String(currentUser.id) | |
| // Check if message is only emoji (1-3 emojis, no other text) | |
| const isEmojiOnly = (text) => { | |
| const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[οΈ]?(?:β\p{Extended_Pictographic}[οΈ]?)*){1,3}$/u | |
| return emojiRegex.test(text.trim()) | |
| } | |
| return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } | |
| } | |