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}
/>
) : (