Compare commits

..

16 Commits

Author SHA1 Message Date
GitHub Actions
a5afa6b493 chore: bump version to 4.11.121 [skip ci] 2026-07-11 11:03:38 +00:00
afeb45f59d Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 5m9s
Build / create-release (push) Successful in 14s
2026-07-11 21:03:28 +10:00
6f45f94f19 refactor: adjust RoomView component for improved mobile composer behavior and styling 2026-07-11 21:03:18 +10:00
GitHub Actions
fbcb291b9a chore: bump version to 4.11.120 [skip ci] 2026-07-11 10:49:12 +00:00
b7ec975fb0 refactor: update mobile gesture styles to use global styles for better maintainability
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m11s
Build / create-release (push) Successful in 14s
2026-07-11 20:48:54 +10:00
GitHub Actions
3f5d5dec98 chore: bump version to 4.11.119 [skip ci] 2026-07-11 10:36:49 +00:00
c7b6e8f5f0 refactor: enhance mobile swipe gesture handling and improve outlet behavior on compact routes
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 1m32s
Build / create-release (push) Has been skipped
2026-07-11 20:36:10 +10:00
GitHub Actions
f709998c50 chore: bump version to 4.11.118 [skip ci] 2026-07-09 09:12:12 +00:00
ddcda65c1f Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m19s
Build / create-release (push) Successful in 14s
2026-07-09 19:12:04 +10:00
6e32bf302a chore: hide scrollbars on mobile devices for better user experience 2026-07-09 19:11:53 +10:00
GitHub Actions
ca51f93ecd chore: bump version to 4.11.117 [skip ci] 2026-07-09 08:48:12 +00:00
47c525cc5a Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m24s
Build / create-release (push) Successful in 14s
2026-07-09 18:48:04 +10:00
a282fce14a chore: update cinny submodule to latest dirty commit and refactor mobile swipe gesture handling 2026-07-09 18:47:55 +10:00
GitHub Actions
f1121839d4 chore: bump version to 4.11.116 [skip ci] 2026-07-09 08:06:48 +00:00
585eeecb1b Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m1s
Build / create-release (push) Successful in 15s
2026-07-09 18:06:32 +10:00
72dbbe29b9 chore: update cinny submodule to latest commit with mobile gesture enhancements and disable route animation on compact view 2026-07-09 18:06:06 +10:00
22 changed files with 2624 additions and 517 deletions

View File

@@ -1,19 +1,29 @@
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
import { useCompactNav, useIsCompactListRoute } from '../hooks/useCompactNav';
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
/**
* Wrapper for Outlet that adds route-based animation
* Forces remount on route change by using location as key
* Outlet wrapper for route remounts + compact swipe-back chrome.
*
* On compact list routes the parent PageRoot already omits children. If this
* outlet is still mounted (stale stack / empty SpaceIndexRedirect), render
* nothing so an empty swipe shell cannot sit above the channel list and eat taps.
*/
export function AnimatedOutlet() {
const location = useLocation();
const compact = useCompactNav();
const isListRoute = useIsCompactListRoute();
if (compact && isListRoute) {
return null;
}
return (
<MobileSwipeBackPanel>
<MobileSwipeGestureHost>
<div
key={location.pathname}
data-route-transition="true"
{...(!compact && { 'data-route-transition': 'true' })}
style={{
flex: 1,
minWidth: 0,
@@ -25,6 +35,6 @@ export function AnimatedOutlet() {
>
<Outlet />
</div>
</MobileSwipeBackPanel>
</MobileSwipeGestureHost>
);
}

View File

@@ -0,0 +1,79 @@
import { style } from '@vanilla-extract/css';
import { color, config, DefaultReset, toRem } from 'folds';
export const Editor = style([
DefaultReset,
{
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R400,
overflow: 'hidden',
selectors: {
// Keyboard-flush dock: only a top edge so the input can sit on the IME.
'[data-composer-flush="true"] &': {
borderRadius: 0,
boxShadow: `inset 0 ${config.borderWidth.B300} 0 0 ${color.SurfaceVariant.ContainerLine}`,
},
},
},
]);
export const EditorOptions = style([
DefaultReset,
{
padding: config.space.S200,
},
]);
export const EditorTextareaScroll = style({});
export const EditorTextarea = style([
DefaultReset,
{
flexGrow: 1,
height: '100%',
padding: `${toRem(13)} ${toRem(1)}`,
selectors: {
[`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13),
},
[`${EditorTextareaScroll}:last-child &`]: {
paddingRight: toRem(13),
},
'&:focus': {
outline: 'none',
},
},
},
]);
export const EditorPlaceholderContainer = style([
DefaultReset,
{
opacity: config.opacity.Placeholder,
pointerEvents: 'none',
userSelect: 'none',
},
]);
export const EditorPlaceholderTextVisual = style([
DefaultReset,
{
display: 'block',
paddingTop: toRem(13),
paddingLeft: toRem(1),
},
]);
export const EditorToolbarBase = style({
padding: `0 ${config.borderWidth.B300}`,
});
export const EditorToolbar = style({
padding: config.space.S100,
});
export const MarkdownBtnBox = style({
paddingRight: config.space.S100,
});

View File

@@ -1,222 +0,0 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useCompactNav } from '../../hooks/useCompactNav';
import { useBackRoute } from '../../hooks/useBackRoute';
import {
claimMobileGesture,
clearMobileGesture,
getActiveMobileGesture,
} from './mobileGestureArbitration';
import { useWindowPointerDrag } from './useWindowPointerDrag';
import * as css from './mobile-gestures.css';
const COMMIT_RATIO = 0.28;
const MIN_COMMIT_PX = 72;
const MAX_START_Y_RATIO = 0.88;
const DRAG_THRESHOLD = 8;
type DragState = {
pointerId: number;
startX: number;
startY: number;
moved: boolean;
offset: number;
};
type MobileSwipeBackPanelProps = {
children: ReactNode;
};
const readTransformOffset = (el: HTMLElement | null): number => {
if (!el) return 0;
const transform = window.getComputedStyle(el).transform;
if (!transform || transform === 'none') return 0;
return new DOMMatrix(transform).m41;
};
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 [animating, setAnimating] = useState(false);
const resetTransform = useCallback((animate = true) => {
const content = contentRef.current;
if (!content) return;
setAnimating(animate);
content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
content.style.transform = 'translateX(0px)';
if (dragRef.current) {
dragRef.current.offset = 0;
}
}, []);
const setTransform = useCallback((px: number, animate = false) => {
const content = contentRef.current;
if (!content) return;
if (dragRef.current) {
dragRef.current.offset = 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 releaseCapture = useCallback((pointerId: number) => {
const root = rootRef.current;
if (root?.hasPointerCapture(pointerId)) {
root.releasePointerCapture(pointerId);
}
}, []);
const endDrag = useCallback(
(pointerId: number) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== pointerId) return;
releaseCapture(pointerId);
dragRef.current = null;
clearMobileGesture(pointerId);
if (!drag.moved) {
resetTransform(false);
return;
}
const width = rootRef.current?.clientWidth ?? window.innerWidth;
const currentOffset = Math.max(
drag.offset,
readTransformOffset(contentRef.current)
);
const shouldCommit = currentOffset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX);
if (shouldCommit) {
commitBack();
return;
}
resetTransform(true);
},
[commitBack, releaseCapture, resetTransform]
);
const processPointerMove = useCallback(
(evt: { pointerId: number; clientX: number; clientY: number; preventDefault?: () => void }) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== evt.pointerId) return;
const activeGesture = getActiveMobileGesture(evt.pointerId);
if (activeGesture && activeGesture !== 'back') return;
const deltaX = evt.clientX - drag.startX;
const deltaY = evt.clientY - drag.startY;
if (!drag.moved) {
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
dragRef.current = null;
clearMobileGesture(evt.pointerId);
return;
}
if (deltaX <= 0) {
dragRef.current = null;
clearMobileGesture(evt.pointerId);
return;
}
if (!claimMobileGesture('back', evt.pointerId)) return;
drag.moved = true;
try {
rootRef.current?.setPointerCapture(evt.pointerId);
} catch {
// Ignore capture failures on Android WebView.
}
}
evt.preventDefault?.();
const width = rootRef.current?.clientWidth ?? window.innerWidth;
setTransform(Math.min(Math.max(deltaX, 0), width), false);
},
[setTransform]
);
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,
moved: false,
offset: 0,
};
},
[animating, enabled]
);
const isActivePointer = useCallback((pointerId: number) => {
return dragRef.current?.pointerId === pointerId;
}, []);
useWindowPointerDrag({
enabled,
isActivePointer,
onMove: processPointerMove,
onEnd: endDrag,
});
useEffect(() => {
resetTransform(false);
}, [enabled, resetTransform]);
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={rootRef}
className={css.SwipeBackRoot}
onPointerDown={handlePointerDown}
onPointerMove={processPointerMove}
onPointerUp={(evt) => endDrag(evt.pointerId)}
onPointerCancel={(evt) => endDrag(evt.pointerId)}
onLostPointerCapture={(evt) => endDrag(evt.pointerId)}
>
<div className={css.SwipeBackUnderlay} aria-hidden>
<div className={css.SwipeBackSidebarPeek} />
<div className={css.SwipeBackChannelPeek} />
</div>
<div ref={contentRef} className={css.SwipeBackContent}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,368 @@
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
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 { pathname } = useLocation();
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, pathname, resetGesture]);
if (!compact) {
return <>{children}</>;
}
return (
<div ref={rootRef} className={css.SwipeBackRoot}>
<div ref={contentRef} className={css.SwipeBackContent}>
{children}
</div>
</div>
);
}

View File

@@ -1,33 +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 { Editor } from 'slate';
import { useSetAtom } from 'jotai';
import { Icon, Icons } from '../icons';
import { useCompactNav } from '../../hooks/useCompactNav';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { startReplyToEvent } from '../../features/room/replyToMessage';
import {
claimMobileGesture,
clearMobileGesture,
getActiveMobileGesture,
} from './mobileGestureArbitration';
import { useWindowPointerDrag } from './useWindowPointerDrag';
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
import * as css from './mobile-gestures.css';
const SWIPE_THRESHOLD = 56;
const MAX_SWIPE = 88;
const DRAG_THRESHOLD = 8;
type DragState = {
pointerId: number;
startX: number;
startY: number;
messageEl: HTMLElement;
messageId: string;
moved: boolean;
offset: number;
};
type MobileSwipeToReplyLayerProps = {
room: Room;
editor: Editor;
@@ -36,183 +16,40 @@ type MobileSwipeToReplyLayerProps = {
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
const compact = useCompactNav();
const enabled = compact;
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const layerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
const [indicatorActive, setIndicatorActive] = useState(false);
const clearMessageTransform = useCallback((el?: HTMLElement | null, animate = true) => {
const target = el ?? dragRef.current?.messageEl;
if (!target) return;
target.style.transition = animate ? 'transform 0.18s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
target.style.transform = 'translateX(0px)';
}, []);
useLayoutEffect(() => {
if (!compact) return;
const releaseCapture = useCallback((pointerId: number) => {
const layer = layerRef.current;
if (layer?.hasPointerCapture(pointerId)) {
layer.releasePointerCapture(pointerId);
}
}, []);
mobileSwipeReplyBridgeRef.current = {
room,
editor,
setReplyDraft,
layerEl: layerRef.current,
setIndicator: (top, active) => {
setIndicatorTop(top);
setIndicatorActive(active);
},
};
const resetGesture = useCallback(
(pointerId?: number) => {
if (pointerId !== undefined) {
releaseCapture(pointerId);
clearMobileGesture(pointerId);
return () => {
if (mobileSwipeReplyBridgeRef.current?.room.roomId === room.roomId) {
mobileSwipeReplyBridgeRef.current = null;
}
clearMessageTransform();
dragRef.current = null;
setIndicatorTop(null);
setIndicatorActive(false);
},
[clearMessageTransform, releaseCapture]
);
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"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
)
);
};
}, [compact, editor, room, setReplyDraft]);
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 endDrag = useCallback(
(pointerId: number) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== pointerId) return;
releaseCapture(pointerId);
const { messageEl, messageId, moved, offset } = drag;
dragRef.current = null;
clearMobileGesture(pointerId);
if (!moved) {
resetGesture();
return;
}
const shouldReply = Math.abs(offset) >= SWIPE_THRESHOLD;
clearMessageTransform(messageEl, true);
setIndicatorTop(null);
setIndicatorActive(false);
if (shouldReply) {
startReplyToEvent(room, messageId, setReplyDraft, editor);
}
},
[clearMessageTransform, editor, releaseCapture, resetGesture, room, setReplyDraft]
);
const processPointerMove = useCallback(
(evt: { pointerId: number; clientX: number; clientY: number; preventDefault?: () => void }) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== evt.pointerId) return;
const activeGesture = getActiveMobileGesture(evt.pointerId);
if (activeGesture && activeGesture !== 'reply') return;
const deltaX = evt.clientX - drag.startX;
const deltaY = evt.clientY - drag.startY;
if (!drag.moved) {
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
resetGesture(evt.pointerId);
return;
}
if (deltaX >= 0) {
resetGesture(evt.pointerId);
return;
}
if (!claimMobileGesture('reply', evt.pointerId)) return;
drag.moved = true;
try {
layerRef.current?.setPointerCapture(evt.pointerId);
} catch {
// Ignore capture failures on Android WebView.
}
}
evt.preventDefault?.();
const offset = Math.max(deltaX, -MAX_SWIPE);
drag.offset = offset;
drag.messageEl.style.transition = 'none';
drag.messageEl.style.transform = `translateX(${offset}px)`;
updateIndicator(drag.messageEl, offset);
},
[resetGesture, updateIndicator]
);
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,
offset: 0,
};
},
[enabled]
);
const isActivePointer = useCallback((pointerId: number) => {
return dragRef.current?.pointerId === pointerId;
}, []);
useWindowPointerDrag({
enabled,
isActivePointer,
onMove: processPointerMove,
onEnd: endDrag,
});
useEffect(() => {
resetGesture();
}, [room.roomId, resetGesture]);
if (!enabled) {
if (!compact) {
return <>{children}</>;
}
return (
<div
ref={layerRef}
className={css.SwipeToReplyLayer}
onPointerDown={handlePointerDown}
onPointerMove={processPointerMove}
onPointerUp={(evt) => endDrag(evt.pointerId)}
onPointerCancel={(evt) => endDrag(evt.pointerId)}
onLostPointerCapture={(evt) => endDrag(evt.pointerId)}
>
<div ref={layerRef} className={css.SwipeToReplyLayer}>
{children}
{indicatorTop !== null && (
<div

View File

@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const SwipeBackRoot = style({
@@ -9,41 +9,58 @@ export const SwipeBackRoot = style({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
touchAction: 'pan-y',
});
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({
position: 'relative',
zIndex: 1,
flex: 1,
minWidth: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
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',
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,
// Keep underlay tappable if the detail layer is empty/transparent
// (e.g. space index after leaving DMs). Opaque detail content still
// sits above and receives taps first.
pointerEvents: 'auto',
});
export const CompactDetailLayer = style({
position: 'relative',
zIndex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
// Pass through taps when outlet content is null/empty; real pages
// re-enable pointer events on their root via the child rule below.
pointerEvents: 'none',
});
globalStyle(`${CompactDetailLayer} > *`, {
pointerEvents: 'auto',
flex: 1,
minHeight: 0,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Background.Container,
});
export const SwipeToReplyLayer = style({
@@ -52,7 +69,6 @@ export const SwipeToReplyLayer = style({
minHeight: 0,
display: 'flex',
flexDirection: 'column',
touchAction: 'pan-y',
});
export const SwipeToReplyIndicator = style({

View File

@@ -1,27 +0,0 @@
export type MobileGestureKind = 'back' | 'reply';
type ActiveGesture = {
kind: MobileGestureKind;
pointerId: number;
};
let activeGesture: ActiveGesture | null = null;
export const claimMobileGesture = (kind: MobileGestureKind, pointerId: number): boolean => {
if (!activeGesture || activeGesture.pointerId === pointerId) {
activeGesture = { kind, pointerId };
return true;
}
return activeGesture.kind === kind && activeGesture.pointerId === pointerId;
};
export const getActiveMobileGesture = (pointerId: number): MobileGestureKind | null => {
if (!activeGesture || activeGesture.pointerId !== pointerId) return null;
return activeGesture.kind;
};
export const clearMobileGesture = (pointerId: number) => {
if (activeGesture?.pointerId === pointerId) {
activeGesture = null;
}
};

View 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,
};

View File

@@ -1,46 +0,0 @@
import { useEffect } from 'react';
type PointerLikeEvent = {
pointerId: number;
clientX: number;
clientY: number;
preventDefault?: () => void;
};
type UseWindowPointerDragOptions = {
enabled: boolean;
isActivePointer: (pointerId: number) => boolean;
onMove: (evt: PointerLikeEvent) => void;
onEnd: (pointerId: number) => void;
};
export const useWindowPointerDrag = ({
enabled,
isActivePointer,
onMove,
onEnd,
}: UseWindowPointerDragOptions) => {
useEffect(() => {
if (!enabled) return;
const handlePointerMove = (evt: PointerEvent) => {
if (!isActivePointer(evt.pointerId)) return;
onMove(evt);
};
const handlePointerEnd = (evt: PointerEvent) => {
if (!isActivePointer(evt.pointerId)) return;
onEnd(evt.pointerId);
};
window.addEventListener('pointermove', handlePointerMove, true);
window.addEventListener('pointerup', handlePointerEnd, true);
window.addEventListener('pointercancel', handlePointerEnd, true);
return () => {
window.removeEventListener('pointermove', handlePointerMove, true);
window.removeEventListener('pointerup', handlePointerEnd, true);
window.removeEventListener('pointercancel', handlePointerEnd, true);
};
}, [enabled, isActivePointer, onEnd, onMove]);
};

View File

@@ -0,0 +1,206 @@
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;
// Master list routes: only the channel/space list is interactive.
// Never mount the detail/underlay stack here — an empty detail layer would
// sit above the list with pointer-events and freeze taps (DM → space bug).
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} />
));

View File

@@ -14,7 +14,7 @@ import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { RoomViewFollowing } from './RoomViewFollowing';
import { Page } from '../../components/page';
import { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown';
@@ -26,6 +26,8 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { ThreadView } from './ThreadView';
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
import * as composerCss from './room-composer-mobile.css';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@@ -63,6 +65,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { isLandscape, keyboardOpen } = useMobileKeyboardLayout();
const { roomId } = room;
const editor = useEditor();
@@ -101,9 +104,13 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
)
);
const flushComposer = keyboardOpen || isLandscape;
const composerPadding = flushComposer ? '0' : `0 ${config.space.S400}`;
const showHeader = !keyboardOpen;
return (
<Page ref={roomViewRef}>
<RoomViewHeader />
{showHeader && <RoomViewHeader />}
{activeThreadId ? (
<ThreadView room={room} threadRootId={activeThreadId} />
) : (
@@ -120,8 +127,21 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
<RoomViewTyping room={room} />
</Box>
</MobileSwipeToReplyLayer>
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
<div style={{ padding: `0 ${config.space.S400}` }}>
<Box
shrink="No"
direction="Column"
data-disable-swipe-back="true"
data-composer-flush={flushComposer ? 'true' : undefined}
className={composerCss.ComposerDock}
>
{!hideActivity && (
<div className={composerCss.FollowingFloat}>
<div className={composerCss.FollowingFloatHit}>
<RoomViewFollowing room={room} />
</div>
</div>
)}
<div style={{ padding: composerPadding }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
@@ -151,7 +171,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
</>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box>
</>
)}

View File

@@ -0,0 +1,41 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config } from 'folds';
export const RoomViewFollowingPlaceholder = style([
DefaultReset,
{
display: 'none',
height: 0,
},
]);
export const RoomViewFollowing = recipe({
base: [
DefaultReset,
{
minHeight: 0,
padding: `${config.space.S100} ${config.space.S200}`,
width: 'auto',
maxWidth: '100%',
backgroundColor: 'transparent',
color: color.Surface.OnContainer,
outline: 'none',
},
],
variants: {
clickable: {
true: {
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
color: color.Primary.Main,
},
'&:active': {
color: color.Primary.Main,
},
},
},
},
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config } from 'folds';
export const ComposerDock = style({
position: 'relative',
});
/** Read receipts float above the input so they don't reserve layout height. */
export const FollowingFloat = style({
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
zIndex: 2,
display: 'flex',
justifyContent: 'flex-end',
pointerEvents: 'none',
padding: `0 ${config.space.S200}`,
});
export const FollowingFloatHit = style({
pointerEvents: 'auto',
maxWidth: '100%',
});
export const RoomViewFollowingPlaceholder = style([
DefaultReset,
{
display: 'none',
height: 0,
},
]);
export const RoomViewFollowing = recipe({
base: [
DefaultReset,
{
minHeight: 0,
padding: `${config.space.S100} ${config.space.S200}`,
width: 'auto',
maxWidth: '100%',
backgroundColor: 'transparent',
color: color.Surface.OnContainer,
outline: 'none',
},
],
variants: {
clickable: {
true: {
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
color: color.Primary.Main,
},
'&:active': {
color: color.Primary.Main,
},
},
},
},
},
});

View File

@@ -0,0 +1,30 @@
import { matchPath, useLocation } from 'react-router-dom';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
import { ScreenSize, useScreenSizeContext } from './useScreenSize';
/** Skinny window layout (≤750px): master-detail instead of 3-column desktop. */
export const useCompactNav = (): boolean => {
const screenSize = useScreenSizeContext();
return screenSize === ScreenSize.Mobile;
};
const LIST_ROUTE_PATHS = [HOME_PATH, DIRECT_PATH, SPACE_PATH, EXPLORE_PATH, INBOX_PATH] as const;
/**
* Routes where sidebar + channel list are shown together (no room/content pane).
* Uses matchPath on the current pathname so trailing-slash / encoding quirks
* after DM → space navigations still count as master list routes.
*/
export const useIsCompactListRoute = (): boolean => {
const { pathname } = useLocation();
return LIST_ROUTE_PATHS.some(
(path) => matchPath({ path, caseSensitive: true, end: true }, pathname) != null
);
};
export const useShowCompactMasterView = (): boolean => {
const compact = useCompactNav();
const isListRoute = useIsCompactListRoute();
return compact && isListRoute;
};

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
const KEYBOARD_HEIGHT_THRESHOLD_PX = 120;
const getIsLandscape = (): boolean => {
if (typeof window === 'undefined') return false;
if (window.matchMedia?.('(orientation: landscape)').matches) return true;
return window.innerWidth > window.innerHeight;
};
const getKeyboardOpen = (): boolean => {
if (typeof window === 'undefined') return false;
const viewport = window.visualViewport;
if (viewport) {
const obscured = window.innerHeight - viewport.height - viewport.offsetTop;
if (obscured > KEYBOARD_HEIGHT_THRESHOLD_PX) return true;
}
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return false;
if (active.isContentEditable) return true;
const tag = active.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA';
};
export type MobileKeyboardLayout = {
isLandscape: boolean;
keyboardOpen: boolean;
};
/**
* Landscape + soft-keyboard layout signals for compact room chrome.
* Uses visualViewport when available (Capacitor/Android WebView).
*/
export const useMobileKeyboardLayout = (): MobileKeyboardLayout => {
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen);
useEffect(() => {
const sync = () => {
setIsLandscape(getIsLandscape());
setKeyboardOpen(getKeyboardOpen());
};
sync();
const orientationQuery = window.matchMedia?.('(orientation: landscape)');
orientationQuery?.addEventListener?.('change', sync);
window.addEventListener('resize', sync);
window.visualViewport?.addEventListener('resize', sync);
window.visualViewport?.addEventListener('scroll', sync);
window.addEventListener('focusin', sync);
window.addEventListener('focusout', sync);
return () => {
orientationQuery?.removeEventListener?.('change', sync);
window.removeEventListener('resize', sync);
window.visualViewport?.removeEventListener('resize', sync);
window.visualViewport?.removeEventListener('scroll', sync);
window.removeEventListener('focusin', sync);
window.removeEventListener('focusout', sync);
};
}, []);
return { isLandscape, keyboardOpen };
};

View File

@@ -0,0 +1,57 @@
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate, NavigateOptions } from 'react-router-dom';
import { supportsViewTransitions, withViewTransition } from '../utils/viewTransitions';
export const useViewTransitions = () => {
const location = useLocation();
useEffect(() => {
if (!supportsViewTransitions()) {
return;
}
document.documentElement.classList.add('view-transitions-enabled');
return () => {
document.documentElement.classList.remove('view-transitions-enabled');
};
}, []);
useEffect(() => {
// Route changed
}, [location]);
return {
supportsViewTransitions: supportsViewTransitions(),
};
};
/**
* Navigate helper. On Capacitor / coarse-pointer, View Transitions are disabled
* so DM ↔ space switches cannot leave a stuck hit-blocking overlay.
*/
export const useNavigateWithTransition = () => {
const navigate = useNavigate();
return useCallback(
(to: string | number, options?: NavigateOptions) => {
if (!supportsViewTransitions()) {
if (typeof to === 'number') {
navigate(to);
} else {
navigate(to, options);
}
return;
}
withViewTransition(() => {
if (typeof to === 'number') {
navigate(to);
} else {
navigate(to, options);
}
});
},
[navigate]
);
};

View 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;
}

View File

@@ -0,0 +1,21 @@
import React, { ReactNode } from 'react';
import { Box } from 'folds';
import { DockedCallPanel } from '../../features/call/DockedCallPanel';
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
type ClientLayoutProps = {
nav: ReactNode;
children: ReactNode;
};
export function ClientLayout({ nav, children }: ClientLayoutProps) {
const { keyboardOpen } = useMobileKeyboardLayout();
return (
<Box grow="Yes">
{!keyboardOpen && <Box shrink="No">{nav}</Box>}
<Box grow="Yes">{children}</Box>
<DockedCallPanel />
</Box>
);
}

View File

@@ -0,0 +1,81 @@
import { isCapacitorNative } from './tauri';
/**
* View Transitions can leave an invisible hit-blocking overlay on Android
* WebView after DM ↔ space navigations. Keep them off for native/touch shells.
*/
const shouldDisableViewTransitions = (): boolean => {
if (typeof window === 'undefined') return true;
if (isCapacitorNative()) return true;
if (window.matchMedia?.('(pointer: coarse)').matches) return true;
return false;
};
export const supportsViewTransitions = (): boolean => {
if (shouldDisableViewTransitions()) return false;
return 'startViewTransition' in document;
};
export const withViewTransition = async (callback: () => void | Promise<void>): Promise<void> => {
if (!supportsViewTransitions()) {
await callback();
return;
}
const doc = document as Document & {
startViewTransition: (callback: () => void | Promise<void>) => {
finished: Promise<void>;
updateCallbackDone: Promise<void>;
ready: Promise<void>;
};
};
try {
const transition = doc.startViewTransition(async () => {
await callback();
});
await transition.finished;
} catch {
await callback();
}
};
/**
* Global link interception for View Transitions. No-op on Capacitor / touch.
* Also fights hash-router navigations — leave disabled on mobile.
*/
export const enableViewTransitionsForNavigation = (): void => {
if (!supportsViewTransitions()) {
return;
}
const handleLinkClick = (event: MouseEvent) => {
const target = (event.target as HTMLElement).closest('a');
if (!target) return;
if (target.target === '_blank') return;
if (target.origin !== window.location.origin) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
const url = new URL(target.href);
if (url.origin === window.location.origin) {
event.preventDefault();
withViewTransition(() => {
window.history.pushState({}, '', target.href);
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
};
const handlePopState = () => {
withViewTransition(() => {
// URL already changed
});
};
window.addEventListener('click', handleLinkClick);
window.addEventListener('popstate', handlePopState);
};

View File

@@ -184,6 +184,23 @@ body.mocha-theme {
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 [data-route-transition="true"] {
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
@@ -504,3 +521,53 @@ html.android-capacitor body {
word-spacing: 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;
}

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.115",
"version": "4.11.121",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"