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

View File

@@ -334,68 +334,3 @@
user-select: text;
}
/* Pinned state highlight for toolbar pin button */
.action-pinned {
color: var(--primary) !important;
}
/* Mobile bottom-sheet overlay for long-press / right-click pin */
.msg-sheet-overlay {
position: fixed;
inset: 0;
z-index: 500;
background: rgba(0,0,0,0.35);
display: flex;
align-items: flex-end;
justify-content: center;
}
.msg-sheet {
background: var(--surface);
border-radius: 16px 16px 0 0;
padding: 8px 0 max(16px, env(safe-area-inset-bottom)) 0;
width: 100%;
max-width: 480px;
box-shadow: 0 -4px 24px rgba(0,0,0,0.18);
}
.msg-sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--border);
margin: 0 auto 12px;
}
.msg-sheet-btn {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px 24px;
font-size: 15px;
color: var(--text-primary);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background var(--transition);
}
.msg-sheet-btn:hover:not(:disabled) {
background: var(--surface-variant);
}
.msg-sheet-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.msg-sheet-cancel {
color: var(--text-secondary);
font-weight: 500;
border-top: 1px solid var(--border);
margin-top: 4px;
justify-content: center;
gap: 0;
}

View File

@@ -31,7 +31,7 @@ function isEmojiOnly(str) {
return emojiRegex.test(str.trim());
}
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect, onPin, onUnpin, isPinned, pinCount = 0, onlineUserIds = new Set() }) {
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect, onlineUserIds = new Set() }) {
const [showActions, setShowActions] = useState(false);
const [showOptionsMenu, setShowOptionsMenu] = useState(false);
const longPressTimer = useRef(null);
@@ -240,16 +240,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
)}
{isDirect && (
<button
className={`btn-icon action-btn${isPinned ? ' action-pinned' : ''}`}
onClick={() => { isPinned ? onUnpin(msg.id) : (pinCount < 5 && onPin(msg.id)); setShowActions(false); }}
disabled={!isPinned && pinCount >= 5}
title={isPinned ? 'Unpin message' : pinCount >= 5 ? 'Pin limit reached (5/5)' : `Pin message (${pinCount + 1}/5)`}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
</button>
)}
{/* Emoji picker anchored to the toolbar */}
{showEmojiPicker && (
<div
@@ -286,36 +277,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
<span className="msg-time">{formatTime(msg.created_at)}</span>
{/* Mobile long-press / right-click bottom sheet for pin */}
{isDirect && showOptionsMenu && (
<div className="msg-sheet-overlay" onClick={() => setShowOptionsMenu(false)}>
<div
className="msg-sheet"
ref={optionsMenuRef}
onClick={e => e.stopPropagation()}
>
<div className="msg-sheet-handle" />
{isPinned ? (
<button className="msg-sheet-btn" onClick={() => { onUnpin(msg.id); setShowOptionsMenu(false); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
Unpin message
</button>
) : (
<button
className="msg-sheet-btn"
onClick={() => { if (pinCount < 5) { onPin(msg.id); setShowOptionsMenu(false); } }}
disabled={pinCount >= 5}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
{pinCount >= 5 ? 'Pin limit reached (5/5)' : `Pin message (${pinCount + 1}/5)`}
</button>
)}
<button className="msg-sheet-btn msg-sheet-cancel" onClick={() => setShowOptionsMenu(false)}>
Cancel
</button>
</div>
</div>
)}
</div>
{Object.keys(reactionMap).length > 0 && (

View File

@@ -154,6 +154,55 @@
flex-shrink: 0;
}
/* Conversation pin button — visible on hover (desktop) or always when pinned */
.group-item-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.conv-pin-btn {
display: none;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
color: var(--text-tertiary);
opacity: 0;
transition: opacity var(--transition), color var(--transition), background var(--transition);
flex-shrink: 0;
}
.conv-pin-btn:hover { background: var(--border); color: var(--primary); }
.conv-pin-btn.pinned {
display: flex;
opacity: 1;
color: var(--primary);
}
.group-item:hover .conv-pin-btn {
display: flex;
opacity: 0.7;
}
.group-item:hover .conv-pin-btn:hover { opacity: 1; }
/* Small pin icon inline before the name when conversation is pinned */
.conv-pin-indicator {
display: inline;
vertical-align: middle;
margin-right: 3px;
color: var(--primary);
opacity: 0.7;
position: relative;
top: -1px;
}
/* Pinned conversations get a subtle left accent */
.group-item.is-pinned {
border-left: 2px solid var(--primary);
padding-left: 14px;
}
.group-last-msg {
font-size: 13px;
color: var(--text-secondary);

View File

@@ -76,6 +76,35 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
const appName = settings.app_name || 'jama';
const logoUrl = settings.logo_url;
// Conversation pinning — derive from groups data (is_pinned flag from backend)
const [pinnedConvIds, setPinnedConvIds] = useState(new Set());
// Sync pinnedConvIds whenever groups data changes
useEffect(() => {
const allG = [...(groups.publicGroups || []), ...(groups.privateGroups || [])];
setPinnedConvIds(new Set(allG.filter(g => g.is_pinned).map(g => g.id)));
}, [groups]);
const handlePinConversation = async (e, groupId) => {
e.stopPropagation();
try {
await api.pinConversation(groupId);
setPinnedConvIds(prev => new Set([...prev, groupId]));
} catch (err) {
toast('Could not pin conversation', 'error');
}
};
const handleUnpinConversation = async (e, groupId) => {
e.stopPropagation();
try {
await api.unpinConversation(groupId);
setPinnedConvIds(prev => { const n = new Set(prev); n.delete(groupId); return n; });
} catch (err) {
toast('Could not unpin conversation', 'error');
}
};
const allGroups = [
...(groups.publicGroups || []),
...(groups.privateGroups || [])
@@ -83,14 +112,17 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
const publicFiltered = allGroups.filter(g => g.type === 'public');
// All private groups (DMs + group chats) sorted together by most recent message
const privateFiltered = allGroups
.filter(g => g.type === 'private')
.sort((a, b) => {
if (!a.last_message_at && !b.last_message_at) return 0;
if (!a.last_message_at) return 1;
if (!b.last_message_at) return -1;
return new Date(b.last_message_at) - new Date(a.last_message_at);
});
const sortWithPinned = (arr) => [...arr].sort((a, b) => {
const aPinned = pinnedConvIds.has(a.id) ? 1 : 0;
const bPinned = pinnedConvIds.has(b.id) ? 1 : 0;
if (bPinned !== aPinned) return bPinned - aPinned;
if (!a.last_message_at && !b.last_message_at) return 0;
if (!a.last_message_at) return 1;
if (!b.last_message_at) return -1;
return new Date(b.last_message_at) - new Date(a.last_message_at);
});
const privateFiltered = sortWithPinned(allGroups.filter(g => g.type === 'private'));
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
@@ -102,11 +134,24 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
const hasUnread = unreadCount > 0;
const isActive = group.id === activeGroupId;
const isOnline = !!group.is_direct && !!group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false);
const isPinned = pinnedConvIds.has(group.id);
// Long-press for mobile pin
const longPressTimer = useRef(null);
const handleTouchStart = () => {
longPressTimer.current = setTimeout(() => {
isPinned ? handleUnpinConversation({ stopPropagation: () => {} }, group.id)
: handlePinConversation({ stopPropagation: () => {} }, group.id);
}, 600);
};
const handleTouchEnd = () => clearTimeout(longPressTimer.current);
return (
<div
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''} ${isPinned ? 'is-pinned' : ''}`}
onClick={() => onSelectGroup(group.id)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchEnd}
>
<div className="group-icon-wrap">
{group.is_direct && group.peer_avatar ? (
@@ -126,13 +171,29 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
<div className="group-info flex-1 overflow-hidden">
<div className="flex items-center justify-between">
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>
{isPinned && (
<svg className="conv-pin-indicator" width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 2v4l-3 3v6l-2-2-2 2V9L6 6V2h10z"/>
</svg>
)}
{group.is_direct && group.peer_display_name
? <>{group.peer_display_name}<span className="dm-real-name"> ({group.peer_real_name})</span></>
: group.is_direct && group.peer_real_name ? group.peer_real_name : group.name}
</span>
{group.last_message_at && (
<span className="group-time">{formatTime(group.last_message_at)}</span>
)}
<div className="group-item-actions">
{group.last_message_at && (
<span className="group-time">{formatTime(group.last_message_at)}</span>
)}
<button
className={`conv-pin-btn ${isPinned ? 'pinned' : ''}`}
onClick={(e) => isPinned ? handleUnpinConversation(e, group.id) : handlePinConversation(e, group.id)}
title={isPinned ? 'Unpin conversation' : 'Pin to top'}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 2v4l-3 3v6l-2-2-2 2V9L6 6V2h10z"/>
</svg>
</button>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<span className="group-last-msg truncate">

View File

@@ -122,10 +122,9 @@ export const api = {
// Link preview
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
// Message pinning (DMs only, max 5 per conversation)
getPinnedMessages: (groupId) => req('GET', `/messages/pinned?groupId=${groupId}`),
pinMessage: (messageId) => req('POST', `/messages/${messageId}/pin`),
unpinMessage: (messageId) => req('DELETE', `/messages/${messageId}/pin`),
// Conversation pinning (pin a group to top of sidebar)
pinConversation: (groupId) => req('POST', `/groups/${groupId}/pin`),
unpinConversation: (groupId) => req('DELETE', `/groups/${groupId}/pin`),
// VAPID key management (admin only)
generateVapidKeys: () => req('POST', '/push/generate-vapid'),