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
;
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 ? (
{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
}
setShowInfo(true)}
title="Conversation info"
>
{iconGroupInfo ? (
) : (
)}
{/* Messages */}
{hasMore && (
{loading ? 'Loading…' : 'Load older messages'}
)}
{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)} />
)}
>
);
}