V0.7.1 New user online and pin features

This commit is contained in:
2026-03-11 14:47:44 -04:00
parent 861ded53e0
commit 3fe17c7901
8 changed files with 276 additions and 22 deletions

View File

@@ -8,7 +8,7 @@ import MessageInput from './MessageInput.jsx';
import GroupInfoModal from './GroupInfoModal.jsx';
import './ChatWindow.css';
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage }) {
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onlineUserIds = new Set() }) {
const { socket } = useSocket();
const { user } = useAuth();
const toast = useToast();
@@ -275,6 +275,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
else socket.emit('typing:stop', { groupId: group.id });
}
}}
onlineUserIds={onlineUserIds}
/>
) : (
<div className="readonly-bar">

View File

@@ -12,7 +12,7 @@ function isEmojiOnly(str) {
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 [imageFile, setImageFile] = 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' : ''}`}
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 className="mention-role">{u.role}</span>
</button>

View File

@@ -230,3 +230,73 @@
font-weight: 400;
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);
}

View File

@@ -45,16 +45,45 @@ function useAppSettings() {
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 { connected } = useSocket();
const toast = useToast();
const [showMenu, setShowMenu] = useState(false);
const [contextMenu, setContextMenu] = useState(null); // { groupId, x, y, isPinned }
const settings = useAppSettings();
const [dark, setDark] = useTheme();
const menuRef = 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
// Close menu on click outside
useEffect(() => {
@@ -82,7 +111,20 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
];
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;
@@ -93,21 +135,37 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
const unreadCount = unreadGroups.get(group.id) || 0;
const hasUnread = unreadCount > 0;
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 (
<div className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`} onClick={() => onSelectGroup(group.id)}>
{group.is_direct && group.peer_avatar ? (
<img
src={group.peer_avatar}
alt={group.name}
className="group-icon"
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>
)}
<div
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
onClick={() => onSelectGroup(group.id)}
onContextMenu={handleContextMenu}
>
<div className="group-icon-wrap">
{group.is_direct && group.peer_avatar ? (
<img
src={group.peer_avatar}
alt={group.name}
className="group-icon"
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="flex items-center justify-between">
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>
@@ -161,7 +219,18 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{privateFiltered.length > 0 && (
<div className="group-section">
<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>
)}
{allGroups.length === 0 && (
@@ -250,6 +319,26 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
</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>
);
}

View File

@@ -446,3 +446,22 @@ a { color: inherit; text-decoration: none; }
[data-theme="dark"] .help-markdown code { 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); }
/* 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;
}

View File

@@ -29,6 +29,7 @@ export default function Chat() {
const toast = useToast();
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
const [onlineUserIds, setOnlineUserIds] = useState(new Set());
const [activeGroupId, setActiveGroupId] = useState(null);
const [notifications, setNotifications] = useState([]);
const [unreadGroups, setUnreadGroups] = useState(new Map());
@@ -205,6 +206,17 @@ export default function Chat() {
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:deleted', handleGroupDeleted);
socket.on('group:updated', handleGroupUpdated);
@@ -228,6 +240,9 @@ export default function Chat() {
socket.off('group:new', handleGroupNew);
socket.off('group:deleted', handleGroupDeleted);
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('session:displaced', handleSessionDisplaced);
document.removeEventListener('visibilitychange', handleVisibility);
@@ -284,6 +299,7 @@ export default function Chat() {
isMobile={isMobile}
onAbout={() => setModal('about')}
onHelp={() => setModal('help')}
onlineUserIds={onlineUserIds}
/>
)}
@@ -293,6 +309,7 @@ export default function Chat() {
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
onGroupUpdated={loadGroups}
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
onlineUserIds={onlineUserIds}
/>
)}
</div>