|
|
|
|
@@ -2,39 +2,40 @@ 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';
|
|
|
|
|
|
|
|
|
|
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, onBack, onGroupUpdated, onDirectMessage, onlineUserIds = new Set() }) {
|
|
|
|
|
const { user: currentUser } = useAuth();
|
|
|
|
|
const { socket } = useSocket();
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
|
|
|
|
|
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 [typing, setTyping] = useState([]);
|
|
|
|
|
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
|
|
|
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
|
|
|
|
|
|
|
|
|
const messagesEndRef = useRef(null);
|
|
|
|
|
const messagesContainerRef = useRef(null);
|
|
|
|
|
const typingTimers = useRef({});
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
const { socket } = useSocket();
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
|
|
|
|
|
const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
|
|
|
|
|
const onResize = () => setIsMobile(window.innerWidth < 768);
|
|
|
|
|
window.addEventListener('resize', onResize);
|
|
|
|
|
return () => window.removeEventListener('resize', onResize);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
api.getSettings()
|
|
|
|
|
.then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || ''))
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
const handler = () => api.getSettings()
|
|
|
|
|
.then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || ''))
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
window.addEventListener('jama:settings-updated', handler);
|
|
|
|
|
return () => window.removeEventListener('jama:settings-updated', handler);
|
|
|
|
|
}, []);
|
|
|
|
|
@@ -84,7 +85,10 @@ export default function ChatWindow({ group, currentUser, onBack, isMobile, onlin
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 (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));
|
|
|
|
|
@@ -96,11 +100,16 @@ export default function ChatWindow({ group, currentUser, onBack, isMobile, onlin
|
|
|
|
|
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);
|
|
|
|
|
@@ -108,8 +117,9 @@ export default function ChatWindow({ group, currentUser, onBack, isMobile, onlin
|
|
|
|
|
socket.off('reaction:updated', handleReaction);
|
|
|
|
|
socket.off('typing:start', handleTypingStart);
|
|
|
|
|
socket.off('typing:stop', handleTypingStop);
|
|
|
|
|
socket.off('group:updated', handleGroupUpdated);
|
|
|
|
|
};
|
|
|
|
|
}, [socket, group?.id]);
|
|
|
|
|
}, [socket, group?.id, currentUser?.id]);
|
|
|
|
|
|
|
|
|
|
const handleLoadMore = async () => {
|
|
|
|
|
if (!hasMore || loading || messages.length === 0) return;
|
|
|
|
|
@@ -160,13 +170,8 @@ export default function ChatWindow({ group, currentUser, onBack, isMobile, onlin
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
const handleDirectMessage = (dmGroup) => {
|
|
|
|
|
onDirectMessage?.(dmGroup);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!group) {
|
|
|
|
|
@@ -195,7 +200,7 @@ export default function ChatWindow({ group, currentUser, onBack, isMobile, onlin
|
|
|
|
|
<div className="chat-window">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="chat-header">
|
|
|
|
|
{isMobile && (
|
|
|
|
|
{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"/>
|
|
|
|
|
@@ -217,7 +222,7 @@ export default function ChatWindow({ group, currentUser, onBack, isMobile, onlin
|
|
|
|
|
<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>}
|
|
|
|
|
{group.is_readonly ? <span className="readonly-badge" style={{ marginLeft: 8 }}>read-only</span> : null}
|
|
|
|
|
</div>
|
|
|
|
|
{isDirect && isOnline && <div className="chat-header-sub" style={{ color: 'var(--success)' }}>Online</div>}
|
|
|
|
|
{!isDirect && group.type === 'private' && <div className="chat-header-sub">Private group</div>}
|
|
|
|
|
@@ -276,7 +281,7 @@ export default function ChatWindow({ group, currentUser, onBack, isMobile, onlin
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Input */}
|
|
|
|
|
{group.is_readonly && !['admin', 'owner'].includes(currentUser?.role) ? (
|
|
|
|
|
{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"/>
|
|
|
|
|
|