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 ( -
Choose from your existing chats or start a new one
-