Initial Commit

This commit is contained in:
2026-03-06 11:54:19 -05:00
parent ee68c4704f
commit 4517746692
36 changed files with 4262 additions and 0 deletions

View File

@@ -0,0 +1,252 @@
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 './ChatWindow.css';
export default function ChatWindow({ group, onBack, onGroupUpdated }) {
const { socket } = useSocket();
const { user } = useAuth();
const toast = useToast();
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 messagesEndRef = useRef(null);
const messagesTopRef = useRef(null);
const typingTimers = useRef({});
useEffect(() => {
api.getSettings().then(({ settings }) => {
setIconGroupInfo(settings.icon_groupinfo || '');
}).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
window.addEventListener('teamchat:settings-changed', handler);
return () => window.removeEventListener('teamchat:settings-changed', handler);
}, []);
const scrollToBottom = useCallback((smooth = false) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, []);
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 }) => {
setMessages(prev => prev.filter(m => m.id !== messageId));
};
const handleReaction = ({ messageId, reactions }) => {
setMessages(prev => prev.map(m => m.id === messageId ? { ...m, reactions } : m));
};
const handleTypingStart = ({ userId: tid, user: tu }) => {
if (tid === user.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));
}, 3000);
};
const handleTypingStop = ({ userId: tid }) => {
setTyping(prev => prev.filter(t => t.userId !== tid));
if (typingTimers.current[tid]) clearTimeout(typingTimers.current[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, user.id]);
const loadMore = async () => {
if (!messages.length) return;
const oldest = messages[0];
const { messages: older } = await api.getMessages(group.id, oldest.id);
setMessages(prev => [...older, ...prev]);
setHasMore(older.length >= 50);
};
const handleSend = async ({ content, imageFile, linkPreview }) => {
if (!group) return;
const replyId = replyTo?.id;
setReplyTo(null);
try {
if (imageFile) {
const { message } = await api.uploadImage(group.id, imageFile, { replyToId: replyId, content });
// Add immediately to local state — don't wait for socket (it may be slow for large files)
if (message) {
setMessages(prev => prev.find(m => m.id === message.id) ? prev : [...prev, message]);
setTimeout(() => scrollToBottom(true), 50);
}
} else {
socket?.emit('message:send', {
groupId: group.id, content, replyToId: replyId, linkPreview
});
}
} catch (e) {
toast(e.message, 'error');
}
};
if (!group) {
return (
<div className="chat-window empty">
<div className="empty-state">
<div className="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" width="64" height="64">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<h3>Select a conversation</h3>
<p>Choose from your existing chats or start a new one</p>
</div>
</div>
);
}
return (
<div className="chat-window">
{/* Header */}
<div className="chat-header">
{onBack && (
<button className="btn-icon" onClick={onBack}>
<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>
)}
<div
className="group-icon-sm"
style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}
>
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
</div>
<div className="flex-col flex-1 overflow-hidden">
<div className="flex items-center gap-2">
<span className="chat-header-name">{group.name}</span>
{group.is_readonly ? (
<span className="readonly-badge">Read-only</span>
) : null}
</div>
<span className="chat-header-sub">
{group.type === 'public' ? 'Public channel' : 'Private group'}
</span>
</div>
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info">
{iconGroupInfo ? (
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="24" height="24">
<path stroke-linecap="round" stroke-linejoin="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={messagesTopRef}>
{hasMore && (
<button className="load-more-btn" onClick={loadMore}>Load older messages</button>
)}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
) : (
<>
{messages.map((msg, i) => (
<Message
key={msg.id}
message={msg}
prevMessage={messages[i - 1]}
currentUser={user}
onReply={(m) => setReplyTo(m)}
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
/>
))}
{typing.length > 0 && (
<div className="typing-indicator">
<span>{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing</span>
<span className="dots"><span/><span/><span/></span>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input */}
{(!group.is_readonly || user.role === 'admin') ? (
<MessageInput
group={group}
replyTo={replyTo}
onCancelReply={() => setReplyTo(null)}
onSend={handleSend}
onTyping={(isTyping) => {
if (socket) {
if (isTyping) socket.emit('typing:start', { groupId: group.id });
else socket.emit('typing:stop', { groupId: group.id });
}
}}
/>
) : (
<div className="readonly-bar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
This channel is read-only
</div>
)}
{showInfo && (
<GroupInfoModal
group={group}
onClose={() => setShowInfo(false)}
onUpdated={onGroupUpdated}
/>
)}
</div>
);
}