v0.7.1 bugs fix for last update
This commit is contained in:
@@ -7,7 +7,7 @@ TZ=UTC
|
|||||||
# Copy this file to .env and customize
|
# Copy this file to .env and customize
|
||||||
|
|
||||||
# Image version to run (set by build.sh, or use 'latest')
|
# 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)
|
# Default admin credentials (used on FIRST RUN only)
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -207,21 +207,23 @@ function initDb() {
|
|||||||
console.log('[DB] Migration: user_group_names table ready');
|
console.log('[DB] Migration: user_group_names table ready');
|
||||||
} catch (e) { console.error('[DB] user_group_names migration error:', e.message); }
|
} 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 {
|
try {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS pinned_direct_messages (
|
CREATE TABLE IF NOT EXISTS pinned_messages (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
group_id INTEGER NOT NULL,
|
group_id INTEGER NOT NULL,
|
||||||
pinned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
pinned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
pin_order INTEGER NOT NULL DEFAULT 0,
|
pin_order INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (user_id, group_id),
|
PRIMARY KEY (user_id, message_id),
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
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
|
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
console.log('[DB] Migration: pinned_direct_messages table ready');
|
console.log('[DB] Migration: pinned_messages table ready');
|
||||||
} catch (e) { console.error('[DB] pinned_direct_messages migration error:', e.message); }
|
} catch (e) { console.error('[DB] pinned_messages migration error:', e.message); }
|
||||||
|
|
||||||
console.log('[DB] Schema initialized');
|
console.log('[DB] Schema initialized');
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@@ -41,41 +41,6 @@ function emitGroupUpdated(io, groupId) {
|
|||||||
|
|
||||||
// Inject io into routes
|
// 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) => {
|
module.exports = (io) => {
|
||||||
|
|
||||||
// Get all groups for current user
|
// 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 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.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.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
|
FROM groups g
|
||||||
JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
|
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 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'
|
WHERE g.type = 'private'
|
||||||
ORDER BY last_message_at DESC NULLS LAST
|
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
|
// 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
|
// Uses direct_peer1_id / direct_peer2_id so the name survives after a user leaves
|
||||||
|
|||||||
@@ -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;
|
module.exports = router;
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.7.1}"
|
VERSION="${1:-0.7.2}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
||||||
const [typing, setTyping] = useState([]);
|
const [typing, setTyping] = useState([]);
|
||||||
|
const [pinnedMsgIds, setPinnedMsgIds] = useState(new Set());
|
||||||
|
const [pinCount, setPinCount] = useState(0);
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const messagesTopRef = useRef(null);
|
const messagesTopRef = useRef(null);
|
||||||
const typingTimers = useRef({});
|
const typingTimers = useRef({});
|
||||||
@@ -183,6 +185,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
<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>
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
{group.is_direct && group.peer_avatar ? (
|
{group.is_direct && group.peer_avatar ? (
|
||||||
<img
|
<img
|
||||||
src={group.peer_avatar}
|
src={group.peer_avatar}
|
||||||
@@ -198,6 +201,15 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!!group.is_direct && group.peer_id && onlineUserIds.has(group.peer_id) && (
|
||||||
|
<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-col flex-1 overflow-hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{group.is_direct && group.peer_display_name ? (
|
{group.is_direct && group.peer_display_name ? (
|
||||||
@@ -249,6 +261,11 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
|
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
|
||||||
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
|
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
|
||||||
onDirectMessage={onDirectMessage}
|
onDirectMessage={onDirectMessage}
|
||||||
|
onPin={handlePinMessage}
|
||||||
|
onUnpin={handleUnpinMessage}
|
||||||
|
isPinned={pinnedMsgIds.has(msg.id)}
|
||||||
|
pinCount={pinCount}
|
||||||
|
onlineUserIds={onlineUserIds}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{typing.length > 0 && (
|
{typing.length > 0 && (
|
||||||
|
|||||||
@@ -103,12 +103,14 @@
|
|||||||
|
|
||||||
/* Bubble row */
|
/* Bubble row */
|
||||||
.msg-bubble-wrap {
|
.msg-bubble-wrap {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.own .msg-bubble-wrap { flex-direction: row-reverse; }
|
.own .msg-bubble-wrap {
|
||||||
|
position: relative; flex-direction: row-reverse; }
|
||||||
|
|
||||||
/* Wrapper that holds the actions toolbar + bubble together */
|
/* Wrapper that holds the actions toolbar + bubble together */
|
||||||
.msg-bubble-with-actions {
|
.msg-bubble-with-actions {
|
||||||
@@ -331,3 +333,48 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Message pin/options popup menu */
|
||||||
|
.msg-options-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 200;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 4px 0;
|
||||||
|
min-width: 170px;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-options-menu.options-left {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-options-menu.options-right {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-options-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-options-menu button:hover:not(:disabled) {
|
||||||
|
background: var(--surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-options-menu button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ function isEmojiOnly(str) {
|
|||||||
return emojiRegex.test(str.trim());
|
return emojiRegex.test(str.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect }) {
|
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect, onPin, onUnpin, isPinned, pinCount = 0, onlineUserIds = new Set() }) {
|
||||||
const [showActions, setShowActions] = useState(false);
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
const [showOptionsMenu, setShowOptionsMenu] = useState(false);
|
||||||
|
const longPressTimer = useRef(null);
|
||||||
|
const optionsMenuRef = useRef(null);
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
const wrapperRef = useRef(null);
|
const wrapperRef = useRef(null);
|
||||||
const pickerRef = useRef(null);
|
const pickerRef = useRef(null);
|
||||||
@@ -113,6 +116,30 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
setShowEmojiPicker(p => !p);
|
setShowEmojiPicker(p => !p);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Long press for mobile action menu
|
||||||
|
const handleTouchStart = () => {
|
||||||
|
longPressTimer.current = setTimeout(() => setShowOptionsMenu(true), 500);
|
||||||
|
};
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (longPressTimer.current) clearTimeout(longPressTimer.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close options menu on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showOptionsMenu) return;
|
||||||
|
const close = (e) => {
|
||||||
|
if (optionsMenuRef.current && !optionsMenuRef.current.contains(e.target)) {
|
||||||
|
setShowOptionsMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', close);
|
||||||
|
document.addEventListener('touchstart', close);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', close);
|
||||||
|
document.removeEventListener('touchstart', close);
|
||||||
|
};
|
||||||
|
}, [showOptionsMenu]);
|
||||||
|
|
||||||
const msgUser = {
|
const msgUser = {
|
||||||
id: msg.user_id,
|
id: msg.user_id,
|
||||||
name: msg.user_name,
|
name: msg.user_name,
|
||||||
@@ -139,12 +166,20 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
{!isOwn && !prevSameUser && (
|
{!isOwn && !prevSameUser && (
|
||||||
<div
|
<div
|
||||||
ref={avatarRef}
|
ref={avatarRef}
|
||||||
style={{ cursor: 'pointer', borderRadius: '50%', transition: 'box-shadow 0.15s' }}
|
style={{ position: 'relative', cursor: 'pointer', borderRadius: '50%', transition: 'box-shadow 0.15s', flexShrink: 0 }}
|
||||||
onClick={() => setShowProfile(p => !p)}
|
onClick={() => setShowProfile(p => !p)}
|
||||||
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'}
|
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||||
>
|
>
|
||||||
<Avatar user={msgUser} size="sm" className="msg-avatar" />
|
<Avatar user={msgUser} size="sm" className="msg-avatar" />
|
||||||
|
{onlineUserIds.has(msg.user_id) && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', bottom: 0, right: 0,
|
||||||
|
width: 9, height: 9, borderRadius: '50%',
|
||||||
|
background: '#34a853', border: '2px solid var(--surface)',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isOwn && prevSameUser && <div className="avatar-spacer" />}
|
{!isOwn && prevSameUser && <div className="avatar-spacer" />}
|
||||||
@@ -178,6 +213,9 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
<div className="msg-bubble-with-actions"
|
<div className="msg-bubble-with-actions"
|
||||||
onMouseEnter={() => setShowActions(true)}
|
onMouseEnter={() => setShowActions(true)}
|
||||||
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
|
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
|
||||||
|
onTouchStart={isDirect ? handleTouchStart : undefined}
|
||||||
|
onTouchEnd={isDirect ? handleTouchEnd : undefined}
|
||||||
|
onTouchMove={isDirect ? handleTouchEnd : undefined}
|
||||||
>
|
>
|
||||||
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
|
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
|
||||||
{!isDeleted && (showActions || showEmojiPicker) && (
|
{!isDeleted && (showActions || showEmojiPicker) && (
|
||||||
@@ -201,6 +239,13 @@ 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>
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isDirect && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button className="btn-icon action-btn" onClick={() => setShowOptionsMenu(p => !p)} title="More options">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Emoji picker anchored to the toolbar */}
|
{/* Emoji picker anchored to the toolbar */}
|
||||||
{showEmojiPicker && (
|
{showEmojiPicker && (
|
||||||
@@ -237,6 +282,31 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="msg-time">{formatTime(msg.created_at)}</span>
|
<span className="msg-time">{formatTime(msg.created_at)}</span>
|
||||||
|
|
||||||
|
{/* Pin/unpin options menu — desktop triple-dot + mobile long-press */}
|
||||||
|
{isDirect && showOptionsMenu && (
|
||||||
|
<div
|
||||||
|
ref={optionsMenuRef}
|
||||||
|
className={`msg-options-menu ${isOwn ? 'options-left' : 'options-right'}`}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{isPinned ? (
|
||||||
|
<button onClick={() => { onUnpin(msg.id); setShowOptionsMenu(false); }}>
|
||||||
|
<svg width="14" height="14" 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
|
||||||
|
onClick={() => { if (pinCount < 5) { onPin(msg.id); setShowOptionsMenu(false); } }}
|
||||||
|
disabled={pinCount >= 5}
|
||||||
|
title={pinCount >= 5 ? 'Maximum 5 pinned messages reached' : ''}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
|
||||||
|
{pinCount >= 5 ? 'Pin (5/5)' : `Pin (${pinCount + 1}/5)`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Object.keys(reactionMap).length > 0 && (
|
{Object.keys(reactionMap).length > 0 && (
|
||||||
|
|||||||
@@ -50,40 +50,11 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
const { connected } = useSocket();
|
const { connected } = useSocket();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const [contextMenu, setContextMenu] = useState(null); // { groupId, x, y, isPinned }
|
|
||||||
const settings = useAppSettings();
|
const settings = useAppSettings();
|
||||||
const [dark, setDark] = useTheme();
|
const [dark, setDark] = useTheme();
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
const footerBtnRef = 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
|
// Fix 6: swipe right to go back on mobile — handled in ChatWindow, but prevent sidebar swipe exit
|
||||||
// Close menu on click outside
|
// Close menu on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,11 +82,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
];
|
];
|
||||||
|
|
||||||
const publicFiltered = allGroups.filter(g => g.type === 'public');
|
const publicFiltered = allGroups.filter(g => g.type === 'public');
|
||||||
const pinnedDMs = allGroups
|
const dms = allGroups
|
||||||
.filter(g => g.type === 'private' && g.is_direct && g.pin_order != null)
|
.filter(g => g.type === 'private' && !!g.is_direct)
|
||||||
.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) => {
|
.sort((a, b) => {
|
||||||
if (!a.last_message_at && !b.last_message_at) return 0;
|
if (!a.last_message_at && !b.last_message_at) return 0;
|
||||||
if (!a.last_message_at) return 1;
|
if (!a.last_message_at) return 1;
|
||||||
@@ -123,8 +91,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
return new Date(b.last_message_at) - new Date(a.last_message_at);
|
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 privateNonDM = allGroups.filter(g => g.type === 'private' && !g.is_direct);
|
||||||
const privateFiltered = [...privateNonDM, ...pinnedDMs, ...unpinnedDMs];
|
const privateFiltered = [...privateNonDM, ...dms];
|
||||||
const pinnedGroupIds = new Set(pinnedDMs.map(g => g.id));
|
|
||||||
|
|
||||||
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||||
|
|
||||||
@@ -135,21 +102,12 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
const unreadCount = unreadGroups.get(group.id) || 0;
|
const unreadCount = unreadGroups.get(group.id) || 0;
|
||||||
const hasUnread = unreadCount > 0;
|
const hasUnread = unreadCount > 0;
|
||||||
const isActive = group.id === activeGroupId;
|
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 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
|
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
|
||||||
onClick={() => onSelectGroup(group.id)}
|
onClick={() => onSelectGroup(group.id)}
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
>
|
>
|
||||||
<div className="group-icon-wrap">
|
<div className="group-icon-wrap">
|
||||||
{group.is_direct && group.peer_avatar ? (
|
{group.is_direct && group.peer_avatar ? (
|
||||||
@@ -219,18 +177,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
{privateFiltered.length > 0 && (
|
{privateFiltered.length > 0 && (
|
||||||
<div className="group-section">
|
<div className="group-section">
|
||||||
<div className="section-label">DIRECT MESSAGES</div>
|
<div className="section-label">DIRECT MESSAGES</div>
|
||||||
{pinnedDMs.length > 0 && (
|
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||||
<>
|
|
||||||
<div className="section-sublabel">
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style={{ marginRight: 3 }}><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
|
|
||||||
PINNED
|
|
||||||
</div>
|
|
||||||
{pinnedDMs.map(g => <GroupItem key={g.id} group={g} />)}
|
|
||||||
{unpinnedDMs.length > 0 && <div className="section-divider" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{privateNonDM.map(g => <GroupItem key={g.id} group={g} />)}
|
|
||||||
{unpinnedDMs.map(g => <GroupItem key={g.id} group={g} />)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{allGroups.length === 0 && (
|
{allGroups.length === 0 && (
|
||||||
@@ -319,26 +266,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* DM pin context menu */}
|
|
||||||
{contextMenu && (
|
|
||||||
<div
|
|
||||||
className="dm-context-menu"
|
|
||||||
style={{ top: contextMenu.y, left: Math.min(contextMenu.x, window.innerWidth - 160) }}
|
|
||||||
onMouseDown={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{contextMenu.isPinned ? (
|
|
||||||
<button onClick={() => handleUnpin(contextMenu.groupId)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
|
|
||||||
Unpin conversation
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => handlePin(contextMenu.groupId)}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
|
|
||||||
Pin conversation
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user