diff --git a/apps/android/android/app/build.gradle b/apps/android/android/app/build.gradle index c5a8ca0..261b1c7 100644 --- a/apps/android/android/app/build.gradle +++ b/apps/android/android/app/build.gradle @@ -8,7 +8,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 27 - versionName "1.0.29" + versionName "1.0.30" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/apps/electron/package.json b/apps/electron/package.json index 0c12c88..c2535c3 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.0.29", + "version": "1.0.30", "description": "Discord Clone - Electron app", "author": "Moyettes", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index 37c0a3f..e666ae0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/web", "private": true, - "version": "1.0.29", + "version": "1.0.30", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shared/package.json b/packages/shared/package.json index 2ebfaef..4dd82de 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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": { diff --git a/packages/shared/src/components/ChatArea.jsx b/packages/shared/src/components/ChatArea.jsx index 8234829..80fe16d 100644 --- a/packages/shared/src/components/ChatArea.jsx +++ b/packages/shared/src/components/ChatArea.jsx @@ -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 (
@@ -2316,6 +2378,17 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u )}
{contextMenu && setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />} + {mobileDrawer && ( + setMobileDrawer(null)} + onAction={(action) => handleMobileDrawerAction(action, mobileDrawer.messageId)} + onQuickReaction={(emoji) => { addReaction({ messageId: mobileDrawer.messageId, userId: currentUserId, emoji }); }} + /> + )} {reactionPickerMsgId && (
setReactionPickerMsgId(null)}>
e.stopPropagation()}> diff --git a/packages/shared/src/components/MessageItem.jsx b/packages/shared/src/components/MessageItem.jsx index 9d9cf51..77523e0 100644 --- a/packages/shared/src/components/MessageItem.jsx +++ b/packages/shared/src/components/MessageItem.jsx @@ -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 &&
} diff --git a/packages/shared/src/components/MobileMessageDrawer.jsx b/packages/shared/src/components/MobileMessageDrawer.jsx new file mode 100644 index 0000000..e030006 --- /dev/null +++ b/packages/shared/src/components/MobileMessageDrawer.jsx @@ -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 = () => ( + + + +); + +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( + <> +
+
+
+
+
+ + {/* Quick reactions */} +
+ {QUICK_REACTIONS.map(({ name, category }) => ( + + ))} + +
+ +
+ + {/* Primary actions */} +
+ {isOwner && !isAttachment && ( + + )} + + {!isAttachment && ( + + )} + +
+ + {/* Danger actions */} + {canDelete && ( +
+ +
+ )} +
+ , + document.body + ); +}; + +export default MobileMessageDrawer; diff --git a/packages/shared/src/index.css b/packages/shared/src/index.css index 688c3b2..bcbccca 100644 --- a/packages/shared/src/index.css +++ b/packages/shared/src/index.css @@ -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;