v0.8.5 fix pinning

This commit is contained in:
2026-03-13 10:51:27 -04:00
parent a02facff1a
commit b3aac1981c
13 changed files with 153 additions and 504 deletions

View File

@@ -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>
);
}