V0.7.1 New user online and pin features
This commit is contained in:
@@ -207,6 +207,22 @@ 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)
|
||||||
|
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');
|
console.log('[DB] Schema initialized');
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,42 @@ 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
|
||||||
@@ -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 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);
|
`).all(userId, 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
|
||||||
@@ -91,6 +129,7 @@ router.get('/', authMiddleware, (req, res) => {
|
|||||||
if (otherUserId) {
|
if (otherUserId) {
|
||||||
const other = db.prepare('SELECT display_name, name, avatar FROM users WHERE id = ?').get(otherUserId);
|
const other = db.prepare('SELECT display_name, name, avatar FROM users WHERE id = ?').get(otherUserId);
|
||||||
if (other) {
|
if (other) {
|
||||||
|
g.peer_id = otherUserId;
|
||||||
g.peer_real_name = other.name;
|
g.peer_real_name = other.name;
|
||||||
g.peer_display_name = other.display_name || null; // null if no custom display name set
|
g.peer_display_name = other.display_name || null; // null if no custom display name set
|
||||||
g.peer_avatar = other.avatar || null;
|
g.peer_avatar = other.avatar || null;
|
||||||
@@ -399,4 +438,4 @@ router.patch('/:id/custom-name', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
@@ -8,7 +8,7 @@ import MessageInput from './MessageInput.jsx';
|
|||||||
import GroupInfoModal from './GroupInfoModal.jsx';
|
import GroupInfoModal from './GroupInfoModal.jsx';
|
||||||
import './ChatWindow.css';
|
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 { socket } = useSocket();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -275,6 +275,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
else socket.emit('typing:stop', { groupId: group.id });
|
else socket.emit('typing:stop', { groupId: group.id });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onlineUserIds={onlineUserIds}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="readonly-bar">
|
<div className="readonly-bar">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function isEmojiOnly(str) {
|
|||||||
return emojiRegex.test(str.trim());
|
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 [text, setText] = useState('');
|
||||||
const [imageFile, setImageFile] = useState(null);
|
const [imageFile, setImageFile] = useState(null);
|
||||||
const [imagePreview, setImagePreview] = 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' : ''}`}
|
className={`mention-item ${i === mentionIndex ? 'active' : ''}`}
|
||||||
onMouseDown={(e) => { e.preventDefault(); insertMention(u); }}
|
onMouseDown={(e) => { e.preventDefault(); insertMention(u); }}
|
||||||
>
|
>
|
||||||
<div className="mention-avatar">{(u.display_name || u.name)?.[0]?.toUpperCase()}</div>
|
<div className="mention-avatar-wrap">
|
||||||
|
<div className="mention-avatar">{(u.display_name || u.name)?.[0]?.toUpperCase()}</div>
|
||||||
|
{onlineUserIds.has(u.id) && <span className="mention-online-dot" />}
|
||||||
|
</div>
|
||||||
<span>{u.display_name || u.name}</span>
|
<span>{u.display_name || u.name}</span>
|
||||||
<span className="mention-role">{u.role}</span>
|
<span className="mention-role">{u.role}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -230,3 +230,73 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-tertiary);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,16 +45,45 @@ function useAppSettings() {
|
|||||||
return settings;
|
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 { user, logout } = useAuth();
|
||||||
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(() => {
|
||||||
@@ -82,7 +111,20 @@ 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 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;
|
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 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 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 className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`} onClick={() => onSelectGroup(group.id)}>
|
<div
|
||||||
{group.is_direct && group.peer_avatar ? (
|
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
|
||||||
<img
|
onClick={() => onSelectGroup(group.id)}
|
||||||
src={group.peer_avatar}
|
onContextMenu={handleContextMenu}
|
||||||
alt={group.name}
|
>
|
||||||
className="group-icon"
|
<div className="group-icon-wrap">
|
||||||
style={{ objectFit: 'cover', padding: 0 }}
|
{group.is_direct && group.peer_avatar ? (
|
||||||
/>
|
<img
|
||||||
) : (
|
src={group.peer_avatar}
|
||||||
<div className="group-icon" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}>
|
alt={group.name}
|
||||||
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
className="group-icon"
|
||||||
</div>
|
style={{ objectFit: 'cover', padding: 0 }}
|
||||||
)}
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="group-icon" 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>
|
||||||
|
)}
|
||||||
|
{isOnline && <span className="online-dot" />}
|
||||||
|
</div>
|
||||||
<div className="group-info flex-1 overflow-hidden">
|
<div className="group-info flex-1 overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>
|
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>
|
||||||
@@ -161,7 +219,18 @@ 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>
|
||||||
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
{pinnedDMs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<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 && (
|
||||||
@@ -250,6 +319,26 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -446,3 +446,22 @@ a { color: inherit; text-decoration: none; }
|
|||||||
[data-theme="dark"] .help-markdown code { background: var(--surface); }
|
[data-theme="dark"] .help-markdown code { background: var(--surface); }
|
||||||
[data-theme="dark"] .help-markdown pre { 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); }
|
[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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default function Chat() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
|
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
|
||||||
|
const [onlineUserIds, setOnlineUserIds] = useState(new Set());
|
||||||
const [activeGroupId, setActiveGroupId] = useState(null);
|
const [activeGroupId, setActiveGroupId] = useState(null);
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unreadGroups, setUnreadGroups] = useState(new Map());
|
const [unreadGroups, setUnreadGroups] = useState(new Map());
|
||||||
@@ -205,6 +206,17 @@ export default function Chat() {
|
|||||||
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
|
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:new', handleGroupNew);
|
||||||
socket.on('group:deleted', handleGroupDeleted);
|
socket.on('group:deleted', handleGroupDeleted);
|
||||||
socket.on('group:updated', handleGroupUpdated);
|
socket.on('group:updated', handleGroupUpdated);
|
||||||
@@ -228,6 +240,9 @@ export default function Chat() {
|
|||||||
socket.off('group:new', handleGroupNew);
|
socket.off('group:new', handleGroupNew);
|
||||||
socket.off('group:deleted', handleGroupDeleted);
|
socket.off('group:deleted', handleGroupDeleted);
|
||||||
socket.off('group:updated', handleGroupUpdated);
|
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('connect', handleReconnect);
|
||||||
socket.off('session:displaced', handleSessionDisplaced);
|
socket.off('session:displaced', handleSessionDisplaced);
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
@@ -284,6 +299,7 @@ export default function Chat() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onAbout={() => setModal('about')}
|
onAbout={() => setModal('about')}
|
||||||
onHelp={() => setModal('help')}
|
onHelp={() => setModal('help')}
|
||||||
|
onlineUserIds={onlineUserIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -293,6 +309,7 @@ export default function Chat() {
|
|||||||
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
|
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
|
||||||
onGroupUpdated={loadGroups}
|
onGroupUpdated={loadGroups}
|
||||||
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
|
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
|
||||||
|
onlineUserIds={onlineUserIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user