From b3aac1981c5b9ace94dd82faa306d2fa081dc865 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Fri, 13 Mar 2026 10:51:27 -0400 Subject: [PATCH] v0.8.5 fix pinning --- .env.example | 2 +- backend/package.json | 2 +- backend/src/models/db.js | 23 +- backend/src/routes/groups.js | 16 +- backend/src/routes/messages.js | 67 ------ build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/ChatWindow.jsx | 293 +------------------------ frontend/src/components/Message.css | 65 ------ frontend/src/components/Message.jsx | 44 +--- frontend/src/components/Sidebar.css | 49 +++++ frontend/src/components/Sidebar.jsx | 85 ++++++- frontend/src/utils/api.js | 7 +- 13 files changed, 153 insertions(+), 504 deletions(-) diff --git a/.env.example b/.env.example index e5fa1a0..a00c11b 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ TZ=UTC # Copy this file to .env and customize # Image version to run (set by build.sh, or use 'latest') -JAMA_VERSION=0.8.4 +JAMA_VERSION=0.8.5 # Default admin credentials (used on FIRST RUN only) ADMIN_NAME=Admin User diff --git a/backend/package.json b/backend/package.json index 9793ab2..36ac3c8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.8.4", + "version": "0.8.5", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/db.js b/backend/src/models/db.js index 2bad431..dee520b 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -207,23 +207,20 @@ function initDb() { console.log('[DB] Migration: user_group_names table ready'); } catch (e) { console.error('[DB] user_group_names migration error:', e.message); } - // Migration: pinned messages within DMs (per-user, up to 5 per DM group) + // Migration: pinned conversations (per-user, pins a group to top of sidebar) try { db.exec(` - CREATE TABLE IF NOT EXISTS pinned_messages ( - user_id INTEGER NOT NULL, - message_id INTEGER NOT NULL, - group_id INTEGER NOT NULL, - pinned_at TEXT NOT NULL DEFAULT (datetime('now')), - pin_order INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, message_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE + CREATE TABLE IF NOT EXISTS pinned_conversations ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + pinned_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, group_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE ) `); - console.log('[DB] Migration: pinned_messages table ready'); - } catch (e) { console.error('[DB] pinned_messages migration error:', e.message); } + console.log('[DB] Migration: pinned_conversations table ready'); + } catch (e) { console.error('[DB] pinned_conversations migration error:', e.message); } console.log('[DB] Schema initialized'); return db; diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index 219396c..3e96f92 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -53,11 +53,13 @@ router.get('/', authMiddleware, (req, res) => { (SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count, (SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message, (SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at, - (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id + (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id, + CASE WHEN pc.group_id IS NOT NULL THEN 1 ELSE 0 END as is_pinned FROM groups g + LEFT JOIN pinned_conversations pc ON pc.group_id = g.id AND pc.user_id = ? WHERE g.type = 'public' - ORDER BY g.is_default DESC, g.name ASC - `).all(); + ORDER BY is_pinned DESC, g.is_default DESC, g.name ASC + `).all(userId); // For direct messages, replace name with opposite user's display name const privateGroupsRaw = db.prepare(` @@ -66,13 +68,15 @@ router.get('/', authMiddleware, (req, res) => { (SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count, (SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message, (SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at, - (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id + (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id, + CASE WHEN pc.group_id IS NOT NULL THEN 1 ELSE 0 END as is_pinned FROM groups g JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ? LEFT JOIN users u ON g.owner_id = u.id + LEFT JOIN pinned_conversations pc ON pc.group_id = g.id AND pc.user_id = ? WHERE g.type = 'private' - ORDER BY last_message_at DESC NULLS LAST - `).all(userId); + ORDER BY is_pinned DESC, last_message_at DESC NULLS LAST + `).all(userId, userId); // For direct groups, set the name to the other user's display name // Uses direct_peer1_id / direct_peer2_id so the name survives after a user leaves diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js index c0bf8d8..51a6d56 100644 --- a/backend/src/routes/messages.js +++ b/backend/src/routes/messages.js @@ -173,71 +173,4 @@ router.post('/:id/reactions', authMiddleware, (req, res) => { }); -// Get pinned messages for a DM group -router.get('/pinned', authMiddleware, (req, res) => { - const db = getDb(); - const userId = req.user.id; - const groupId = parseInt(req.query.groupId); - if (!groupId) return res.status(400).json({ error: 'groupId required' }); - - // Verify membership - const member = db.prepare('SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId); - if (!member) return res.status(403).json({ error: 'Not a member' }); - - const pinned = db.prepare(` - SELECT m.id, m.content, m.image_url, m.created_at, m.user_id, - u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, - pm.pin_order, pm.pinned_at - FROM pinned_messages pm - JOIN messages m ON pm.message_id = m.id - JOIN users u ON m.user_id = u.id - WHERE pm.user_id = ? AND pm.group_id = ? AND m.is_deleted = 0 - ORDER BY pm.pin_order ASC - `).all(userId, groupId); - - res.json({ pinned, count: pinned.length }); -}); - -// Pin a message in a DM -router.post('/:id/pin', authMiddleware, (req, res) => { - const db = getDb(); - const userId = req.user.id; - const messageId = parseInt(req.params.id); - - const msg = db.prepare('SELECT m.*, g.is_direct FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ? AND m.is_deleted = 0').get(messageId); - if (!msg) return res.status(404).json({ error: 'Message not found' }); - if (!msg.is_direct) return res.status(400).json({ error: 'Can only pin messages in direct messages' }); - - // Verify membership - const member = db.prepare('SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?').get(msg.group_id, userId); - if (!member) return res.status(403).json({ error: 'Not a member' }); - - // Check limit (5 per group per user) - const count = db.prepare('SELECT COUNT(*) as n FROM pinned_messages WHERE user_id = ? AND group_id = ?').get(userId, msg.group_id).n; - if (count >= 5) return res.status(400).json({ error: 'Maximum 5 pinned messages per conversation' }); - - const maxOrder = db.prepare('SELECT MAX(pin_order) as m FROM pinned_messages WHERE user_id = ? AND group_id = ?').get(userId, msg.group_id).m || 0; - - db.prepare('INSERT OR IGNORE INTO pinned_messages (user_id, message_id, group_id, pin_order) VALUES (?, ?, ?, ?)') - .run(userId, messageId, msg.group_id, maxOrder + 1); - - const newCount = db.prepare('SELECT COUNT(*) as n FROM pinned_messages WHERE user_id = ? AND group_id = ?').get(userId, msg.group_id).n; - res.json({ ok: true, count: newCount }); -}); - -// Unpin a message -router.delete('/:id/pin', authMiddleware, (req, res) => { - const db = getDb(); - const userId = req.user.id; - const messageId = parseInt(req.params.id); - - const msg = db.prepare('SELECT group_id FROM messages WHERE id = ?').get(messageId); - if (!msg) return res.status(404).json({ error: 'Message not found' }); - - db.prepare('DELETE FROM pinned_messages WHERE user_id = ? AND message_id = ?').run(userId, messageId); - - const newCount = db.prepare('SELECT COUNT(*) as n FROM pinned_messages WHERE user_id = ? AND group_id = ?').get(userId, msg.group_id).n; - res.json({ ok: true, count: newCount }); -}); - module.exports = router; diff --git a/build.sh b/build.sh index 011c847..f5726a5 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.8.4}" +VERSION="${1:-0.8.5}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 0d7cb69..17c79ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.8.4", + "version": "0.8.5", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 8a3ee69..efebafe 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -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 ( -
-
-
- - - -
-

Select a conversation

-

Choose from your existing chats or start a new one

-
-
- ); - } - - 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 ( -
- {/* Header */} -
- {onBack && ( - - )} -
- {group.is_direct && group.peer_avatar ? ( - {group.name} - ) : ( -
- {group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()} -
- )} - {!!group.is_direct && group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false) && ( - - )} -
-
-
- {group.is_direct && group.peer_display_name ? ( - - {group.peer_display_name} - ({group.peer_real_name}) - - ) : ( - {group.is_direct && group.peer_real_name ? group.peer_real_name : group.name} - )} - {group.is_readonly ? ( - Read-only - ) : null} -
- - {group.is_direct ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'} - -
- -
- - {/* Messages */} -
- {hasMore && ( - - )} - {loading ? ( -
-
-
- ) : ( - <> - {messages.map((msg, i) => ( - 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 && ( -
- {typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing - -
- )} -
- - )} -
- - {/* Input */} - {(!group.is_readonly || user.role === 'admin') ? ( - 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} - /> - ) : ( -
- - This message is read-only -
- )} - - {showInfo && ( - setShowInfo(false)} - onUpdated={onGroupUpdated} - onBack={onBack} - /> - )} -
- ); -} diff --git a/frontend/src/components/Message.css b/frontend/src/components/Message.css index d1d67f5..02654d9 100644 --- a/frontend/src/components/Message.css +++ b/frontend/src/components/Message.css @@ -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; -} diff --git a/frontend/src/components/Message.jsx b/frontend/src/components/Message.jsx index 87b3534..a51c0f0 100644 --- a/frontend/src/components/Message.jsx +++ b/frontend/src/components/Message.jsx @@ -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 )} - {isDirect && ( - - )} + {/* Emoji picker anchored to the toolbar */} {showEmojiPicker && (
{formatTime(msg.created_at)} - {/* Mobile long-press / right-click bottom sheet for pin */} - {isDirect && showOptionsMenu && ( -
setShowOptionsMenu(false)}> -
e.stopPropagation()} - > -
- {isPinned ? ( - - ) : ( - - )} - -
-
- )} +
{Object.keys(reactionMap).length > 0 && ( diff --git a/frontend/src/components/Sidebar.css b/frontend/src/components/Sidebar.css index 98a1697..b01db65 100644 --- a/frontend/src/components/Sidebar.css +++ b/frontend/src/components/Sidebar.css @@ -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); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index aa2f596..c897edd 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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 (
onSelectGroup(group.id)} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchEnd} >
{group.is_direct && group.peer_avatar ? ( @@ -126,13 +171,29 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
+ {isPinned && ( + + + + )} {group.is_direct && group.peer_display_name ? <>{group.peer_display_name} ({group.peer_real_name}) : group.is_direct && group.peer_real_name ? group.peer_real_name : group.name} - {group.last_message_at && ( - {formatTime(group.last_message_at)} - )} +
+ {group.last_message_at && ( + {formatTime(group.last_message_at)} + )} + +
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index d98bb02..501a5f1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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'),