Message popup

This commit is contained in:
Bryan1029384756
2026-02-20 12:48:48 -06:00
parent 8b9df69931
commit a64ef84771
8 changed files with 368 additions and 5 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/shared",
"private": true,
"version": "1.0.28",
"version": "1.0.30",
"type": "module",
"main": "src/App.jsx",
"dependencies": {

View File

@@ -27,6 +27,7 @@ import Avatar from './Avatar';
import MentionMenu from './MentionMenu';
import SlashCommandMenu from './SlashCommandMenu';
import MessageItem, { getUserColor, parseAttachment } from './MessageItem';
import MobileMessageDrawer from './MobileMessageDrawer';
import ColoredIcon from './ColoredIcon';
import { usePlatform } from '../platform';
import { useVoice } from '../contexts/VoiceContext';
@@ -538,6 +539,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const [ephemeralMessages, setEphemeralMessages] = useState([]);
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
const [mobileDrawer, setMobileDrawer] = useState(null);
// Focused mode state (for jumping to old messages not in paginated view)
const [focusedMode, setFocusedMode] = useState(false);
@@ -2049,6 +2051,65 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setContextMenu(null);
};
// Long-press handler factory for mobile (not a hook - called per message in render callback)
const createLongPressHandlers = (callback) => {
let timer = null;
let startX = 0;
let startY = 0;
let triggered = false;
return {
onTouchStart: (e) => {
triggered = false;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
timer = setTimeout(() => {
triggered = true;
if (navigator.vibrate) navigator.vibrate(50);
callback();
}, 500);
},
onTouchMove: (e) => {
if (!timer) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
clearTimeout(timer);
timer = null;
}
},
onTouchEnd: (e) => {
if (timer) { clearTimeout(timer); timer = null; }
if (triggered) { e.preventDefault(); triggered = false; }
},
};
};
const handleMobileDrawerAction = (action, messageId) => {
const msg = decryptedMessages.find(m => m.id === messageId);
if (!msg) return;
switch (action) {
case 'reply':
setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) });
break;
case 'edit':
setEditingMessage({ id: msg.id, content: msg.content });
setEditInput(msg.content);
break;
case 'pin':
pinMessageMutation({ id: msg.id, pinned: !msg.pinned });
break;
case 'delete':
deleteMessageMutation({ id: msg.id, userId: currentUserId });
break;
case 'reaction':
setReactionPickerMsgId(msg.id);
break;
case 'copy':
if (msg.content) navigator.clipboard.writeText(msg.content).catch(() => {});
break;
}
};
const scrollToMessage = useCallback((messageId) => {
const idx = decryptedMessages.findIndex(m => m.id === messageId);
if (idx !== -1 && virtuosoRef.current) {
@@ -2244,6 +2305,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner, isAttachment: !!parseAttachment(msg.content), canDelete }); }}
onLongPress={isMobile ? createLongPressHandlers(() => setMobileDrawer({ messageId: msg.id, isOwner, isAttachment: !!parseAttachment(msg.content), canDelete, message: msg })) : undefined}
onEditInputChange={(e) => setEditInput(e.target.value)}
onEditKeyDown={handleEditKeyDown}
onEditSave={handleEditSave}
@@ -2258,7 +2320,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
DirectVideo={DirectVideo}
/>
);
}, [decryptedMessages, username, myPermissions, isMentionedInContent, unreadDividerTimestamp, editingMessage, hoveredMessageId, editInput, roles, customEmojis, reactionPickerMsgId, currentUserId, addReaction, handleEditKeyDown, handleEditSave, handleReactionClick, scrollToMessage, handleProfilePopup, scrollToBottom]);
}, [decryptedMessages, username, myPermissions, isMentionedInContent, unreadDividerTimestamp, editingMessage, hoveredMessageId, editInput, roles, customEmojis, reactionPickerMsgId, currentUserId, isMobile, addReaction, handleEditKeyDown, handleEditSave, handleReactionClick, scrollToMessage, handleProfilePopup, scrollToBottom]);
return (
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
@@ -2316,6 +2378,17 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
)}
</div>
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} isAttachment={contextMenu.isAttachment} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
{mobileDrawer && (
<MobileMessageDrawer
message={mobileDrawer.message}
isOwner={mobileDrawer.isOwner}
isAttachment={mobileDrawer.isAttachment}
canDelete={mobileDrawer.canDelete}
onClose={() => setMobileDrawer(null)}
onAction={(action) => handleMobileDrawerAction(action, mobileDrawer.messageId)}
onQuickReaction={(emoji) => { addReaction({ messageId: mobileDrawer.messageId, userId: currentUserId, emoji }); }}
/>
)}
{reactionPickerMsgId && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 999 }} onClick={() => setReactionPickerMsgId(null)}>
<div style={{ position: 'absolute', right: '80px', top: '50%', transform: 'translateY(-50%)' }} onClick={(e) => e.stopPropagation()}>

View File

@@ -212,6 +212,7 @@ const MessageItem = React.memo(({
onScrollToMessage,
onProfilePopup,
onImageClick,
onLongPress,
scrollToBottom,
Attachment,
LinkPreview,
@@ -300,6 +301,9 @@ const MessageItem = React.memo(({
onMouseEnter={onHover}
onMouseLeave={onLeave}
onContextMenu={onContextMenu}
onTouchStart={onLongPress?.onTouchStart}
onTouchMove={onLongPress?.onTouchMove}
onTouchEnd={onLongPress?.onTouchEnd}
>
{isMentioned && <div style={{ background: 'hsl(34, 50.847%, 53.725%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}

View File

@@ -0,0 +1,158 @@
import React, { useState, useRef, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { EmojieIcon, EditIcon, ReplyIcon, DeleteIcon, PinIcon } from '../assets/icons';
import { getEmojiUrl } from '../assets/emojis';
import ColoredIcon from './ColoredIcon';
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
const QUICK_REACTIONS = [
{ name: 'thumbsup', category: 'people' },
{ name: 'fire', category: 'nature' },
{ name: 'heart', category: 'symbols' },
{ name: 'joy', category: 'people' },
{ name: 'sob', category: 'people' },
{ name: 'eyes', category: 'people' },
];
const CopyIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
);
const MobileMessageDrawer = ({ message, isOwner, isAttachment, canDelete, onClose, onAction, onQuickReaction }) => {
const [closing, setClosing] = useState(false);
const drawerRef = useRef(null);
const dragStartY = useRef(null);
const dragCurrentY = useRef(null);
const dragStartTime = useRef(null);
const dismiss = useCallback(() => {
setClosing(true);
setTimeout(onClose, 200);
}, [onClose]);
const handleAction = useCallback((action) => {
onAction(action);
dismiss();
}, [onAction, dismiss]);
const handleQuickReaction = useCallback((name) => {
onQuickReaction(name);
dismiss();
}, [onQuickReaction, dismiss]);
// Swipe-to-dismiss
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY;
dragCurrentY.current = e.touches[0].clientY;
dragStartTime.current = Date.now();
if (drawerRef.current) {
drawerRef.current.style.transition = 'none';
}
}, []);
const handleTouchMove = useCallback((e) => {
if (dragStartY.current === null) return;
dragCurrentY.current = e.touches[0].clientY;
const dy = dragCurrentY.current - dragStartY.current;
if (dy > 0 && drawerRef.current) {
drawerRef.current.style.transform = `translateY(${dy}px)`;
}
}, []);
const handleTouchEnd = useCallback(() => {
if (dragStartY.current === null || !drawerRef.current) return;
const dy = dragCurrentY.current - dragStartY.current;
const dt = (Date.now() - dragStartTime.current) / 1000;
const velocity = dt > 0 ? dy / dt : 0;
const drawerHeight = drawerRef.current.offsetHeight;
const threshold = drawerHeight * 0.3;
if (dy > threshold || velocity > 500) {
dismiss();
} else {
drawerRef.current.style.transition = 'transform 0.2s ease-out';
drawerRef.current.style.transform = 'translateY(0)';
}
dragStartY.current = null;
}, [dismiss]);
const isPinned = message?.pinned;
return ReactDOM.createPortal(
<>
<div className="mobile-drawer-overlay" onClick={dismiss} />
<div
ref={drawerRef}
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="mobile-drawer-handle">
<div className="mobile-drawer-handle-bar" />
</div>
{/* Quick reactions */}
<div className="mobile-drawer-reactions">
{QUICK_REACTIONS.map(({ name, category }) => (
<button
key={name}
className="mobile-drawer-reaction-btn"
onClick={() => handleQuickReaction(name)}
>
<ColoredIcon src={getEmojiUrl(category, name)} size="24px" color={null} />
</button>
))}
<button
className="mobile-drawer-reaction-btn"
onClick={() => handleAction('reaction')}
>
<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="22px" />
</button>
</div>
<div className="mobile-drawer-separator" />
{/* Primary actions */}
<div className="mobile-drawer-card">
{isOwner && !isAttachment && (
<button className="mobile-drawer-action" onClick={() => handleAction('edit')}>
<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />
<span>Edit Message</span>
</button>
)}
<button className="mobile-drawer-action" onClick={() => handleAction('reply')}>
<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />
<span>Reply</span>
</button>
{!isAttachment && (
<button className="mobile-drawer-action" onClick={() => handleAction('copy')}>
<CopyIcon />
<span>Copy Text</span>
</button>
)}
<button className="mobile-drawer-action" onClick={() => handleAction('pin')}>
<ColoredIcon src={PinIcon} color={ICON_COLOR_DEFAULT} size="20px" />
<span>{isPinned ? 'Unpin Message' : 'Pin Message'}</span>
</button>
</div>
{/* Danger actions */}
{canDelete && (
<div className="mobile-drawer-card">
<button className="mobile-drawer-action mobile-drawer-action-danger" onClick={() => handleAction('delete')}>
<ColoredIcon src={DeleteIcon} color="#ed4245" size="20px" />
<span>Delete Message</span>
</button>
</div>
)}
</div>
</>,
document.body
);
};
export default MobileMessageDrawer;

View File

@@ -3417,6 +3417,134 @@ body {
}
/* ============================================
MOBILE MESSAGE ACTION DRAWER
============================================ */
@keyframes mobileDrawerOverlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes mobileDrawerSlideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes mobileDrawerSlideDown {
from { transform: translateY(0); }
to { transform: translateY(100%); }
}
.mobile-drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 10002;
animation: mobileDrawerOverlayIn 0.2s ease;
}
.mobile-drawer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 70vh;
background: #1C1D22;
border-radius: 16px 16px 0 0;
z-index: 10003;
animation: mobileDrawerSlideUp 0.25s ease-out;
padding-bottom: env(safe-area-inset-bottom, 0px);
touch-action: none;
overflow-y: auto;
}
.mobile-drawer.mobile-drawer-closing {
animation: mobileDrawerSlideDown 0.2s ease-in forwards;
}
.mobile-drawer-handle {
display: flex;
justify-content: center;
padding: 10px 0 6px;
}
.mobile-drawer-handle-bar {
width: 40px;
height: 4px;
border-radius: 2px;
background: hsla(240, 4%, 60%, 0.4);
}
.mobile-drawer-reactions {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 8px 16px 12px;
}
.mobile-drawer-reaction-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: #2E3138;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: none;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.15s;
}
.mobile-drawer-reaction-btn:active {
background: hsla(240, 4%, 40%, 0.4);
}
.mobile-drawer-separator {
height: 1px;
background: hsla(240, 4%, 60%, 0.12);
margin: 0 16px;
}
.mobile-drawer-card {
margin: 8px 16px;
border-radius: 12px;
background: #27272F;
overflow: hidden;
}
.mobile-drawer-action {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
color: var(--text-normal, #dbdee1);
font-size: 15px;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
-webkit-tap-highlight-color: transparent;
}
.mobile-drawer-action:active {
background: hsla(240, 4%, 60%, 0.08);
}
.mobile-drawer-action + .mobile-drawer-action {
border-top: 1px solid hsla(240, 4%, 60%, 0.12);
}
.mobile-drawer-action-danger {
color: #ed4245;
}
/* Remove border between server-list and channel-list on mobile */
.is-mobile .server-list {
border-right: none;