Files

420 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="group-icon-sm" style={{ background: 'transparent', position: 'relative', padding: 0, overflow: 'visible' }}>
{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 <img key={m.id} src={m.avatar} alt={m.name} style={{ ...base, objectFit: 'cover' }} />;
return (
<div key={m.id} style={{ ...base, background: nameToColor(m.name), display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: Math.round(pos.size * 0.42), fontWeight: 700, color: 'white' }}>
{(m.name || '')[0]?.toUpperCase()}
</div>
);
})}
</div>
);
}
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 (
<div className="chat-window empty">
<div className="empty-state">
<div className="empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
</div>
<h3>Select a conversation</h3>
<p>Choose a channel or direct message to start chatting</p>
</div>
</div>
);
}
const isDirect = !!group.is_direct;
const peerName = group.peer_display_name
? <>{group.peer_display_name}<span className="chat-header-real-name"> ({group.peer_real_name})</span></>
: group.peer_real_name || group.name;
const isOnline = isDirect && group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false);
return (
<>
<div className="chat-window">
{/* Header */}
<div className="chat-header">
{isMobile && onBack && (
<button className="btn-icon" onClick={onBack} style={{ marginRight: 4 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
)}
{isDirect && group.peer_avatar && !group.is_managed ? (
<div style={{ position: 'relative', flexShrink: 0 }}>
<img src={group.peer_avatar} alt={group.name} className="group-icon-sm" style={{ objectFit: 'cover', padding: 0 }} />
{isOnline && <span className="online-dot" style={{ position: 'absolute', bottom: 1, right: 1 }} />}
</div>
) : isDirect && !group.is_managed ? (
// No custom avatar — use same per-user colour as Avatar.jsx and Sidebar.jsx
<div style={{ position: 'relative', flexShrink: 0 }}>
<div className="group-icon-sm" style={{ background: nameToColor(group.peer_real_name || group.name), flexShrink: 0 }}>
{(group.peer_real_name || group.name)[0]?.toUpperCase()}
</div>
{isOnline && <span className="online-dot" style={{ position: 'absolute', bottom: 1, right: 1 }} />}
</div>
) : group.is_managed ? (
<div className="group-icon-sm" style={{ background: avatarColors.dm, borderRadius: 8, flexShrink: 0, fontSize: 11, fontWeight: 700 }}>
{group.is_multi_group ? 'MG' : 'UG'}
</div>
) : group.composite_members?.length > 0 ? (
<GroupAvatarCompositeSm memberPreviews={group.composite_members} />
) : (
<div className="group-icon-sm" style={{ background: group.type === 'public' ? avatarColors.public : avatarColors.dm, flexShrink: 0 }}>
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 overflow-hidden">
<div className="chat-header-name truncate">
{isDirect ? peerName : group.name}
{group.is_readonly ? <span className="readonly-badge" style={{ marginLeft: 8 }}>read-only</span> : null}
</div>
{isDirect && <div className="chat-header-sub">Private message</div>}
{!isDirect && group.type === 'public' && <div className="chat-header-sub">Public message</div>}
{!isDirect && group.type === 'private' && group.is_managed && !group.is_multi_group && <div className="chat-header-sub">Private user group</div>}
{!isDirect && group.type === 'private' && group.is_managed && group.is_multi_group && <div className="chat-header-sub">Private group</div>}
{!isDirect && group.type === 'private' && !group.is_managed && <div className="chat-header-sub">Private group</div>}
</div>
<button
className="btn-icon"
onClick={() => setShowInfo(true)}
title="Conversation info"
>
{iconGroupInfo ? (
<img src={iconGroupInfo} alt="info" style={{ width: 22, height: 22, objectFit: 'contain' }} />
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="22" height="22">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
</svg>
)}
</button>
</div>
{/* Messages */}
<div className="messages-container" ref={messagesContainerRef}>
{hasMore && (
<button className="load-more-btn" onClick={handleLoadMore} disabled={loading}>
{loading ? 'Loading…' : 'Load older messages'}
</button>
)}
{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 (
<Message
key={msg.id}
message={msg}
prevMessage={effectivePrev}
currentUser={currentUser}
onReply={handleReply}
onDelete={handleDelete}
onReact={handleReact}
onDirectMessage={handleDirectMessage}
isDirect={isDirect}
onlineUserIds={onlineUserIds} />
);
})}
{typing.length > 0 && (
<div className="typing-indicator">
<span>{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing</span>
<div className="dots"><span /><span /><span /></div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
{group.is_readonly && currentUser?.role !== 'admin' ? (
<div className="readonly-bar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
This channel is read-only
</div>
) : (
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} />
)}
</div>
{showInfo && (
<GroupInfoModal
group={group}
onClose={() => setShowInfo(false)}
onUpdated={(updatedGroup) => { setShowInfo(false); onGroupUpdated && onGroupUpdated(updatedGroup); }}
onBack={() => setShowInfo(false)} />
)}
</>
);
}