From d82278482616d35c9e0f2705fd108b67647ad200 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Wed, 11 Mar 2026 15:38:28 -0400 Subject: [PATCH] v0.7.1 bugs fix for last update --- .env.example | 2 +- backend/package.json | 2 +- backend/src/models/db.js | 24 ++++---- backend/src/routes/groups.js | 41 +------------ backend/src/routes/messages.js | 68 +++++++++++++++++++++ build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/ChatWindow.jsx | 47 ++++++++++----- frontend/src/components/Message.css | 49 ++++++++++++++- frontend/src/components/Message.jsx | 74 ++++++++++++++++++++++- frontend/src/components/Sidebar.jsx | 83 ++------------------------ 11 files changed, 244 insertions(+), 150 deletions(-) diff --git a/.env.example b/.env.example index 9d53215..504b8c4 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.7.1 +JAMA_VERSION=0.7.2 # Default admin credentials (used on FIRST RUN only) ADMIN_NAME=Admin User diff --git a/backend/package.json b/backend/package.json index 45dd5d9..2c9c3e3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.7.1", + "version": "0.7.2", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/db.js b/backend/src/models/db.js index 15ff75e..2bad431 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -207,21 +207,23 @@ 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 direct messages (per-user, up to 5) + // Migration: pinned messages within DMs (per-user, up to 5 per DM group) try { db.exec(` - CREATE TABLE IF NOT EXISTS pinned_direct_messages ( - user_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, group_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE + 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 ) `); - console.log('[DB] Migration: pinned_direct_messages table ready'); - } catch (e) { console.error('[DB] pinned_direct_messages migration error:', e.message); } + console.log('[DB] Migration: pinned_messages table ready'); + } catch (e) { console.error('[DB] pinned_messages 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 22557ea..219396c 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -41,41 +41,6 @@ function emitGroupUpdated(io, groupId) { // Inject io into routes -// Pin a DM (max 5 per user) -router.post('/:id/pin', authMiddleware, (req, res) => { - const db = getDb(); - const userId = req.user.id; - const groupId = parseInt(req.params.id); - - // Verify it's a DM this user is part of - const group = db.prepare('SELECT * FROM groups WHERE id = ? AND is_direct = 1').get(groupId); - if (!group) return res.status(404).json({ error: 'DM not found' }); - 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' }); - - // Check limit - const count = db.prepare('SELECT COUNT(*) as n FROM pinned_direct_messages WHERE user_id = ?').get(userId).n; - if (count >= 5) return res.status(400).json({ error: 'Maximum 5 pinned DMs allowed' }); - - // Get next pin_order - const maxOrder = db.prepare('SELECT MAX(pin_order) as m FROM pinned_direct_messages WHERE user_id = ?').get(userId).m || 0; - - db.prepare('INSERT OR IGNORE INTO pinned_direct_messages (user_id, group_id, pin_order) VALUES (?, ?, ?)') - .run(userId, groupId, maxOrder + 1); - - res.json({ ok: true }); -}); - -// Unpin a DM -router.delete('/:id/pin', authMiddleware, (req, res) => { - const db = getDb(); - const userId = req.user.id; - const groupId = parseInt(req.params.id); - - db.prepare('DELETE FROM pinned_direct_messages WHERE user_id = ? AND group_id = ?').run(userId, groupId); - res.json({ ok: true }); -}); - module.exports = (io) => { // Get all groups for current user @@ -101,15 +66,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, - pdm.pin_order as pin_order + (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 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_direct_messages pdm ON pdm.group_id = g.id AND pdm.user_id = ? WHERE g.type = 'private' ORDER BY last_message_at DESC NULLS LAST - `).all(userId, userId); + `).all(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 ead673d..c0bf8d8 100644 --- a/backend/src/routes/messages.js +++ b/backend/src/routes/messages.js @@ -172,4 +172,72 @@ 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 f4e211f..9941e72 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.7.1}" +VERSION="${1:-0.7.2}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index f41d118..3c79d38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.7.1", + "version": "0.7.2", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index d33474c..67e1071 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -20,6 +20,8 @@ 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({}); @@ -183,21 +185,31 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess )} - {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_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.has(group.peer_id) && ( + + )} +
{group.is_direct && group.peer_display_name ? ( @@ -249,6 +261,11 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess 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 && ( diff --git a/frontend/src/components/Message.css b/frontend/src/components/Message.css index 2a2fdfd..5de05de 100644 --- a/frontend/src/components/Message.css +++ b/frontend/src/components/Message.css @@ -103,12 +103,14 @@ /* Bubble row */ .msg-bubble-wrap { + position: relative; display: flex; align-items: flex-start; gap: 6px; } -.own .msg-bubble-wrap { flex-direction: row-reverse; } +.own .msg-bubble-wrap { + position: relative; flex-direction: row-reverse; } /* Wrapper that holds the actions toolbar + bubble together */ .msg-bubble-with-actions { @@ -331,3 +333,48 @@ margin: 0; user-select: text; } + +/* Message pin/options popup menu */ +.msg-options-menu { + position: absolute; + z-index: 200; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + padding: 4px 0; + min-width: 170px; + top: calc(100% + 4px); +} + +.msg-options-menu.options-left { + right: 0; +} + +.msg-options-menu.options-right { + left: 0; +} + +.msg-options-menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 14px; + font-size: 13px; + color: var(--text-primary); + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: background var(--transition); +} + +.msg-options-menu button:hover:not(:disabled) { + background: var(--surface-variant); +} + +.msg-options-menu button:disabled { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/frontend/src/components/Message.jsx b/frontend/src/components/Message.jsx index 7bb54d1..c370dc5 100644 --- a/frontend/src/components/Message.jsx +++ b/frontend/src/components/Message.jsx @@ -31,8 +31,11 @@ function isEmojiOnly(str) { return emojiRegex.test(str.trim()); } -export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect }) { +export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect, onPin, onUnpin, isPinned, pinCount = 0, onlineUserIds = new Set() }) { const [showActions, setShowActions] = useState(false); + const [showOptionsMenu, setShowOptionsMenu] = useState(false); + const longPressTimer = useRef(null); + const optionsMenuRef = useRef(null); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const wrapperRef = useRef(null); const pickerRef = useRef(null); @@ -113,6 +116,30 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl setShowEmojiPicker(p => !p); }; + // Long press for mobile action menu + const handleTouchStart = () => { + longPressTimer.current = setTimeout(() => setShowOptionsMenu(true), 500); + }; + const handleTouchEnd = () => { + if (longPressTimer.current) clearTimeout(longPressTimer.current); + }; + + // Close options menu on outside click + useEffect(() => { + if (!showOptionsMenu) return; + const close = (e) => { + if (optionsMenuRef.current && !optionsMenuRef.current.contains(e.target)) { + setShowOptionsMenu(false); + } + }; + document.addEventListener('mousedown', close); + document.addEventListener('touchstart', close); + return () => { + document.removeEventListener('mousedown', close); + document.removeEventListener('touchstart', close); + }; + }, [showOptionsMenu]); + const msgUser = { id: msg.user_id, name: msg.user_name, @@ -139,12 +166,20 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl {!isOwn && !prevSameUser && (
setShowProfile(p => !p)} onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'} onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} > + {onlineUserIds.has(msg.user_id) && ( + + )}
)} {!isOwn && prevSameUser &&
} @@ -178,6 +213,9 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
setShowActions(true)} onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }} + onTouchStart={isDirect ? handleTouchStart : undefined} + onTouchEnd={isDirect ? handleTouchEnd : undefined} + onTouchMove={isDirect ? handleTouchEnd : undefined} > {/* Actions toolbar — floats above the bubble, aligned to correct side */} {!isDeleted && (showActions || showEmojiPicker) && ( @@ -201,6 +239,13 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl )} + {isDirect && ( +
+ +
+ )} {/* Emoji picker anchored to the toolbar */} {showEmojiPicker && ( @@ -237,6 +282,31 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
{formatTime(msg.created_at)} + + {/* Pin/unpin options menu — desktop triple-dot + mobile long-press */} + {isDirect && showOptionsMenu && ( +
e.stopPropagation()} + > + {isPinned ? ( + + ) : ( + + )} +
+ )}
{Object.keys(reactionMap).length > 0 && ( diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index cd6f9cf..818057e 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -50,40 +50,11 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica const { connected } = useSocket(); const toast = useToast(); const [showMenu, setShowMenu] = useState(false); - const [contextMenu, setContextMenu] = useState(null); // { groupId, x, y, isPinned } const settings = useAppSettings(); const [dark, setDark] = useTheme(); const menuRef = useRef(null); const footerBtnRef = useRef(null); - const handlePin = async (groupId) => { - try { - await api.pinDM(groupId); - onGroupsUpdated(); - } catch (e) { toast(e.message, 'error'); } - setContextMenu(null); - }; - - const handleUnpin = async (groupId) => { - try { - await api.unpinDM(groupId); - onGroupsUpdated(); - } catch (e) { toast(e.message, 'error'); } - setContextMenu(null); - }; - - // Close context menu on outside click - useEffect(() => { - if (!contextMenu) return; - const close = () => setContextMenu(null); - document.addEventListener('mousedown', close); - document.addEventListener('touchstart', close); - return () => { - document.removeEventListener('mousedown', close); - document.removeEventListener('touchstart', close); - }; - }, [contextMenu]); - // Fix 6: swipe right to go back on mobile — handled in ChatWindow, but prevent sidebar swipe exit // Close menu on click outside useEffect(() => { @@ -111,11 +82,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica ]; const publicFiltered = allGroups.filter(g => g.type === 'public'); - const pinnedDMs = allGroups - .filter(g => g.type === 'private' && g.is_direct && g.pin_order != null) - .sort((a, b) => a.pin_order - b.pin_order); - const unpinnedDMs = allGroups - .filter(g => g.type === 'private' && g.is_direct && g.pin_order == null) + const dms = allGroups + .filter(g => g.type === 'private' && !!g.is_direct) .sort((a, b) => { if (!a.last_message_at && !b.last_message_at) return 0; if (!a.last_message_at) return 1; @@ -123,8 +91,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica return new Date(b.last_message_at) - new Date(a.last_message_at); }); const privateNonDM = allGroups.filter(g => g.type === 'private' && !g.is_direct); - const privateFiltered = [...privateNonDM, ...pinnedDMs, ...unpinnedDMs]; - const pinnedGroupIds = new Set(pinnedDMs.map(g => g.id)); + const privateFiltered = [...privateNonDM, ...dms]; const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length; @@ -135,21 +102,12 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica const unreadCount = unreadGroups.get(group.id) || 0; const hasUnread = unreadCount > 0; const isActive = group.id === activeGroupId; - const isPinned = pinnedGroupIds.has(group.id); - const isOnline = group.is_direct && group.peer_id && onlineUserIds.has(group.peer_id); - - const handleContextMenu = (e) => { - if (!group.is_direct) return; - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ groupId: group.id, x: e.clientX, y: e.clientY, isPinned }); - }; + const isOnline = !!group.is_direct && !!group.peer_id && onlineUserIds.has(group.peer_id); return (
onSelectGroup(group.id)} - onContextMenu={handleContextMenu} >
{group.is_direct && group.peer_avatar ? ( @@ -219,18 +177,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica {privateFiltered.length > 0 && (
DIRECT MESSAGES
- {pinnedDMs.length > 0 && ( - <> -
- - PINNED -
- {pinnedDMs.map(g => )} - {unpinnedDMs.length > 0 &&
} - - )} - {privateNonDM.map(g => )} - {unpinnedDMs.map(g => )} + {privateFiltered.map(g => )}
)} {allGroups.length === 0 && ( @@ -319,26 +266,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
)}
- {/* DM pin context menu */} - {contextMenu && ( -
e.stopPropagation()} - > - {contextMenu.isPinned ? ( - - ) : ( - - )} -
- )}
); }