Message popup
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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' }} />}
|
||||
|
||||
|
||||
158
packages/shared/src/components/MobileMessageDrawer.jsx
Normal file
158
packages/shared/src/components/MobileMessageDrawer.jsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user