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 ? (
-
- ) : (
-