v0.7.1 bugs fix for last update

This commit is contained in:
2026-03-11 15:38:28 -04:00
parent 39fa6e9ff2
commit d822784826
11 changed files with 244 additions and 150 deletions

View File

@@ -20,6 +20,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
const [showInfo, setShowInfo] = useState(false);
const [iconGroupInfo, setIconGroupInfo] = useState('');
const [typing, setTyping] = useState([]);
const [pinnedMsgIds, setPinnedMsgIds] = useState(new Set());
const [pinCount, setPinCount] = useState(0);
const messagesEndRef = useRef(null);
const messagesTopRef = useRef(null);
const typingTimers = useRef({});
@@ -183,21 +185,31 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
<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>
)}
{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>
)}
<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.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 items-center gap-2">
{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 })}
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 && (

View File

@@ -103,12 +103,14 @@
/* Bubble row */
.msg-bubble-wrap {
position: relative;
display: flex;
align-items: flex-start;
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 */
.msg-bubble-with-actions {
@@ -331,3 +333,48 @@
margin: 0;
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;
}

View File

@@ -31,8 +31,11 @@ function isEmojiOnly(str) {
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 [showOptionsMenu, setShowOptionsMenu] = useState(false);
const longPressTimer = useRef(null);
const optionsMenuRef = useRef(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const wrapperRef = useRef(null);
const pickerRef = useRef(null);
@@ -113,6 +116,30 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
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 = {
id: msg.user_id,
name: msg.user_name,
@@ -139,12 +166,20 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
{!isOwn && !prevSameUser && (
<div
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)}
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
<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>
)}
{!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"
onMouseEnter={() => setShowActions(true)}
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 */}
{!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>
</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 */}
{showEmojiPicker && (
@@ -237,6 +282,31 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
</div>
<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>
{Object.keys(reactionMap).length > 0 && (

View File

@@ -50,40 +50,11 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
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(() => {
@@ -111,11 +82,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
];
const publicFiltered = allGroups.filter(g => g.type === 'public');
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)
const dms = allGroups
.filter(g => g.type === 'private' && !!g.is_direct)
.sort((a, b) => {
if (!a.last_message_at && !b.last_message_at) return 0;
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);
});
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 privateFiltered = [...privateNonDM, ...dms];
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 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 });
};
const isOnline = !!group.is_direct && !!group.peer_id && onlineUserIds.has(group.peer_id);
return (
<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 ? (
@@ -219,18 +177,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{privateFiltered.length > 0 && (
<div className="group-section">
<div className="section-label">DIRECT MESSAGES</div>
{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} />)}
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
</div>
)}
{allGroups.length === 0 && (
@@ -319,26 +266,6 @@ 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>
);
}