chore: update cinny submodule to latest dirty commit and refactor mobile swipe gesture handling
This commit is contained in:
@@ -22,6 +22,7 @@ type GestureState = {
|
|||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
messageEl: HTMLElement | null;
|
messageEl: HTMLElement | null;
|
||||||
|
transformEl: HTMLElement | null;
|
||||||
messageId: string | null;
|
messageId: string | null;
|
||||||
edgeBack: boolean;
|
edgeBack: boolean;
|
||||||
offset: number;
|
offset: number;
|
||||||
@@ -33,6 +34,7 @@ const IDLE_STATE: GestureState = {
|
|||||||
startX: 0,
|
startX: 0,
|
||||||
startY: 0,
|
startY: 0,
|
||||||
messageEl: null,
|
messageEl: null,
|
||||||
|
transformEl: null,
|
||||||
messageId: null,
|
messageId: null,
|
||||||
edgeBack: false,
|
edgeBack: false,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -65,6 +67,12 @@ const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
|||||||
return target.closest('[data-message-id]') as HTMLElement | null;
|
return target.closest('[data-message-id]') as HTMLElement | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findReplyTransformTarget = (messageEl: HTMLElement): HTMLElement => {
|
||||||
|
const content = messageEl.querySelector('[data-swipe-reply-content]');
|
||||||
|
if (content instanceof HTMLElement) return content;
|
||||||
|
return messageEl;
|
||||||
|
};
|
||||||
|
|
||||||
const applyBackOffset = (dx: number, width: number): number => {
|
const applyBackOffset = (dx: number, width: number): number => {
|
||||||
const positive = Math.max(0, dx);
|
const positive = Math.max(0, dx);
|
||||||
if (positive <= width) return positive;
|
if (positive <= width) return positive;
|
||||||
@@ -123,18 +131,18 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
document.documentElement.classList.remove('mobile-gesture-lock');
|
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resetReplyTransform = useCallback((messageEl?: HTMLElement | null) => {
|
const resetReplyTransform = useCallback((transformEl?: HTMLElement | null) => {
|
||||||
const bridge = mobileSwipeReplyBridgeRef.current;
|
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||||
const el = messageEl ?? gestureRef.current.messageEl;
|
const el = transformEl ?? gestureRef.current.transformEl;
|
||||||
resetElementTransform(el);
|
resetElementTransform(el);
|
||||||
bridge?.setIndicator(null, false);
|
bridge?.setIndicator(null, false);
|
||||||
document.documentElement.classList.remove('mobile-gesture-lock');
|
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resetGesture = useCallback(
|
const resetGesture = useCallback(
|
||||||
(messageEl?: HTMLElement | null) => {
|
(transformEl?: HTMLElement | null) => {
|
||||||
resetBackTransform();
|
resetBackTransform();
|
||||||
resetReplyTransform(messageEl);
|
resetReplyTransform(transformEl);
|
||||||
unlockScroll();
|
unlockScroll();
|
||||||
gestureRef.current = { ...IDLE_STATE };
|
gestureRef.current = { ...IDLE_STATE };
|
||||||
},
|
},
|
||||||
@@ -150,6 +158,14 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
bridge.setIndicator(top, Math.abs(offset) >= REPLY_THRESHOLD * 0.6);
|
bridge.setIndicator(top, Math.abs(offset) >= REPLY_THRESHOLD * 0.6);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const applyReplyTransform = useCallback(
|
||||||
|
(transformEl: HTMLElement, messageEl: HTMLElement, offset: number) => {
|
||||||
|
setElementTransform(transformEl, offset);
|
||||||
|
updateReplyIndicator(messageEl, offset);
|
||||||
|
},
|
||||||
|
[updateReplyIndicator]
|
||||||
|
);
|
||||||
|
|
||||||
const lockGesture = useCallback(
|
const lockGesture = useCallback(
|
||||||
(phase: 'back' | 'reply', scrollAnchor?: HTMLElement | null) => {
|
(phase: 'back' | 'reply', scrollAnchor?: HTMLElement | null) => {
|
||||||
gestureRef.current.phase = phase;
|
gestureRef.current.phase = phase;
|
||||||
@@ -183,9 +199,9 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
|
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
|
||||||
const bridge = mobileSwipeReplyBridgeRef.current;
|
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||||
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
|
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
|
||||||
const { messageEl, messageId } = gesture;
|
const { messageEl, messageId, transformEl } = gesture;
|
||||||
|
|
||||||
resetGesture(messageEl);
|
resetGesture(transformEl);
|
||||||
|
|
||||||
if (shouldReply && bridge) {
|
if (shouldReply && bridge) {
|
||||||
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
|
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
|
||||||
@@ -232,6 +248,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
startX: touch.clientX,
|
startX: touch.clientX,
|
||||||
startY: touch.clientY,
|
startY: touch.clientY,
|
||||||
messageEl: null,
|
messageEl: null,
|
||||||
|
transformEl: null,
|
||||||
messageId: null,
|
messageId: null,
|
||||||
edgeBack: true,
|
edgeBack: true,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -246,6 +263,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
startX: touch.clientX,
|
startX: touch.clientX,
|
||||||
startY: touch.clientY,
|
startY: touch.clientY,
|
||||||
messageEl: canStartReply ? messageEl : null,
|
messageEl: canStartReply ? messageEl : null,
|
||||||
|
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
|
||||||
messageId: canStartReply ? messageId : null,
|
messageId: canStartReply ? messageId : null,
|
||||||
edgeBack: false,
|
edgeBack: false,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -277,7 +295,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
|
|
||||||
if (deltaX > 0 && backEnabled) {
|
if (deltaX > 0 && backEnabled) {
|
||||||
lockGesture('back', contentRef.current);
|
lockGesture('back', contentRef.current);
|
||||||
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId) {
|
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId && gesture.transformEl) {
|
||||||
lockGesture('reply', gesture.messageEl);
|
lockGesture('reply', gesture.messageEl);
|
||||||
} else {
|
} else {
|
||||||
resetGesture();
|
resetGesture();
|
||||||
@@ -296,14 +314,13 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gesture.phase === 'reply' && gesture.messageEl) {
|
if (gesture.phase === 'reply' && gesture.transformEl && gesture.messageEl) {
|
||||||
if (!evt.cancelable) return;
|
if (!evt.cancelable) return;
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
||||||
const offset = Math.max(deltaX, -REPLY_MAX);
|
const offset = Math.max(deltaX, -REPLY_MAX);
|
||||||
gesture.offset = offset;
|
gesture.offset = offset;
|
||||||
setElementTransform(gesture.messageEl, offset);
|
applyReplyTransform(gesture.transformEl, gesture.messageEl, offset);
|
||||||
updateReplyIndicator(gesture.messageEl, offset);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -329,7 +346,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
|
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
|
||||||
resetGesture();
|
resetGesture();
|
||||||
};
|
};
|
||||||
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, updateReplyIndicator]);
|
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, applyReplyTransform]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetGesture();
|
resetGesture();
|
||||||
@@ -339,16 +356,8 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showBackChrome = backEnabled;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className={css.SwipeBackRoot}>
|
<div ref={rootRef} className={css.SwipeBackRoot}>
|
||||||
{showBackChrome && (
|
|
||||||
<div className={css.SwipeBackUnderlay} aria-hidden>
|
|
||||||
<div className={css.SwipeBackSidebarPeek} />
|
|
||||||
<div className={css.SwipeBackChannelPeek} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={contentRef} className={css.SwipeBackContent}>
|
<div ref={contentRef} className={css.SwipeBackContent}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,37 +13,38 @@ export const SwipeBackRoot = style({
|
|||||||
|
|
||||||
export const SwipeBackContent = style({
|
export const SwipeBackContent = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
backgroundColor: color.Background.Container,
|
backgroundColor: color.Background.Container,
|
||||||
boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)',
|
boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.28)',
|
||||||
willChange: 'transform',
|
willChange: 'transform',
|
||||||
touchAction: 'pan-y',
|
touchAction: 'pan-y',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SwipeBackUnderlay = style({
|
export const CompactDetailRoot = style({
|
||||||
|
position: 'relative',
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CompactNavUnderlay = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
pointerEvents: 'none',
|
flexDirection: 'column',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SwipeBackSidebarPeek = style({
|
export const CompactDetailLayer = style({
|
||||||
width: toRem(66),
|
position: 'relative',
|
||||||
flexShrink: 0,
|
zIndex: 1,
|
||||||
backgroundColor: color.Background.Container,
|
|
||||||
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SwipeBackChannelPeek = style({
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
backgroundColor: color.Background.Container,
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SwipeToReplyLayer = style({
|
export const SwipeToReplyLayer = style({
|
||||||
|
|||||||
203
overlay/src/app/components/page/Page.tsx
Normal file
203
overlay/src/app/components/page/Page.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
|
||||||
|
import { Box, Header, Line, Scroll, Text, as } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import * as css from './style.css';
|
||||||
|
import * as swipeCss from '../mobile/mobile-gestures.css';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||||
|
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||||
|
|
||||||
|
type PageRootProps = {
|
||||||
|
nav: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageRoot({ nav, children }: PageRootProps) {
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
const showCompactMaster = useShowCompactMasterView();
|
||||||
|
const compact = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
|
if (compact && showCompactMaster) {
|
||||||
|
return (
|
||||||
|
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||||
|
{nav}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact && !showCompactMaster) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
className={classNames(ContainerColor({ variant: 'Background' }), swipeCss.CompactDetailRoot)}
|
||||||
|
>
|
||||||
|
{nav && (
|
||||||
|
<Box className={swipeCss.CompactNavUnderlay} aria-hidden>
|
||||||
|
{nav}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box grow="Yes" className={swipeCss.CompactDetailLayer}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||||
|
{nav}
|
||||||
|
{screenSize !== ScreenSize.Mobile && (
|
||||||
|
<Line variant="Background" size="300" direction="Vertical" />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientDrawerLayoutProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
export function PageNav({
|
||||||
|
size,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ClientDrawerLayoutProps & css.PageNavVariants & { className?: string }) {
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
const isMobile = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
grow={isMobile ? 'Yes' : undefined}
|
||||||
|
className={classNames(css.PageNav({ size }), className)}
|
||||||
|
shrink={isMobile ? 'Yes' : 'No'}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{children}
|
||||||
|
<SidebarDockedCallPanel />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
||||||
|
({ className, outlined, ...props }, ref) => (
|
||||||
|
<Header
|
||||||
|
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||||
|
variant="Background"
|
||||||
|
size="600"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export function PageNavContent({
|
||||||
|
scrollRef,
|
||||||
|
scrollProps,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Scroll
|
||||||
|
ref={scrollRef}
|
||||||
|
variant="Background"
|
||||||
|
direction="Vertical"
|
||||||
|
size="300"
|
||||||
|
hideTrack
|
||||||
|
visibility="Hover"
|
||||||
|
{...scrollProps}
|
||||||
|
>
|
||||||
|
<div className={css.PageNavContent}>{children}</div>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Page = as<'div'>(({ className, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
export const PageHeader = as<'div', css.PageHeaderVariants>(
|
||||||
|
({ className, outlined, balance, ...props }, ref) => (
|
||||||
|
<Header
|
||||||
|
as="header"
|
||||||
|
size="600"
|
||||||
|
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PageContent = as<'div'>(({ className, ...props }, ref) => (
|
||||||
|
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
export function PageHeroEmpty({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="200"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
className={classNames(css.PageHeroSection, className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export function PageHero({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subTitle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
title: ReactNode;
|
||||||
|
subTitle: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Box direction="Column" alignItems="Center" gap="200">
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
<Box as="h2" direction="Column" gap="200" alignItems="Center">
|
||||||
|
<Text align="Center" size="H2">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text align="Center" priority="400">
|
||||||
|
{subTitle}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
|
||||||
|
<div className={classNames(css.PageContentCenter, className)} {...props} ref={ref} />
|
||||||
|
));
|
||||||
1405
overlay/src/app/features/room/message/Message.tsx
Normal file
1405
overlay/src/app/features/room/message/Message.tsx
Normal file
File diff suppressed because it is too large
Load Diff
20
overlay/src/app/pages/MobileFriendly.tsx
Normal file
20
overlay/src/app/pages/MobileFriendly.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type MobileFriendlyClientNavProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Server sidebar stays mounted on compact detail routes for swipe-back reveal. */
|
||||||
|
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MobileFriendlyPageNavProps = {
|
||||||
|
path: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Channel list stays mounted on compact detail routes for swipe-back reveal. */
|
||||||
|
export function MobileFriendlyPageNav({ path: _path, children }: MobileFriendlyPageNavProps) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user