Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f709998c50 | ||
| ddcda65c1f | |||
| 6e32bf302a | |||
|
|
ca51f93ecd | ||
| 47c525cc5a | |||
| a282fce14a | |||
|
|
f1121839d4 | ||
| 585eeecb1b | |||
| 72dbbe29b9 | |||
|
|
b6000c1db8 | ||
| 83b4cbbd8e |
@@ -27,8 +27,8 @@ android {
|
|||||||
applicationId "com.paarrot.app"
|
applicationId "com.paarrot.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1783526741
|
versionCode 1783582912
|
||||||
versionName "2026-07-08.160541.766"
|
versionName "2026-07-09.074152.772"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
|
import { useCompactNav } from '../hooks/useCompactNav';
|
||||||
|
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper for Outlet that adds route-based animation
|
* Wrapper for Outlet that adds route-based animation
|
||||||
@@ -8,12 +9,13 @@ import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
|
|||||||
*/
|
*/
|
||||||
export function AnimatedOutlet() {
|
export function AnimatedOutlet() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const compact = useCompactNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileSwipeBackPanel>
|
<MobileSwipeGestureHost>
|
||||||
<div
|
<div
|
||||||
key={location.pathname}
|
key={location.pathname}
|
||||||
data-route-transition="true"
|
{...(!compact && { 'data-route-transition': 'true' })}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -25,6 +27,6 @@ export function AnimatedOutlet() {
|
|||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</MobileSwipeBackPanel>
|
</MobileSwipeGestureHost>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
|
||||||
import { useBackRoute } from '../../hooks/useBackRoute';
|
|
||||||
import * as css from './mobile-gestures.css';
|
|
||||||
|
|
||||||
const COMMIT_RATIO = 0.28;
|
|
||||||
const MIN_COMMIT_PX = 72;
|
|
||||||
const MAX_START_Y_RATIO = 0.88;
|
|
||||||
|
|
||||||
type DragState = {
|
|
||||||
pointerId: number;
|
|
||||||
startX: number;
|
|
||||||
startY: number;
|
|
||||||
dragging: boolean;
|
|
||||||
moved: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MobileSwipeBackPanelProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) {
|
|
||||||
const compact = useCompactNav();
|
|
||||||
const { canGoBack, goBack } = useBackRoute();
|
|
||||||
const enabled = compact && canGoBack;
|
|
||||||
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const dragRef = useRef<DragState | null>(null);
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
const [animating, setAnimating] = useState(false);
|
|
||||||
|
|
||||||
const resetTransform = useCallback((animate = true) => {
|
|
||||||
const content = contentRef.current;
|
|
||||||
if (!content) return;
|
|
||||||
setAnimating(animate);
|
|
||||||
setOffset(0);
|
|
||||||
content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
|
|
||||||
content.style.transform = 'translateX(0px)';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setTransform = useCallback((px: number, animate = false) => {
|
|
||||||
const content = contentRef.current;
|
|
||||||
if (!content) return;
|
|
||||||
setOffset(px);
|
|
||||||
content.style.transition = animate
|
|
||||||
? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)'
|
|
||||||
: 'none';
|
|
||||||
content.style.transform = `translateX(${px}px)`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const commitBack = useCallback(() => {
|
|
||||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
|
||||||
setAnimating(true);
|
|
||||||
setTransform(width, true);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
goBack();
|
|
||||||
resetTransform(false);
|
|
||||||
setAnimating(false);
|
|
||||||
}, 180);
|
|
||||||
}, [goBack, resetTransform, setTransform]);
|
|
||||||
|
|
||||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
|
||||||
if (!(target instanceof Element)) return true;
|
|
||||||
return Boolean(
|
|
||||||
target.closest(
|
|
||||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
|
||||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!enabled || animating || evt.button !== 0 || !evt.isPrimary) return;
|
|
||||||
if (shouldIgnoreTarget(evt.target)) return;
|
|
||||||
if (evt.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
|
|
||||||
|
|
||||||
dragRef.current = {
|
|
||||||
pointerId: evt.pointerId,
|
|
||||||
startX: evt.clientX,
|
|
||||||
startY: evt.clientY,
|
|
||||||
dragging: true,
|
|
||||||
moved: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
rootRef.current?.setPointerCapture(evt.pointerId);
|
|
||||||
},
|
|
||||||
[animating, enabled]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
|
||||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
const drag = dragRef.current;
|
|
||||||
if (!drag || !drag.dragging || drag.pointerId !== evt.pointerId) return;
|
|
||||||
|
|
||||||
const deltaX = evt.clientX - drag.startX;
|
|
||||||
const deltaY = evt.clientY - drag.startY;
|
|
||||||
|
|
||||||
if (!drag.moved) {
|
|
||||||
if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return;
|
|
||||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
|
||||||
dragRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (deltaX <= 0) {
|
|
||||||
dragRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
drag.moved = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
evt.preventDefault();
|
|
||||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
|
||||||
setTransform(Math.min(Math.max(deltaX, 0), width), false);
|
|
||||||
},
|
|
||||||
[setTransform]
|
|
||||||
);
|
|
||||||
|
|
||||||
const endDrag = useCallback(
|
|
||||||
(pointerId: number) => {
|
|
||||||
const drag = dragRef.current;
|
|
||||||
if (!drag || drag.pointerId !== pointerId) return;
|
|
||||||
|
|
||||||
if (rootRef.current?.hasPointerCapture(pointerId)) {
|
|
||||||
rootRef.current.releasePointerCapture(pointerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
dragRef.current = null;
|
|
||||||
|
|
||||||
if (!drag.moved) {
|
|
||||||
resetTransform(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
|
||||||
const shouldCommit = offset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX);
|
|
||||||
if (shouldCommit) {
|
|
||||||
commitBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetTransform(true);
|
|
||||||
},
|
|
||||||
[commitBack, offset, resetTransform]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback(
|
|
||||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
endDrag(evt.pointerId);
|
|
||||||
},
|
|
||||||
[endDrag]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
resetTransform(false);
|
|
||||||
}, [enabled, resetTransform]);
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={rootRef}
|
|
||||||
className={css.SwipeBackRoot}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
>
|
|
||||||
<div className={css.SwipeBackUnderlay} aria-hidden>
|
|
||||||
<div className={css.SwipeBackSidebarPeek} />
|
|
||||||
<div className={css.SwipeBackChannelPeek} />
|
|
||||||
</div>
|
|
||||||
<div ref={contentRef} className={css.SwipeBackContent}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
366
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
366
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||||
|
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||||
|
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
||||||
|
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||||
|
import * as css from './mobile-gestures.css';
|
||||||
|
|
||||||
|
const EDGE_WIDTH = 28;
|
||||||
|
const BACK_COMMIT_RATIO = 0.25;
|
||||||
|
const BACK_COMMIT_MIN = 56;
|
||||||
|
const REPLY_THRESHOLD = 48;
|
||||||
|
const REPLY_MAX = 96;
|
||||||
|
const AXIS_LOCK_PX = 10;
|
||||||
|
const AXIS_DOMINANCE = 1.2;
|
||||||
|
const MAX_START_Y_RATIO = 0.88;
|
||||||
|
|
||||||
|
type GesturePhase = 'idle' | 'pending' | 'back' | 'reply';
|
||||||
|
|
||||||
|
type GestureState = {
|
||||||
|
phase: GesturePhase;
|
||||||
|
touchId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
messageEl: HTMLElement | null;
|
||||||
|
transformEl: HTMLElement | null;
|
||||||
|
messageId: string | null;
|
||||||
|
edgeBack: boolean;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IDLE_STATE: GestureState = {
|
||||||
|
phase: 'idle',
|
||||||
|
touchId: -1,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
messageEl: null,
|
||||||
|
transformEl: null,
|
||||||
|
messageId: null,
|
||||||
|
edgeBack: false,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
type MobileSwipeGestureHostProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element)) return true;
|
||||||
|
return Boolean(
|
||||||
|
target.closest(
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element)) return true;
|
||||||
|
return Boolean(
|
||||||
|
target.closest(
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
||||||
|
if (!(target instanceof Element)) return 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 positive = Math.max(0, dx);
|
||||||
|
if (positive <= width) return positive;
|
||||||
|
return width + (positive - width) * 0.22;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setElementTransform = (el: HTMLElement | null, px: number) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.transition = 'none';
|
||||||
|
el.style.transform = `translateX(${px}px)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetElementTransform = (el: HTMLElement | null) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.transition = 'none';
|
||||||
|
el.style.transform = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) {
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const { canGoBack, goBack } = useBackRoute();
|
||||||
|
const backEnabled = compact && canGoBack;
|
||||||
|
const replyEnabled = compact;
|
||||||
|
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const gestureRef = useRef<GestureState>({ ...IDLE_STATE });
|
||||||
|
const scrollLockRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const unlockScroll = useCallback(() => {
|
||||||
|
if (scrollLockRef.current) {
|
||||||
|
scrollLockRef.current.style.overflow = '';
|
||||||
|
scrollLockRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const lockScroll = useCallback((anchor: HTMLElement) => {
|
||||||
|
unlockScroll();
|
||||||
|
let el: HTMLElement | null = anchor;
|
||||||
|
while (el && el !== document.body) {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
if (
|
||||||
|
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
|
||||||
|
el.scrollHeight > el.clientHeight + 1
|
||||||
|
) {
|
||||||
|
scrollLockRef.current = el;
|
||||||
|
el.style.overflow = 'hidden';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
}, [unlockScroll]);
|
||||||
|
|
||||||
|
const resetBackTransform = useCallback(() => {
|
||||||
|
resetElementTransform(contentRef.current);
|
||||||
|
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetReplyTransform = useCallback((transformEl?: HTMLElement | null) => {
|
||||||
|
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||||
|
const el = transformEl ?? gestureRef.current.transformEl;
|
||||||
|
resetElementTransform(el);
|
||||||
|
bridge?.setIndicator(null, false);
|
||||||
|
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetGesture = useCallback(
|
||||||
|
(transformEl?: HTMLElement | null) => {
|
||||||
|
resetBackTransform();
|
||||||
|
resetReplyTransform(transformEl);
|
||||||
|
unlockScroll();
|
||||||
|
gestureRef.current = { ...IDLE_STATE };
|
||||||
|
},
|
||||||
|
[resetBackTransform, resetReplyTransform, unlockScroll]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateReplyIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
|
||||||
|
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||||
|
if (!bridge?.layerEl) return;
|
||||||
|
const messageRect = messageEl.getBoundingClientRect();
|
||||||
|
const layerRect = bridge.layerEl.getBoundingClientRect();
|
||||||
|
const top = messageRect.top - layerRect.top + messageRect.height / 2 - 18;
|
||||||
|
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(
|
||||||
|
(phase: 'back' | 'reply', scrollAnchor?: HTMLElement | null) => {
|
||||||
|
gestureRef.current.phase = phase;
|
||||||
|
document.documentElement.classList.add('mobile-gesture-lock');
|
||||||
|
if (scrollAnchor) {
|
||||||
|
lockScroll(scrollAnchor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[lockScroll]
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishGesture = useCallback(() => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (gesture.phase === 'idle' || gesture.phase === 'pending') {
|
||||||
|
resetGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture.phase === 'back') {
|
||||||
|
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||||
|
const shouldCommit =
|
||||||
|
gesture.offset >= Math.max(width * BACK_COMMIT_RATIO, BACK_COMMIT_MIN);
|
||||||
|
|
||||||
|
resetGesture();
|
||||||
|
if (shouldCommit) {
|
||||||
|
goBack();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
|
||||||
|
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||||
|
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
|
||||||
|
const { messageEl, messageId, transformEl } = gesture;
|
||||||
|
|
||||||
|
resetGesture(transformEl);
|
||||||
|
|
||||||
|
if (shouldReply && bridge) {
|
||||||
|
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [goBack, resetGesture]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!compact) return;
|
||||||
|
|
||||||
|
const getActiveTouch = (evt: TouchEvent, touchId: number) =>
|
||||||
|
Array.from(evt.touches).find((touch) => touch.identifier === touchId);
|
||||||
|
|
||||||
|
const getEndedTouch = (evt: TouchEvent, touchId: number) =>
|
||||||
|
Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId);
|
||||||
|
|
||||||
|
const handleTouchStart = (evt: TouchEvent) => {
|
||||||
|
if (gestureRef.current.phase !== 'idle') return;
|
||||||
|
|
||||||
|
const touch = evt.changedTouches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const target = evt.target;
|
||||||
|
if (touch.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
|
||||||
|
|
||||||
|
const messageEl = findMessageElement(target);
|
||||||
|
const messageId = messageEl?.getAttribute('data-message-id') ?? null;
|
||||||
|
|
||||||
|
const canStartBack = backEnabled && !shouldIgnoreBackTarget(target);
|
||||||
|
const canStartReply =
|
||||||
|
replyEnabled &&
|
||||||
|
messageEl &&
|
||||||
|
messageId &&
|
||||||
|
!shouldIgnoreReplyTarget(target) &&
|
||||||
|
mobileSwipeReplyBridgeRef.current;
|
||||||
|
|
||||||
|
if (!canStartBack && !canStartReply) return;
|
||||||
|
|
||||||
|
if (touch.clientX <= EDGE_WIDTH && canStartBack) {
|
||||||
|
evt.preventDefault();
|
||||||
|
gestureRef.current = {
|
||||||
|
phase: 'back',
|
||||||
|
touchId: touch.identifier,
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
messageEl: null,
|
||||||
|
transformEl: null,
|
||||||
|
messageId: null,
|
||||||
|
edgeBack: true,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
document.documentElement.classList.add('mobile-gesture-lock');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gestureRef.current = {
|
||||||
|
phase: 'pending',
|
||||||
|
touchId: touch.identifier,
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
messageEl: canStartReply ? messageEl : null,
|
||||||
|
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
|
||||||
|
messageId: canStartReply ? messageId : null,
|
||||||
|
edgeBack: false,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (evt: TouchEvent) => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (gesture.phase === 'idle') return;
|
||||||
|
|
||||||
|
const touch = getActiveTouch(evt, gesture.touchId);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const deltaX = touch.clientX - gesture.startX;
|
||||||
|
const deltaY = touch.clientY - gesture.startY;
|
||||||
|
|
||||||
|
if (gesture.phase === 'pending') {
|
||||||
|
if (Math.abs(deltaX) < AXIS_LOCK_PX && Math.abs(deltaY) < AXIS_LOCK_PX) return;
|
||||||
|
|
||||||
|
const horizontal = Math.abs(deltaX) >= Math.abs(deltaY) * AXIS_DOMINANCE;
|
||||||
|
const vertical = Math.abs(deltaY) >= Math.abs(deltaX) * AXIS_DOMINANCE;
|
||||||
|
|
||||||
|
if (vertical) {
|
||||||
|
resetGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!horizontal) return;
|
||||||
|
|
||||||
|
if (deltaX > 0 && backEnabled) {
|
||||||
|
lockGesture('back', contentRef.current);
|
||||||
|
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId && gesture.transformEl) {
|
||||||
|
lockGesture('reply', gesture.messageEl);
|
||||||
|
} else {
|
||||||
|
resetGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture.phase === 'back') {
|
||||||
|
if (!evt.cancelable) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||||
|
const offset = applyBackOffset(deltaX, width);
|
||||||
|
gesture.offset = offset;
|
||||||
|
setElementTransform(contentRef.current, offset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture.phase === 'reply' && gesture.transformEl && gesture.messageEl) {
|
||||||
|
if (!evt.cancelable) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
const offset = Math.max(deltaX, -REPLY_MAX);
|
||||||
|
gesture.offset = offset;
|
||||||
|
applyReplyTransform(gesture.transformEl, gesture.messageEl, offset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (evt: TouchEvent) => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (gesture.phase === 'idle') return;
|
||||||
|
|
||||||
|
const touch = getEndedTouch(evt, gesture.touchId);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
finishGesture();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: false });
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { capture: true, passive: false });
|
||||||
|
document.addEventListener('touchend', handleTouchEnd, { capture: true });
|
||||||
|
document.addEventListener('touchcancel', handleTouchEnd, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart, { capture: true });
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove, { capture: true });
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd, { capture: true });
|
||||||
|
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
|
||||||
|
resetGesture();
|
||||||
|
};
|
||||||
|
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, applyReplyTransform]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetGesture();
|
||||||
|
}, [canGoBack, resetGesture]);
|
||||||
|
|
||||||
|
if (!compact) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={css.SwipeBackRoot}>
|
||||||
|
<div ref={contentRef} className={css.SwipeBackContent}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,13 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||||
import * as css from './mobile-gestures.css';
|
import * as css from './mobile-gestures.css';
|
||||||
|
|
||||||
const SWIPE_THRESHOLD = 56;
|
|
||||||
const MAX_SWIPE = 88;
|
|
||||||
|
|
||||||
type DragState = {
|
|
||||||
pointerId: number;
|
|
||||||
startX: number;
|
|
||||||
startY: number;
|
|
||||||
messageEl: HTMLElement;
|
|
||||||
messageId: string;
|
|
||||||
moved: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MobileSwipeToReplyLayerProps = {
|
type MobileSwipeToReplyLayerProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -28,178 +16,40 @@ type MobileSwipeToReplyLayerProps = {
|
|||||||
|
|
||||||
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
|
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
|
||||||
const compact = useCompactNav();
|
const compact = useCompactNav();
|
||||||
const enabled = compact;
|
|
||||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||||
const layerRef = useRef<HTMLDivElement>(null);
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
const dragRef = useRef<DragState | null>(null);
|
|
||||||
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
|
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
|
||||||
const [indicatorActive, setIndicatorActive] = useState(false);
|
const [indicatorActive, setIndicatorActive] = useState(false);
|
||||||
|
|
||||||
const clearMessageTransform = useCallback((el?: HTMLElement | null, animate = true) => {
|
useLayoutEffect(() => {
|
||||||
const target = el ?? dragRef.current?.messageEl;
|
if (!compact) return;
|
||||||
if (!target) return;
|
|
||||||
target.style.transition = animate ? 'transform 0.18s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
|
|
||||||
target.style.transform = 'translateX(0px)';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetGesture = useCallback(() => {
|
mobileSwipeReplyBridgeRef.current = {
|
||||||
clearMessageTransform();
|
room,
|
||||||
dragRef.current = null;
|
editor,
|
||||||
setIndicatorTop(null);
|
setReplyDraft,
|
||||||
setIndicatorActive(false);
|
layerEl: layerRef.current,
|
||||||
}, [clearMessageTransform]);
|
setIndicator: (top, active) => {
|
||||||
|
setIndicatorTop(top);
|
||||||
|
setIndicatorActive(active);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
return () => {
|
||||||
if (!(target instanceof Element)) return true;
|
if (mobileSwipeReplyBridgeRef.current?.room.roomId === room.roomId) {
|
||||||
if (
|
mobileSwipeReplyBridgeRef.current = null;
|
||||||
target.closest(
|
|
||||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-disable-swipe-reply="true"]'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let el: Element | null = target;
|
|
||||||
while (el && layerRef.current?.contains(el)) {
|
|
||||||
if (el instanceof HTMLElement) {
|
|
||||||
const { overflowX } = window.getComputedStyle(el);
|
|
||||||
if (
|
|
||||||
(overflowX === 'auto' || overflowX === 'scroll') &&
|
|
||||||
el.scrollWidth > el.clientWidth + 8
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
el = el.parentElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
|
||||||
if (!(target instanceof Element)) return null;
|
|
||||||
return target.closest('[data-message-id]') as HTMLElement | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
|
|
||||||
const layer = layerRef.current;
|
|
||||||
if (!layer) return;
|
|
||||||
const messageRect = messageEl.getBoundingClientRect();
|
|
||||||
const layerRect = layer.getBoundingClientRect();
|
|
||||||
setIndicatorTop(messageRect.top - layerRect.top + messageRect.height / 2 - 18);
|
|
||||||
setIndicatorActive(Math.abs(offset) >= SWIPE_THRESHOLD * 0.65);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
|
||||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!enabled || evt.button !== 0 || !evt.isPrimary) return;
|
|
||||||
if (shouldIgnoreTarget(evt.target)) return;
|
|
||||||
|
|
||||||
const messageEl = findMessageElement(evt.target);
|
|
||||||
const messageId = messageEl?.getAttribute('data-message-id');
|
|
||||||
if (!messageEl || !messageId) return;
|
|
||||||
|
|
||||||
dragRef.current = {
|
|
||||||
pointerId: evt.pointerId,
|
|
||||||
startX: evt.clientX,
|
|
||||||
startY: evt.clientY,
|
|
||||||
messageEl,
|
|
||||||
messageId,
|
|
||||||
moved: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
layerRef.current?.setPointerCapture(evt.pointerId);
|
|
||||||
},
|
|
||||||
[enabled]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
|
||||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
const drag = dragRef.current;
|
|
||||||
if (!drag || drag.pointerId !== evt.pointerId) return;
|
|
||||||
|
|
||||||
const deltaX = evt.clientX - drag.startX;
|
|
||||||
const deltaY = evt.clientY - drag.startY;
|
|
||||||
|
|
||||||
if (!drag.moved) {
|
|
||||||
if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return;
|
|
||||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
|
||||||
resetGesture();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (deltaX >= 0) {
|
|
||||||
resetGesture();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
drag.moved = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
evt.preventDefault();
|
|
||||||
const offset = Math.max(deltaX, -MAX_SWIPE);
|
|
||||||
drag.messageEl.style.transition = 'none';
|
|
||||||
drag.messageEl.style.transform = `translateX(${offset}px)`;
|
|
||||||
updateIndicator(drag.messageEl, offset);
|
|
||||||
},
|
|
||||||
[resetGesture, updateIndicator]
|
|
||||||
);
|
|
||||||
|
|
||||||
const endDrag = useCallback(
|
|
||||||
(pointerId: number) => {
|
|
||||||
const drag = dragRef.current;
|
|
||||||
if (!drag || drag.pointerId !== pointerId) return;
|
|
||||||
|
|
||||||
if (layerRef.current?.hasPointerCapture(pointerId)) {
|
|
||||||
layerRef.current.releasePointerCapture(pointerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { messageEl, messageId, moved } = drag;
|
|
||||||
dragRef.current = null;
|
|
||||||
|
|
||||||
if (!moved) {
|
|
||||||
resetGesture();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matrix = window.getComputedStyle(messageEl).transform;
|
|
||||||
const offset =
|
|
||||||
matrix && matrix !== 'none'
|
|
||||||
? Number(new DOMMatrix(matrix).m41)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const shouldReply = Math.abs(offset) >= SWIPE_THRESHOLD;
|
|
||||||
clearMessageTransform(messageEl, true);
|
|
||||||
setIndicatorTop(null);
|
setIndicatorTop(null);
|
||||||
setIndicatorActive(false);
|
setIndicatorActive(false);
|
||||||
|
};
|
||||||
|
}, [compact, editor, room, setReplyDraft]);
|
||||||
|
|
||||||
if (shouldReply) {
|
if (!compact) {
|
||||||
startReplyToEvent(room, messageId, setReplyDraft, editor);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[clearMessageTransform, editor, resetGesture, room, setReplyDraft]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback(
|
|
||||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
endDrag(evt.pointerId);
|
|
||||||
},
|
|
||||||
[endDrag]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => resetGesture, [room.roomId, resetGesture]);
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={layerRef} className={css.SwipeToReplyLayer}>
|
||||||
ref={layerRef}
|
|
||||||
className={css.SwipeToReplyLayer}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
{indicatorTop !== null && (
|
{indicatorTop !== null && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -11,38 +11,40 @@ export const SwipeBackRoot = style({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SwipeBackUnderlay = style({
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
display: 'flex',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SwipeBackSidebarPeek = style({
|
|
||||||
width: toRem(66),
|
|
||||||
flexShrink: 0,
|
|
||||||
backgroundColor: color.Background.Container,
|
|
||||||
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SwipeBackChannelPeek = style({
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
backgroundColor: color.Background.Container,
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CompactDetailRoot = style({
|
||||||
|
position: 'relative',
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CompactNavUnderlay = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CompactDetailLayer = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SwipeToReplyLayer = style({
|
export const SwipeToReplyLayer = style({
|
||||||
@@ -51,7 +53,6 @@ export const SwipeToReplyLayer = style({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
touchAction: 'pan-y',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SwipeToReplyIndicator = style({
|
export const SwipeToReplyIndicator = style({
|
||||||
|
|||||||
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal file
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { IReplyDraft } from '../../state/room/roomInputDrafts';
|
||||||
|
|
||||||
|
export type MobileSwipeReplyBridge = {
|
||||||
|
room: Room;
|
||||||
|
editor: Editor;
|
||||||
|
setReplyDraft: (draft: IReplyDraft | undefined) => void;
|
||||||
|
layerEl: HTMLElement | null;
|
||||||
|
setIndicator: (top: number | null, active: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mobileSwipeReplyBridgeRef: { current: MobileSwipeReplyBridge | null } = {
|
||||||
|
current: null,
|
||||||
|
};
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -184,6 +184,23 @@ body.mocha-theme {
|
|||||||
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disable route slide animation on compact/mobile — conflicts with swipe gestures */
|
||||||
|
@media (max-width: 750px) {
|
||||||
|
[data-route-transition='true'] {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.mobile-gesture-lock,
|
||||||
|
html.mobile-gesture-lock body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.mobile-gesture-lock [data-timeline-scroll] {
|
||||||
|
overflow: hidden !important;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Twilight theme enhanced transitions */
|
/* Twilight theme enhanced transitions */
|
||||||
.twilight-theme [data-route-transition="true"] {
|
.twilight-theme [data-route-transition="true"] {
|
||||||
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -504,3 +521,53 @@ html.android-capacitor body {
|
|||||||
word-spacing: normal;
|
word-spacing: normal;
|
||||||
font-variant-ligatures: normal;
|
font-variant-ligatures: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: hide all scrollbars (touch scrolling still works) */
|
||||||
|
@media (max-width: 750px) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar,
|
||||||
|
*::-webkit-scrollbar-thumb,
|
||||||
|
*::-webkit-scrollbar-track,
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twilight-theme ::-webkit-scrollbar,
|
||||||
|
.twilight-theme ::-webkit-scrollbar-thumb,
|
||||||
|
.twilight-theme ::-webkit-scrollbar-track,
|
||||||
|
.mocha-theme ::-webkit-scrollbar,
|
||||||
|
.mocha-theme ::-webkit-scrollbar-thumb,
|
||||||
|
.mocha-theme ::-webkit-scrollbar-track {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.android-capacitor,
|
||||||
|
html.android-capacitor *,
|
||||||
|
html.android-capacitor *::before,
|
||||||
|
html.android-capacitor *::after {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.android-capacitor *::-webkit-scrollbar,
|
||||||
|
html.android-capacitor *::-webkit-scrollbar-thumb,
|
||||||
|
html.android-capacitor *::-webkit-scrollbar-track,
|
||||||
|
html.android-capacitor *::-webkit-scrollbar-corner {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.114",
|
"version": "4.11.118",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user