diff --git a/backend/src/models/db.js b/backend/src/models/db.js index 71b58ff..15ff75e 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -207,6 +207,22 @@ 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) + 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 + ) + `); + console.log('[DB] Migration: pinned_direct_messages table ready'); + } catch (e) { console.error('[DB] pinned_direct_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 8f4c145..22557ea 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -40,6 +40,42 @@ 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 @@ -65,13 +101,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, + pdm.pin_order as pin_order 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); + `).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 @@ -91,6 +129,7 @@ router.get('/', authMiddleware, (req, res) => { if (otherUserId) { const other = db.prepare('SELECT display_name, name, avatar FROM users WHERE id = ?').get(otherUserId); if (other) { + g.peer_id = otherUserId; g.peer_real_name = other.name; g.peer_display_name = other.display_name || null; // null if no custom display name set g.peer_avatar = other.avatar || null; @@ -399,4 +438,4 @@ router.patch('/:id/custom-name', authMiddleware, (req, res) => { }); return router; -}; +}; \ No newline at end of file diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index c9758ba..d33474c 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -8,7 +8,7 @@ import MessageInput from './MessageInput.jsx'; import GroupInfoModal from './GroupInfoModal.jsx'; import './ChatWindow.css'; -export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage }) { +export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onlineUserIds = new Set() }) { const { socket } = useSocket(); const { user } = useAuth(); const toast = useToast(); @@ -275,6 +275,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess else socket.emit('typing:stop', { groupId: group.id }); } }} + onlineUserIds={onlineUserIds} /> ) : (
diff --git a/frontend/src/components/MessageInput.jsx b/frontend/src/components/MessageInput.jsx index d1f356e..f43c00a 100644 --- a/frontend/src/components/MessageInput.jsx +++ b/frontend/src/components/MessageInput.jsx @@ -12,7 +12,7 @@ function isEmojiOnly(str) { return emojiRegex.test(str.trim()); } -export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) { +export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onlineUserIds = new Set() }) { const [text, setText] = useState(''); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(null); @@ -269,7 +269,10 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on className={`mention-item ${i === mentionIndex ? 'active' : ''}`} onMouseDown={(e) => { e.preventDefault(); insertMention(u); }} > -
{(u.display_name || u.name)?.[0]?.toUpperCase()}
+
+
{(u.display_name || u.name)?.[0]?.toUpperCase()}
+ {onlineUserIds.has(u.id) && } +
{u.display_name || u.name} {u.role} diff --git a/frontend/src/components/Sidebar.css b/frontend/src/components/Sidebar.css index f66f1d2..98a1697 100644 --- a/frontend/src/components/Sidebar.css +++ b/frontend/src/components/Sidebar.css @@ -230,3 +230,73 @@ font-weight: 400; color: var(--text-tertiary); } + +/* Online presence dot on DM avatars */ +.group-icon-wrap { + position: relative; + flex-shrink: 0; + display: inline-flex; +} + +.online-dot { + position: absolute; + bottom: 1px; + right: 1px; + width: 11px; + height: 11px; + background: #34a853; + border-radius: 50%; + border: 2px solid var(--surface); + pointer-events: none; +} + +/* Pin sublabel */ +.section-sublabel { + display: flex; + align-items: center; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--text-tertiary); + padding: 4px 12px 2px; + text-transform: uppercase; +} + +/* Thin divider between pinned and unpinned */ +.section-divider { + height: 1px; + background: var(--border); + margin: 4px 12px; + opacity: 0.6; +} + +/* DM right-click context menu */ +.dm-context-menu { + position: fixed; + z-index: 9999; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + padding: 4px 0; + min-width: 180px; +} + +.dm-context-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); +} + +.dm-context-menu button:hover { + background: var(--surface-variant); +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 4c45ed8..cd6f9cf 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -45,16 +45,45 @@ function useAppSettings() { return settings; } -export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated, isMobile, onAbout, onHelp }) { +export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set() }) { const { user, logout } = useAuth(); 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(() => { @@ -82,7 +111,20 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica ]; const publicFiltered = allGroups.filter(g => g.type === 'public'); - const privateFiltered = allGroups.filter(g => g.type === 'private'); + 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) + .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 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 getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length; @@ -93,21 +135,37 @@ 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 }); + }; return ( -
onSelectGroup(group.id)}> - {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()} -
- )} +
onSelectGroup(group.id)} + onContextMenu={handleContextMenu} + > +
+ {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()} +
+ )} + {isOnline && } +
@@ -161,7 +219,18 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica {privateFiltered.length > 0 && (
DIRECT MESSAGES
- {privateFiltered.map(g => )} + {pinnedDMs.length > 0 && ( + <> +
+ + PINNED +
+ {pinnedDMs.map(g => )} + {unpinnedDMs.length > 0 &&
} + + )} + {privateNonDM.map(g => )} + {unpinnedDMs.map(g => )}
)} {allGroups.length === 0 && ( @@ -250,6 +319,26 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
)}
+ {/* DM pin context menu */} + {contextMenu && ( +
e.stopPropagation()} + > + {contextMenu.isPinned ? ( + + ) : ( + + )} +
+ )}
); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 816bd32..b6452a8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -446,3 +446,22 @@ a { color: inherit; text-decoration: none; } [data-theme="dark"] .help-markdown code { background: var(--surface); } [data-theme="dark"] .help-markdown pre { background: var(--surface); } [data-theme="dark"] .help-markdown blockquote { background: rgba(99,102,241,0.1); } + +/* Mention picker online dot */ +.mention-avatar-wrap { + position: relative; + display: inline-flex; + flex-shrink: 0; +} + +.mention-online-dot { + position: absolute; + bottom: 0; + right: 0; + width: 9px; + height: 9px; + background: #34a853; + border-radius: 50%; + border: 2px solid var(--surface); + pointer-events: none; +} diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index 2bb7536..321e64e 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -29,6 +29,7 @@ export default function Chat() { const toast = useToast(); const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] }); + const [onlineUserIds, setOnlineUserIds] = useState(new Set()); const [activeGroupId, setActiveGroupId] = useState(null); const [notifications, setNotifications] = useState([]); const [unreadGroups, setUnreadGroups] = useState(new Map()); @@ -205,6 +206,17 @@ export default function Chat() { window.dispatchEvent(new CustomEvent('jama:session-displaced')); }; + // Online presence + const handleUserOnline = ({ userId }) => setOnlineUserIds(prev => new Set([...prev, userId])); + const handleUserOffline = ({ userId }) => setOnlineUserIds(prev => { const n = new Set(prev); n.delete(userId); return n; }); + const handleUsersOnline = ({ userIds }) => setOnlineUserIds(new Set(userIds)); + + socket.on('user:online', handleUserOnline); + socket.on('user:offline', handleUserOffline); + socket.on('users:online', handleUsersOnline); + // Request current online list on connect + socket.emit('users:online'); + socket.on('group:new', handleGroupNew); socket.on('group:deleted', handleGroupDeleted); socket.on('group:updated', handleGroupUpdated); @@ -228,6 +240,9 @@ export default function Chat() { socket.off('group:new', handleGroupNew); socket.off('group:deleted', handleGroupDeleted); socket.off('group:updated', handleGroupUpdated); + socket.off('user:online', handleUserOnline); + socket.off('user:offline', handleUserOffline); + socket.off('users:online', handleUsersOnline); socket.off('connect', handleReconnect); socket.off('session:displaced', handleSessionDisplaced); document.removeEventListener('visibilitychange', handleVisibility); @@ -284,6 +299,7 @@ export default function Chat() { isMobile={isMobile} onAbout={() => setModal('about')} onHelp={() => setModal('help')} + onlineUserIds={onlineUserIds} /> )} @@ -293,6 +309,7 @@ export default function Chat() { onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null} onGroupUpdated={loadGroups} onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }} + onlineUserIds={onlineUserIds} /> )}