fix: prevent native text selection on mobile long-press

iOS triggered both the app context menu and native text selection overlay
on long-press. Three-layer fix: CSS user-select:none on buttons/[role=button]/
[data-context-menu] elements; non-passive touchstart listener with conditional
preventDefault on context-menu targets; removeAllRanges() at timer fire to
clear any residual selection. Added data-context-menu to all mobile surfaces
that use onContextMenu handlers.
This commit is contained in:
Jannis Braun
2026-03-23 21:12:06 +01:00
parent 161e876df8
commit 9f8a4ffa90
7 changed files with 19 additions and 5 deletions
+2 -1
View File
@@ -224,6 +224,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const ownerId = spaces.find(s => s.id === currentSpaceId)?.ownerId; const ownerId = spaces.find(s => s.id === currentSpaceId)?.ownerId;
const getMemberDisplayColor = (userId: string) => { const getMemberDisplayColor = (userId: string) => {
if (isDmMessage) return { color: '#d8d8de' };
const member = members.find(m => m.userId === userId); const member = members.find(m => m.userId === userId);
if (member?.roles && member.roles.length > 0) { if (member?.roles && member.roles.length > 0) {
const sorted = [...member.roles].sort((a, b) => b.position - a.position); const sorted = [...member.roles].sort((a, b) => b.position - a.position);
@@ -524,7 +525,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
); );
return ( return (
<div onContextMenu={handleContextMenu}> <div data-context-menu onContextMenu={handleContextMenu}>
{content} {content}
</div> </div>
); );
@@ -165,6 +165,7 @@ export function MobileDmsScreen() {
return ( return (
<button <button
key={dm.id} key={dm.id}
data-context-menu
onClick={() => handleDmTap(dm.id)} onClick={() => handleDmTap(dm.id)}
onContextMenu={(e) => handleDmContextMenu(e, dm.id, isGroup)} onContextMenu={(e) => handleDmContextMenu(e, dm.id, isGroup)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-interactive-hover text-left transition-colors" className="w-full flex items-center gap-3 px-4 py-3 hover:bg-interactive-hover text-left transition-colors"
@@ -94,7 +94,7 @@ export function MobileFolderSheet({ folder, onClose, onSelectSpace, onUpdateFold
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1 shrink-0" /> <div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1 shrink-0" />
{/* Folder header */} {/* Folder header */}
<div className="px-4 py-2 flex items-center gap-2 shrink-0" onContextMenu={handleFolderContextMenu}> <div className="px-4 py-2 flex items-center gap-2 shrink-0" data-context-menu onContextMenu={handleFolderContextMenu}>
<div <div
className="w-5 h-5 rounded" className="w-5 h-5 rounded"
style={{ background: folder.color || 'rgb(var(--text-tertiary))' }} style={{ background: folder.color || 'rgb(var(--text-tertiary))' }}
@@ -669,6 +669,7 @@ export function MobileSpacesScreen() {
/> />
</div> </div>
<button <button
data-context-menu
onClick={() => handleSpaceSelect(space.id)} onClick={() => handleSpaceSelect(space.id)}
onContextMenu={(e) => handleSpaceContextMenu(e, space.id)} onContextMenu={(e) => handleSpaceContextMenu(e, space.id)}
className={`w-10 h-10 rounded-2xl overflow-hidden flex items-center justify-center transition-all ${ className={`w-10 h-10 rounded-2xl overflow-hidden flex items-center justify-center transition-all ${
@@ -765,6 +766,7 @@ export function MobileSpacesScreen() {
return ( return (
<div key={category.id} className="mt-3"> <div key={category.id} className="mt-3">
<button <button
data-context-menu
onClick={() => toggleCategory(category.id)} onClick={() => toggleCategory(category.id)}
onContextMenu={(e) => handleCategoryContextMenu(e, category.id)} onContextMenu={(e) => handleCategoryContextMenu(e, category.id)}
className="flex items-center gap-1 px-1 py-1 w-full text-left" className="flex items-center gap-1 px-1 py-1 w-full text-left"
@@ -198,6 +198,7 @@ export function MobileVoiceFullScreen() {
return ( return (
<div <div
key={userId} key={userId}
data-context-menu
className={`rounded-xl bg-surface-channel p-4 flex flex-col items-center gap-3 ${ className={`rounded-xl bg-surface-channel p-4 flex flex-col items-center gap-3 ${
participantIds.length <= 2 ? 'py-8' : 'py-4' participantIds.length <= 2 ? 'py-8' : 'py-4'
}`} }`}
@@ -550,6 +550,10 @@ function useGlobalLongPress(isMobile: boolean) {
}; };
const onTouchStart = (e: TouchEvent) => { const onTouchStart = (e: TouchEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-context-menu]')) {
e.preventDefault();
}
if (e.touches.length !== 1) { cancel(); return; } if (e.touches.length !== 1) { cancel(); return; }
const touch = e.touches[0]!; const touch = e.touches[0]!;
originX = touch.clientX; originX = touch.clientX;
@@ -600,7 +604,7 @@ function useGlobalLongPress(isMobile: boolean) {
} }
}; };
document.addEventListener('touchstart', onTouchStart, { passive: true }); document.addEventListener('touchstart', onTouchStart, { passive: false });
document.addEventListener('touchmove', onTouchMove, { passive: true }); document.addEventListener('touchmove', onTouchMove, { passive: true });
document.addEventListener('touchend', onTouchEnd, { passive: true }); document.addEventListener('touchend', onTouchEnd, { passive: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true }); document.addEventListener('touchcancel', onTouchEnd, { passive: true });
+7 -2
View File
@@ -99,13 +99,18 @@
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
/* Suppress native long-press callout on mobile (iOS "Copy/Lookup" popup). /* Suppress native text selection and callout on mobile interactive elements.
Our global useLongPress handler provides context menus instead. Our global useGlobalLongPress handler provides context menus instead.
Text can still be copied via context menu "Copy Text" option. */ Text can still be copied via context menu "Copy Text" option. */
@media (max-width: 767px) { @media (max-width: 767px) {
* { * {
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
button, [role="button"], [data-context-menu] {
-webkit-user-select: none;
user-select: none;
}
} }
/* Thin scrollbar */ /* Thin scrollbar */