import { useState, useEffect, useRef, useCallback } from 'react'; import Message from './Message.jsx'; import MessageInput from './MessageInput.jsx'; import { api } from '../utils/api.js'; import { useAuth } from '../contexts/AuthContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx'; import { useSocket } from '../contexts/SocketContext.jsx'; import './ChatWindow.css'; import GroupInfoModal from './GroupInfoModal.jsx'; // Must match Avatar.jsx and Sidebar.jsx exactly so header colours are consistent with message avatars const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7']; function nameToColor(name) { return AVATAR_COLORS[(name || '').charCodeAt(0) % AVATAR_COLORS.length]; } // Composite avatar layouts for the 40×40 chat header icon const COMPOSITE_LAYOUTS_SM = { 1: [{ top: 4, left: 4, size: 32 }], 2: [ { top: 10, left: 1, size: 19 }, { top: 10, right: 1, size: 19 }, ], 3: [ { top: 2, left: 2, size: 17 }, { top: 2, right: 2, size: 17 }, { bottom: 2, left: 11, size: 17 }, ], 4: [ { top: 1, left: 1, size: 18 }, { top: 1, right: 1, size: 18 }, { bottom: 1, left: 1, size: 18 }, { bottom: 1, right: 1, size: 18 }, ], }; function GroupAvatarCompositeSm({ memberPreviews }) { const members = (memberPreviews || []).slice(0, 4); const positions = COMPOSITE_LAYOUTS_SM[members.length]; if (!positions) return null; return (
{members.map((m, i) => { const pos = positions[i]; const base = { position: 'absolute', width: pos.size, height: pos.size, borderRadius: '50%', boxSizing: 'border-box', border: '2px solid var(--surface)', ...(pos.top !== undefined ? { top: pos.top } : {}), ...(pos.bottom !== undefined ? { bottom: pos.bottom } : {}), ...(pos.left !== undefined ? { left: pos.left } : {}), ...(pos.right !== undefined ? { right: pos.right } : {}), overflow: 'hidden', flexShrink: 0, }; if (m.avatar) return {m.name}; return (
{(m.name || '')[0]?.toUpperCase()}
); })}
); } export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onMessageDeleted, onHasTextChange, onlineUserIds = new Set() }) { const { user: currentUser } = useAuth(); const { socket } = useSocket(); const { toast } = useToast(); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(false); const [typing, setTyping] = useState([]); const [iconGroupInfo, setIconGroupInfo] = useState(''); const [avatarColors, setAvatarColors] = useState({ public: '#1a73e8', dm: '#a142f4' }); const [showInfo, setShowInfo] = useState(false); const [replyTo, setReplyTo] = useState(null); const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const typingTimers = useRef({}); useEffect(() => { const onResize = () => setIsMobile(window.innerWidth < 768); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); const scrollToBottom = useCallback((smooth = false) => { messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); }, []); // On mobile, when the soft keyboard opens the visual viewport shrinks but the // messages-container scroll position stays where it was, leaving the latest // messages hidden behind the keyboard. Scroll to bottom whenever the visual // viewport resizes (keyboard appear/dismiss) so the last message stays visible. useEffect(() => { const vv = window.visualViewport; if (!vv) return; const onVVResize = () => scrollToBottom(); vv.addEventListener('resize', onVVResize); return () => vv.removeEventListener('resize', onVVResize); }, [scrollToBottom]); useEffect(() => { api.getSettings().then(({ settings }) => { setIconGroupInfo(settings.icon_groupinfo || ''); setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' }); }).catch(() => {}); const handler = () => api.getSettings().then(({ settings }) => { setIconGroupInfo(settings.icon_groupinfo || ''); setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' }); }).catch(() => {}); window.addEventListener('rosterchirp:settings-updated', handler); window.addEventListener('rosterchirp:settings-changed', handler); return () => { window.removeEventListener('rosterchirp:settings-updated', handler); window.removeEventListener('rosterchirp:settings-changed', handler); }; }, []); useEffect(() => { if (!group) { setMessages([]); return; } setMessages([]); setHasMore(false); setLoading(true); api.getMessages(group.id) .then(({ messages }) => { setMessages(messages); setHasMore(messages.length >= 50); setTimeout(() => scrollToBottom(), 50); }) .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, groupId }) => { setMessages(prev => { const updated = prev.map(m => m.id === messageId ? { ...m, is_deleted: 1, content: null, image_url: null } : m ); // Notify Chat.jsx so the sidebar preview updates immediately — pass the // post-delete messages so it can derive the new last non-deleted message // without an extra API call. onMessageDeleted?.({ groupId, messages: updated }); return updated; }); }; const handleReaction = ({ messageId, reactions }) => { setMessages(prev => prev.map(m => m.id === messageId ? { ...m, reactions } : m )); }; const handleTypingStart = ({ userId: tid, user: tu }) => { if (tid === currentUser?.id) return; 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)); }; const handleGroupUpdated = (updatedGroup) => { if (updatedGroup.id === group.id) onGroupUpdated?.(); }; 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); socket.on('group:updated', handleGroupUpdated); 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.off('group:updated', handleGroupUpdated); }; }, [socket, group?.id, currentUser?.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, imageFile, linkPreview, emojiOnly }) => { if ((!content?.trim() && !imageFile) || !group) return; const replyToId = replyTo?.id || null; setReplyTo(null); try { if (imageFile) { await api.uploadImage(group.id, imageFile, { replyToId, content: content?.trim() || '' }); } else { await api.sendMessage(group.id, { content: content.trim(), replyToId, linkPreview, emojiOnly }); } } 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) => { setReplyTo(msg); }; const handleDirectMessage = (dmGroup) => { onDirectMessage?.(dmGroup); }; if (!group) { return (

Select a conversation

Choose a channel or direct message to start chatting

); } const isDirect = !!group.is_direct; const peerName = group.peer_display_name ? <>{group.peer_display_name} ({group.peer_real_name}) : group.peer_real_name || group.name; const isOnline = isDirect && group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false); return ( <>
{/* Header */}
{isMobile && onBack && ( )} {isDirect && group.peer_avatar && !group.is_managed ? (
{group.name} {isOnline && }
) : isDirect && !group.is_managed ? ( // No custom avatar — use same per-user colour as Avatar.jsx and Sidebar.jsx
{(group.peer_real_name || group.name)[0]?.toUpperCase()}
{isOnline && }
) : group.is_managed ? (
{group.is_multi_group ? 'MG' : 'UG'}
) : group.composite_members?.length > 0 ? ( ) : (
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
)}
{isDirect ? peerName : group.name} {group.is_readonly ? read-only : null}
{isDirect &&
Private message
} {!isDirect && group.type === 'public' &&
Public message
} {!isDirect && group.type === 'private' && group.is_managed && !group.is_multi_group &&
Private user group
} {!isDirect && group.type === 'private' && group.is_managed && group.is_multi_group &&
Private group
} {!isDirect && group.type === 'private' && !group.is_managed &&
Private group
}
{/* Messages */}
{hasMore && ( )} {messages.map((msg, i) => { // Skip deleted entries when looking for the effective previous message. // Deleted messages render null, so they must not affect date separators // or avatar-grouping for the messages that follow them. let effectivePrev = null; for (let j = i - 1; j >= 0; j--) { if (!messages[j].is_deleted) { effectivePrev = messages[j]; break; } } return ( ); })} {typing.length > 0 && (
{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing
)}
{/* Input */} {group.is_readonly && currentUser?.role !== 'admin' ? (
This channel is read-only
) : ( setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} /> )}
{showInfo && ( setShowInfo(false)} onUpdated={(updatedGroup) => { setShowInfo(false); onGroupUpdated && onGroupUpdated(updatedGroup); }} onBack={() => setShowInfo(false)} /> )} ); }