v0.8.5 fix pinning
This commit is contained in:
@@ -20,8 +20,6 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
||||
const [typing, setTyping] = useState([]);
|
||||
const [pinnedMsgIds, setPinnedMsgIds] = useState(new Set());
|
||||
const [pinCount, setPinCount] = useState(0);
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesTopRef = useRef(null);
|
||||
const typingTimers = useRef({});
|
||||
@@ -43,7 +41,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) { setMessages([]); setPinnedMsgIds(new Set()); setPinCount(0); return; }
|
||||
if (!group) { setMessages([]); return; }
|
||||
setMessages([]);
|
||||
setHasMore(false);
|
||||
setLoading(true);
|
||||
@@ -55,292 +53,3 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
||||
})
|
||||
.catch(e => toast(e.message, 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
// Load pinned messages for DMs
|
||||
if (group.is_direct) {
|
||||
api.getPinnedMessages(group.id)
|
||||
.then(({ pinned, count }) => {
|
||||
setPinnedMsgIds(new Set(pinned.map(p => p.id)));
|
||||
setPinCount(count);
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
setPinnedMsgIds(new Set());
|
||||
setPinCount(0);
|
||||
}
|
||||
}, [group?.id]);
|
||||
|
||||
const handlePinMessage = async (msgId) => {
|
||||
try {
|
||||
const { count } = await api.pinMessage(msgId);
|
||||
setPinnedMsgIds(prev => new Set([...prev, msgId]));
|
||||
setPinCount(count);
|
||||
} catch (e) {
|
||||
toast(e.message || 'Could not pin message', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinMessage = async (msgId) => {
|
||||
try {
|
||||
const { count } = await api.unpinMessage(msgId);
|
||||
setPinnedMsgIds(prev => { const n = new Set(prev); n.delete(msgId); return n; });
|
||||
setPinCount(count);
|
||||
} catch (e) {
|
||||
toast(e.message || 'Could not unpin message', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 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, emojiOnly }) => {
|
||||
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, emojiOnly
|
||||
});
|
||||
}
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
swipeStartX.current = e.touches[0].clientX;
|
||||
swipeStartY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
if (swipeStartX.current === null || !onBack) return;
|
||||
const dx = e.changedTouches[0].clientX - swipeStartX.current;
|
||||
const dy = Math.abs(e.changedTouches[0].clientY - swipeStartY.current);
|
||||
// Swipe right: at least 80px horizontal, less than 60px vertical drift
|
||||
if (dx > 80 && dy < 60) {
|
||||
e.preventDefault();
|
||||
onBack();
|
||||
}
|
||||
swipeStartX.current = null;
|
||||
swipeStartY.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="chat-window"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* 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 style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{group.is_direct && group.peer_avatar ? (
|
||||
<img
|
||||
src={group.peer_avatar}
|
||||
alt={group.name}
|
||||
className="group-icon-sm"
|
||||
style={{ objectFit: 'cover', padding: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="group-icon-sm"
|
||||
style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}
|
||||
>
|
||||
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{!!group.is_direct && group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false) && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: 1, right: 1,
|
||||
width: 11, height: 11, borderRadius: '50%',
|
||||
background: '#34a853', border: '2px solid var(--surface)',
|
||||
pointerEvents: 'none'
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
{group.is_direct && group.peer_display_name ? (
|
||||
<span className="chat-header-name">
|
||||
{group.peer_display_name}
|
||||
<span className="chat-header-real-name"> ({group.peer_real_name})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="chat-header-name">{group.is_direct && group.peer_real_name ? group.peer_real_name : group.name}</span>
|
||||
)}
|
||||
{group.is_readonly ? (
|
||||
<span className="readonly-badge">Read-only</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="chat-header-sub">
|
||||
{group.is_direct ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Message info">
|
||||
{iconGroupInfo ? (
|
||||
<img src={iconGroupInfo} alt="Message info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="24" height="24">
|
||||
<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={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}
|
||||
isDirect={!!group.is_direct}
|
||||
onReply={(m) => setReplyTo(m)}
|
||||
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
|
||||
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
|
||||
onDirectMessage={onDirectMessage}
|
||||
onPin={handlePinMessage}
|
||||
onUnpin={handleUnpinMessage}
|
||||
isPinned={pinnedMsgIds.has(msg.id)}
|
||||
pinCount={pinCount}
|
||||
onlineUserIds={onlineUserIds}
|
||||
/>
|
||||
))}
|
||||
{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 });
|
||||
}
|
||||
}}
|
||||
onlineUserIds={onlineUserIds}
|
||||
/>
|
||||
) : (
|
||||
<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 message is read-only
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInfo && (
|
||||
<GroupInfoModal
|
||||
group={group}
|
||||
onClose={() => setShowInfo(false)}
|
||||
onUpdated={onGroupUpdated}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user