Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swipe-to-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Add swipe-to-edit gesture on mobile messages.
34 changes: 25 additions & 9 deletions src/app/components/MobileSwipeDownModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createPortal } from 'react-dom';
import { Box } from 'folds';
import * as css from '$features/room/message/styles.css';
import { useDismissOnBack } from '$utils/androidBack';
import { getMobileSheetTiming, useMobileSheetAnimation } from './mobileSheetAnimation';
import * as animationCss from './mobileSheetAnimation.css';

interface MobileSwipeDownModalProps {
children: (
Expand All @@ -22,6 +24,8 @@ export function MobileSwipeDownModal({ children, requestClose }: MobileSwipeDown
const touchYDiff = useRef(0);
const startTime = useRef(0);
const [mounted, setMounted] = useState(false);
const resetAnimationRef = useRef<Animation>();
const { shouldReduceMotion } = useMobileSheetAnimation();

useEffect(() => {
setMounted(true);
Expand All @@ -43,9 +47,10 @@ export function MobileSwipeDownModal({ children, requestClose }: MobileSwipeDown
// Only allow pulling down
if (diff > 0) {
touchYDiff.current = diff;
resetAnimationRef.current?.cancel();
containerRef.current?.getAnimations().forEach((animation) => animation.cancel());
if (containerRef.current) {
containerRef.current.style.transform = `translateY(${diff}px)`;
containerRef.current.style.transition = 'none';
containerRef.current.style.transform = `translate3d(0, ${diff}px, 0)`;
}
}
};
Expand All @@ -58,10 +63,24 @@ export function MobileSwipeDownModal({ children, requestClose }: MobileSwipeDown
) {
requestClose();
} else {
const currentOffset = touchYDiff.current;
touchYDiff.current = 0;
if (containerRef.current) {
containerRef.current.style.transform = '';
containerRef.current.style.transition = 'transform 0.2s ease-out';
const container = containerRef.current;
resetAnimationRef.current = container.animate(
[
{ transform: `translate3d(0, ${currentOffset}px, 0)` },
{ transform: 'translate3d(0, 0, 0)' },
],
getMobileSheetTiming(shouldReduceMotion)
);
resetAnimationRef.current.addEventListener(
'finish',
() => {
container.style.transform = '';
},
{ once: true }
);
}
}
touchStartY.current = null;
Expand Down Expand Up @@ -92,11 +111,8 @@ export function MobileSwipeDownModal({ children, requestClose }: MobileSwipeDown
>
<Box
ref={containerRef}
className={css.MessageMobileOptionsContainer}
style={{
transform: touchYDiff.current > 0 ? `translateY(${touchYDiff.current}px)` : undefined,
transition: touchStartY.current === null ? 'transform 0.2s ease-out' : 'none',
}}
className={`${css.MessageMobileOptionsContainer} ${animationCss.SheetEntrance}`}
style={shouldReduceMotion ? { animation: 'none' } : undefined}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{children(dragHandleJSX, dragHandlers)}
Expand Down
83 changes: 39 additions & 44 deletions src/app/components/SwipeableChatWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { ReactNode } from 'react';
import { useCallback, useLayoutEffect, useRef, type ReactNode } from 'react';
import { animate, motion, useMotionValue } from 'framer-motion';
import { useDrag } from '@use-gesture/react';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom, RightSwipeAction } from '$state/settings';
import { haptic } from '$utils/haptics';
import { mobileOrTablet } from '$utils/user-agent';
import { useMobileNavDrawer } from '$components/page/MobileNavDrawerContext';

interface SwipeableChatWrapperProps {
children: ReactNode;
Expand All @@ -20,57 +20,52 @@ export function SwipeableChatWrapper({
const [mobileGestures] = useSetting(settingsAtom, 'mobileGestures');
const [rightSwipeAction] = useSetting(settingsAtom, 'rightSwipeAction');
const x = useMotionValue(0);
const containerRef = useRef<HTMLDivElement>(null);
const gestureActiveRef = useRef(false);
const drawer = useMobileNavDrawer();

const bind = useDrag(
({ first, active, offset: [ox], velocity: [vx], direction: [dx], event: e, cancel }) => {
if (first && e && 'target' in e && e.target instanceof HTMLElement) {
if (e.target.closest('[data-gestures="ignore"]')) {
cancel();
return;
}
const move = useCallback(
(distanceX: number) => {
if (!gestureActiveRef.current) {
gestureActiveRef.current = true;
x.stop();
}

if (!mobileGestures || !mobileOrTablet()) return;

let val = ox;

const canSwipeLeft =
rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply;
x.set(canSwipeLeft ? Math.max(-200, Math.min(0, distanceX)) : 0);
},
[onOpenMembers, onReply, rightSwipeAction, x]
);

// The drawer owns the rightward reveal; this wrapper only handles leftward actions.
if (val > 0) val = 0;
if (!canSwipeLeft && val < 0) val = 0;

if (active) {
// Take over any settling spring; offset is seeded from the live position.
if (first) x.stop();
x.set(val);
} else {
const swipeThreshold = 120;
const velocityThreshold = 0.5;

if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) {
haptic('light');
if (rightSwipeAction === RightSwipeAction.Members) {
onOpenMembers?.();
} else {
onReply?.();
}
const finish = useCallback(
(commit: boolean, distanceX = 0, velocityX = 0) => {
if (commit && (distanceX < -120 || velocityX < -0.5)) {
haptic('light');
if (rightSwipeAction === RightSwipeAction.Members) {
onOpenMembers?.();
} else {
onReply?.();
}
animate(x, 0, { type: 'spring', stiffness: 400, damping: 40 });
}

gestureActiveRef.current = false;
animate(x, 0, { type: 'spring', stiffness: 400, damping: 40 });
},
{
axis: 'x',
bounds: { left: -200, right: 200 },
rubberband: true,
filterTaps: true,
pointer: { capture: false },
eventOptions: { passive: true },
from: () => [x.get(), 0],
}
[onOpenMembers, onReply, rightSwipeAction, x]
);

useLayoutEffect(() => {
const element = containerRef.current;
if (!drawer || !element || !mobileGestures || !mobileOrTablet()) return undefined;

return drawer.registerChatSwipe(element, {
move,
end: ({ distanceX, velocityX }) => finish(true, distanceX, velocityX),
cancel: () => finish(false),
});
}, [drawer, finish, mobileGestures, move]);

if (!mobileGestures || !mobileOrTablet()) {
return (
<div
Expand All @@ -89,9 +84,9 @@ export function SwipeableChatWrapper({

return (
<div
{...bind()}
ref={containerRef}
data-chat-swipe
style={{
touchAction: 'pan-y',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
Expand Down
Loading
Loading