v0.8.5 fix pinning
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-backend",
|
||||
"version": "0.8.4",
|
||||
"version": "0.8.5",
|
||||
"description": "TeamChat backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
2
build.sh
2
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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.8.4",
|
||||
"version": "0.8.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 (
|
||||
<div className="chat-window empty">
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" width="64" height="64">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Select a conversation</h3>
|
||||
<p>Choose from your existing chats or start a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
swipeStartX.current = e.touches[0].clientX;
|
||||
swipeStartY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
if (swipeStartX.current === null || !onBack) return;
|
||||
const dx = e.changedTouches[0].clientX - swipeStartX.current;
|
||||
const dy = Math.abs(e.changedTouches[0].clientY - swipeStartY.current);
|
||||
// Swipe right: at least 80px horizontal, less than 60px vertical drift
|
||||
if (dx > 80 && dy < 60) {
|
||||
e.preventDefault();
|
||||
onBack();
|
||||
}
|
||||
swipeStartX.current = null;
|
||||
swipeStartY.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="chat-window"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="chat-header">
|
||||
{onBack && (
|
||||
<button className="btn-icon" onClick={onBack}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{group.is_direct && group.peer_avatar ? (
|
||||
<img
|
||||
src={group.peer_avatar}
|
||||
alt={group.name}
|
||||
className="group-icon-sm"
|
||||
style={{ objectFit: 'cover', padding: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="group-icon-sm"
|
||||
style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}
|
||||
>
|
||||
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{!!group.is_direct && group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false) && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: 1, right: 1,
|
||||
width: 11, height: 11, borderRadius: '50%',
|
||||
background: '#34a853', border: '2px solid var(--surface)',
|
||||
pointerEvents: 'none'
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
{group.is_direct && group.peer_display_name ? (
|
||||
<span className="chat-header-name">
|
||||
{group.peer_display_name}
|
||||
<span className="chat-header-real-name"> ({group.peer_real_name})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="chat-header-name">{group.is_direct && group.peer_real_name ? group.peer_real_name : group.name}</span>
|
||||
)}
|
||||
{group.is_readonly ? (
|
||||
<span className="readonly-badge">Read-only</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="chat-header-sub">
|
||||
{group.is_direct ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Message info">
|
||||
{iconGroupInfo ? (
|
||||
<img src={iconGroupInfo} alt="Message info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="24" height="24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="messages-container" ref={messagesTopRef}>
|
||||
{hasMore && (
|
||||
<button className="load-more-btn" onClick={loadMore}>Load older messages</button>
|
||||
)}
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((msg, i) => (
|
||||
<Message
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
prevMessage={messages[i - 1]}
|
||||
currentUser={user}
|
||||
isDirect={!!group.is_direct}
|
||||
onReply={(m) => setReplyTo(m)}
|
||||
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 && (
|
||||
<div className="typing-indicator">
|
||||
<span>{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing</span>
|
||||
<span className="dots"><span/><span/><span/></span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{(!group.is_readonly || user.role === 'admin') ? (
|
||||
<MessageInput
|
||||
group={group}
|
||||
replyTo={replyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSend={handleSend}
|
||||
onTyping={(isTyping) => {
|
||||
if (socket) {
|
||||
if (isTyping) socket.emit('typing:start', { groupId: group.id });
|
||||
else socket.emit('typing:stop', { groupId: group.id });
|
||||
}
|
||||
}}
|
||||
onlineUserIds={onlineUserIds}
|
||||
/>
|
||||
) : (
|
||||
<div className="readonly-bar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
This message is read-only
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInfo && (
|
||||
<GroupInfoModal
|
||||
group={group}
|
||||
onClose={() => setShowInfo(false)}
|
||||
onUpdated={onGroupUpdated}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -334,68 +334,3 @@
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Pinned state highlight for toolbar pin button */
|
||||
.action-pinned {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* Mobile bottom-sheet overlay for long-press / right-click pin */
|
||||
.msg-sheet-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
background: rgba(0,0,0,0.35);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.msg-sheet {
|
||||
background: var(--surface);
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 8px 0 max(16px, env(safe-area-inset-bottom)) 0;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 -4px 24px rgba(0,0,0,0.18);
|
||||
}
|
||||
|
||||
.msg-sheet-handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
.msg-sheet-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 14px 24px;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.msg-sheet-btn:hover:not(:disabled) {
|
||||
background: var(--surface-variant);
|
||||
}
|
||||
|
||||
.msg-sheet-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.msg-sheet-cancel {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ function isEmojiOnly(str) {
|
||||
return emojiRegex.test(str.trim());
|
||||
}
|
||||
|
||||
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect, onPin, onUnpin, isPinned, pinCount = 0, onlineUserIds = new Set() }) {
|
||||
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect, onlineUserIds = new Set() }) {
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [showOptionsMenu, setShowOptionsMenu] = useState(false);
|
||||
const longPressTimer = useRef(null);
|
||||
@@ -240,16 +240,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{isDirect && (
|
||||
<button
|
||||
className={`btn-icon action-btn${isPinned ? ' action-pinned' : ''}`}
|
||||
onClick={() => { isPinned ? onUnpin(msg.id) : (pinCount < 5 && onPin(msg.id)); setShowActions(false); }}
|
||||
disabled={!isPinned && pinCount >= 5}
|
||||
title={isPinned ? 'Unpin message' : pinCount >= 5 ? 'Pin limit reached (5/5)' : `Pin message (${pinCount + 1}/5)`}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Emoji picker anchored to the toolbar */}
|
||||
{showEmojiPicker && (
|
||||
<div
|
||||
@@ -286,36 +277,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
|
||||
<span className="msg-time">{formatTime(msg.created_at)}</span>
|
||||
|
||||
{/* Mobile long-press / right-click bottom sheet for pin */}
|
||||
{isDirect && showOptionsMenu && (
|
||||
<div className="msg-sheet-overlay" onClick={() => setShowOptionsMenu(false)}>
|
||||
<div
|
||||
className="msg-sheet"
|
||||
ref={optionsMenuRef}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="msg-sheet-handle" />
|
||||
{isPinned ? (
|
||||
<button className="msg-sheet-btn" onClick={() => { onUnpin(msg.id); setShowOptionsMenu(false); }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
|
||||
Unpin message
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="msg-sheet-btn"
|
||||
onClick={() => { if (pinCount < 5) { onPin(msg.id); setShowOptionsMenu(false); } }}
|
||||
disabled={pinCount >= 5}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
|
||||
{pinCount >= 5 ? 'Pin limit reached (5/5)' : `Pin message (${pinCount + 1}/5)`}
|
||||
</button>
|
||||
)}
|
||||
<button className="msg-sheet-btn msg-sheet-cancel" onClick={() => setShowOptionsMenu(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{Object.keys(reactionMap).length > 0 && (
|
||||
|
||||
@@ -154,6 +154,55 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Conversation pin button — visible on hover (desktop) or always when pinned */
|
||||
.group-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-pin-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition), color var(--transition), background var(--transition);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.conv-pin-btn:hover { background: var(--border); color: var(--primary); }
|
||||
.conv-pin-btn.pinned {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
color: var(--primary);
|
||||
}
|
||||
.group-item:hover .conv-pin-btn {
|
||||
display: flex;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.group-item:hover .conv-pin-btn:hover { opacity: 1; }
|
||||
|
||||
/* Small pin icon inline before the name when conversation is pinned */
|
||||
.conv-pin-indicator {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
margin-right: 3px;
|
||||
color: var(--primary);
|
||||
opacity: 0.7;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
/* Pinned conversations get a subtle left accent */
|
||||
.group-item.is-pinned {
|
||||
border-left: 2px solid var(--primary);
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.group-last-msg {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -76,6 +76,35 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
const appName = settings.app_name || 'jama';
|
||||
const logoUrl = settings.logo_url;
|
||||
|
||||
// Conversation pinning — derive from groups data (is_pinned flag from backend)
|
||||
const [pinnedConvIds, setPinnedConvIds] = useState(new Set());
|
||||
|
||||
// Sync pinnedConvIds whenever groups data changes
|
||||
useEffect(() => {
|
||||
const allG = [...(groups.publicGroups || []), ...(groups.privateGroups || [])];
|
||||
setPinnedConvIds(new Set(allG.filter(g => g.is_pinned).map(g => g.id)));
|
||||
}, [groups]);
|
||||
|
||||
const handlePinConversation = async (e, groupId) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.pinConversation(groupId);
|
||||
setPinnedConvIds(prev => new Set([...prev, groupId]));
|
||||
} catch (err) {
|
||||
toast('Could not pin conversation', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinConversation = async (e, groupId) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.unpinConversation(groupId);
|
||||
setPinnedConvIds(prev => { const n = new Set(prev); n.delete(groupId); return n; });
|
||||
} catch (err) {
|
||||
toast('Could not unpin conversation', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const allGroups = [
|
||||
...(groups.publicGroups || []),
|
||||
...(groups.privateGroups || [])
|
||||
@@ -83,14 +112,17 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
|
||||
const publicFiltered = allGroups.filter(g => g.type === 'public');
|
||||
// All private groups (DMs + group chats) sorted together by most recent message
|
||||
const privateFiltered = allGroups
|
||||
.filter(g => g.type === 'private')
|
||||
.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 sortWithPinned = (arr) => [...arr].sort((a, b) => {
|
||||
const aPinned = pinnedConvIds.has(a.id) ? 1 : 0;
|
||||
const bPinned = pinnedConvIds.has(b.id) ? 1 : 0;
|
||||
if (bPinned !== aPinned) return bPinned - aPinned;
|
||||
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 privateFiltered = sortWithPinned(allGroups.filter(g => g.type === 'private'));
|
||||
|
||||
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||
|
||||
@@ -102,11 +134,24 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
const hasUnread = unreadCount > 0;
|
||||
const isActive = group.id === activeGroupId;
|
||||
const isOnline = !!group.is_direct && !!group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false);
|
||||
const isPinned = pinnedConvIds.has(group.id);
|
||||
// Long-press for mobile pin
|
||||
const longPressTimer = useRef(null);
|
||||
const handleTouchStart = () => {
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
isPinned ? handleUnpinConversation({ stopPropagation: () => {} }, group.id)
|
||||
: handlePinConversation({ stopPropagation: () => {} }, group.id);
|
||||
}, 600);
|
||||
};
|
||||
const handleTouchEnd = () => clearTimeout(longPressTimer.current);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
|
||||
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''} ${isPinned ? 'is-pinned' : ''}`}
|
||||
onClick={() => onSelectGroup(group.id)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchEnd}
|
||||
>
|
||||
<div className="group-icon-wrap">
|
||||
{group.is_direct && group.peer_avatar ? (
|
||||
@@ -126,13 +171,29 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
<div className="group-info flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>
|
||||
{isPinned && (
|
||||
<svg className="conv-pin-indicator" width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 2v4l-3 3v6l-2-2-2 2V9L6 6V2h10z"/>
|
||||
</svg>
|
||||
)}
|
||||
{group.is_direct && group.peer_display_name
|
||||
? <>{group.peer_display_name}<span className="dm-real-name"> ({group.peer_real_name})</span></>
|
||||
: group.is_direct && group.peer_real_name ? group.peer_real_name : group.name}
|
||||
</span>
|
||||
{group.last_message_at && (
|
||||
<span className="group-time">{formatTime(group.last_message_at)}</span>
|
||||
)}
|
||||
<div className="group-item-actions">
|
||||
{group.last_message_at && (
|
||||
<span className="group-time">{formatTime(group.last_message_at)}</span>
|
||||
)}
|
||||
<button
|
||||
className={`conv-pin-btn ${isPinned ? 'pinned' : ''}`}
|
||||
onClick={(e) => isPinned ? handleUnpinConversation(e, group.id) : handlePinConversation(e, group.id)}
|
||||
title={isPinned ? 'Unpin conversation' : 'Pin to top'}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 2v4l-3 3v6l-2-2-2 2V9L6 6V2h10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="group-last-msg truncate">
|
||||
|
||||
@@ -122,10 +122,9 @@ export const api = {
|
||||
// Link preview
|
||||
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
|
||||
|
||||
// Message pinning (DMs only, max 5 per conversation)
|
||||
getPinnedMessages: (groupId) => req('GET', `/messages/pinned?groupId=${groupId}`),
|
||||
pinMessage: (messageId) => req('POST', `/messages/${messageId}/pin`),
|
||||
unpinMessage: (messageId) => req('DELETE', `/messages/${messageId}/pin`),
|
||||
// Conversation pinning (pin a group to top of sidebar)
|
||||
pinConversation: (groupId) => req('POST', `/groups/${groupId}/pin`),
|
||||
unpinConversation: (groupId) => req('DELETE', `/groups/${groupId}/pin`),
|
||||
|
||||
// VAPID key management (admin only)
|
||||
generateVapidKeys: () => req('POST', '/push/generate-vapid'),
|
||||
|
||||
Reference in New Issue
Block a user