v0.8.5 pinning bug fix
This commit is contained in:
@@ -1,41 +1,44 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
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 Message from './Message.jsx';
|
||||||
import MessageInput from './MessageInput.jsx';
|
import MessageInput from './MessageInput.jsx';
|
||||||
import GroupInfoModal from './GroupInfoModal.jsx';
|
import { api } from '../utils/api.js';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||||
import './ChatWindow.css';
|
import './ChatWindow.css';
|
||||||
|
|
||||||
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onlineUserIds = new Set() }) {
|
function formatTime(ts) {
|
||||||
const { socket } = useSocket();
|
if (!ts) return '';
|
||||||
const { user } = useAuth();
|
const d = new Date(ts);
|
||||||
const toast = useToast();
|
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, currentUser, onBack, isMobile, onlineUserIds = new Set() }) {
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = 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 [typing, setTyping] = useState([]);
|
||||||
|
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const messagesTopRef = useRef(null);
|
const messagesContainerRef = useRef(null);
|
||||||
const typingTimers = useRef({});
|
const typingTimers = useRef({});
|
||||||
const swipeStartX = useRef(null);
|
const { toast } = useToast();
|
||||||
const swipeStartY = useRef(null);
|
const { socket } = useSocket();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then(({ settings }) => {
|
api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
|
||||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
|
||||||
}).catch(() => {});
|
|
||||||
const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
|
const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
|
||||||
window.addEventListener('jama:settings-changed', handler);
|
window.addEventListener('jama:settings-updated', handler);
|
||||||
return () => window.removeEventListener('jama:settings-changed', handler);
|
return () => window.removeEventListener('jama:settings-updated', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = false) => {
|
const scrollToBottom = useCallback((smooth = false) => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -53,3 +56,237 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
})
|
})
|
||||||
.catch(e => toast(e.message, 'error'))
|
.catch(e => toast(e.message, 'error'))
|
||||||
.finally(() => setLoading(false));
|
.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.map(m =>
|
||||||
|
m.id === messageId ? { ...m, is_deleted: 1, content: null, image_url: null } : m
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReaction = ({ messageId, reactions }) => {
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
m.id === messageId ? { ...m, reactions } : m
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (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));
|
||||||
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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, imageUrl, replyToId }) => {
|
||||||
|
if ((!content?.trim() && !imageUrl) || !group) return;
|
||||||
|
try {
|
||||||
|
await api.sendMessage({ groupId: group.id, content: content?.trim() || '', imageUrl, replyToId });
|
||||||
|
} 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) => {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<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 ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<div className="group-icon-sm" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4', flexShrink: 0 }}>
|
||||||
|
{group.type === 'public' ? '#' : isDirect ? (group.peer_real_name || group.name)[0]?.toUpperCase() : 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>}
|
||||||
|
</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>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isDirect && (
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent('jama:group-info', { detail: group }))}
|
||||||
|
title="Group info"
|
||||||
|
>
|
||||||
|
{iconGroupInfo ? (
|
||||||
|
<img src={iconGroupInfo} alt="info" style={{ width: 22, height: 22, objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</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) => (
|
||||||
|
<Message
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
prevMessage={messages[i - 1]}
|
||||||
|
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 && !['admin', 'owner'].includes(currentUser?.role) ? (
|
||||||
|
<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} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user