diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index efebafe..d776d70 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -1,41 +1,44 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { useSocket } from '../contexts/SocketContext.jsx'; -import { useAuth } from '../contexts/AuthContext.jsx'; -import { useToast } from '../contexts/ToastContext.jsx'; -import { api } from '../utils/api.js'; import Message from './Message.jsx'; import MessageInput from './MessageInput.jsx'; -import GroupInfoModal from './GroupInfoModal.jsx'; +import { api } from '../utils/api.js'; +import { useToast } from '../contexts/ToastContext.jsx'; +import { useSocket } from '../contexts/SocketContext.jsx'; import './ChatWindow.css'; -export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onlineUserIds = new Set() }) { - const { socket } = useSocket(); - const { user } = useAuth(); - const toast = useToast(); +function formatTime(ts) { + if (!ts) return ''; + const d = new Date(ts); + const now = new Date(); + const diff = now - d; + if (diff < 86400000 && d.getDate() === now.getDate()) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + if (diff < 604800000) { + return d.toLocaleDateString([], { weekday: 'short' }); + } + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} +export default function ChatWindow({ group, currentUser, onBack, isMobile, onlineUserIds = new Set() }) { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(false); - const [replyTo, setReplyTo] = useState(null); - const [showInfo, setShowInfo] = useState(false); - const [iconGroupInfo, setIconGroupInfo] = useState(''); const [typing, setTyping] = useState([]); + const [iconGroupInfo, setIconGroupInfo] = useState(''); const messagesEndRef = useRef(null); - const messagesTopRef = useRef(null); + const messagesContainerRef = useRef(null); const typingTimers = useRef({}); - const swipeStartX = useRef(null); - const swipeStartY = useRef(null); + const { toast } = useToast(); + const { socket } = useSocket(); useEffect(() => { - api.getSettings().then(({ settings }) => { - setIconGroupInfo(settings.icon_groupinfo || ''); - }).catch(() => {}); + api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {}); const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {}); - window.addEventListener('jama:settings-changed', handler); - return () => window.removeEventListener('jama:settings-changed', handler); + window.addEventListener('jama:settings-updated', handler); + return () => window.removeEventListener('jama:settings-updated', handler); }, []); - const scrollToBottom = useCallback((smooth = false) => { messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); }, []); @@ -53,3 +56,237 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess }) .catch(e => toast(e.message, 'error')) .finally(() => setLoading(false)); + }, [group?.id]); + + // Socket events + useEffect(() => { + if (!socket || !group) return; + + const handleNew = (msg) => { + if (msg.group_id !== group.id) return; + setMessages(prev => { + if (prev.find(m => m.id === msg.id)) return prev; + return [...prev, msg]; + }); + setTimeout(() => scrollToBottom(true), 50); + }; + + const handleDeleted = ({ messageId }) => { + setMessages(prev => prev.map(m => + m.id === messageId ? { ...m, is_deleted: 1, content: null, image_url: null } : m + )); + }; + + const handleReaction = ({ messageId, reactions }) => { + setMessages(prev => prev.map(m => + m.id === messageId ? { ...m, reactions } : m + )); + }; + + const handleTypingStart = ({ userId: tid, user: tu }) => { + setTyping(prev => prev.find(t => t.userId === tid) ? prev : [...prev, { userId: tid, name: tu?.display_name || tu?.name || 'Someone' }]); + if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]); + typingTimers.current[tid] = setTimeout(() => { + setTyping(prev => prev.filter(t => t.userId !== tid)); + }, 4000); + }; + + const handleTypingStop = ({ userId: tid }) => { + clearTimeout(typingTimers.current[tid]); + setTyping(prev => prev.filter(t => t.userId !== tid)); + }; + + socket.on('message:new', handleNew); + socket.on('message:deleted', handleDeleted); + socket.on('reaction:updated', handleReaction); + socket.on('typing:start', handleTypingStart); + socket.on('typing:stop', handleTypingStop); + + return () => { + socket.off('message:new', handleNew); + socket.off('message:deleted', handleDeleted); + socket.off('reaction:updated', handleReaction); + socket.off('typing:start', handleTypingStart); + socket.off('typing:stop', handleTypingStop); + }; + }, [socket, group?.id]); + + const handleLoadMore = async () => { + if (!hasMore || loading || messages.length === 0) return; + const container = messagesContainerRef.current; + const prevScrollHeight = container?.scrollHeight || 0; + setLoading(true); + try { + const oldest = messages[0]; + const { messages: older } = await api.getMessages(group.id, oldest.id); + setMessages(prev => [...older, ...prev]); + setHasMore(older.length >= 50); + requestAnimationFrame(() => { + if (container) container.scrollTop = container.scrollHeight - prevScrollHeight; + }); + } catch (e) { + toast(e.message, 'error'); + } finally { + setLoading(false); + } + }; + + const handleSend = async ({ content, imageUrl, replyToId }) => { + if ((!content?.trim() && !imageUrl) || !group) return; + try { + await api.sendMessage({ groupId: group.id, content: content?.trim() || '', imageUrl, replyToId }); + } catch (e) { + toast(e.message || 'Failed to send', 'error'); + } + }; + + const handleDelete = async (msgId) => { + try { + await api.deleteMessage(msgId); + } catch (e) { + toast(e.message || 'Could not delete', 'error'); + } + }; + + const handleReact = async (msgId, emoji) => { + try { + await api.toggleReaction(msgId, emoji); + } catch (e) { + toast(e.message || 'Could not react', 'error'); + } + }; + + const handleReply = (msg) => { + window.dispatchEvent(new CustomEvent('jama:reply', { detail: msg })); + }; + + const handleDirectMessage = async (userId) => { + try { + const { group: dmGroup } = await api.createGroup({ type: 'direct', userId }); + window.dispatchEvent(new CustomEvent('jama:open-group', { detail: dmGroup.id })); + } catch (e) { + toast(e.message || 'Could not open DM', 'error'); + } + }; + + if (!group) { + return ( +
Choose a channel or direct message to start chatting
+