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;