Compare commits

...

10 Commits

Author SHA1 Message Date
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
14 changed files with 2018 additions and 40 deletions

View File

@@ -1,15 +1,23 @@
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useCompactNav } from '../hooks/useCompactNav';
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 (
<MobileSwipeGestureHost>

View File

@@ -1,4 +1,5 @@
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';
@@ -22,6 +23,7 @@ type GestureState = {
startX: number;
startY: number;
messageEl: HTMLElement | null;
transformEl: HTMLElement | null;
messageId: string | null;
edgeBack: boolean;
offset: number;
@@ -33,6 +35,7 @@ const IDLE_STATE: GestureState = {
startX: 0,
startY: 0,
messageEl: null,
transformEl: null,
messageId: null,
edgeBack: false,
offset: 0,
@@ -65,6 +68,12 @@ const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
return target.closest('[data-message-id]') as HTMLElement | null;
};
const findReplyTransformTarget = (messageEl: HTMLElement): HTMLElement => {
const content = messageEl.querySelector('[data-swipe-reply-content]');
if (content instanceof HTMLElement) return content;
return messageEl;
};
const applyBackOffset = (dx: number, width: number): number => {
const positive = Math.max(0, dx);
if (positive <= width) return positive;
@@ -85,6 +94,7 @@ const resetElementTransform = (el: HTMLElement | null) => {
export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) {
const compact = useCompactNav();
const { pathname } = useLocation();
const { canGoBack, goBack } = useBackRoute();
const backEnabled = compact && canGoBack;
const replyEnabled = compact;
@@ -123,18 +133,18 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
document.documentElement.classList.remove('mobile-gesture-lock');
}, []);
const resetReplyTransform = useCallback((messageEl?: HTMLElement | null) => {
const resetReplyTransform = useCallback((transformEl?: HTMLElement | null) => {
const bridge = mobileSwipeReplyBridgeRef.current;
const el = messageEl ?? gestureRef.current.messageEl;
const el = transformEl ?? gestureRef.current.transformEl;
resetElementTransform(el);
bridge?.setIndicator(null, false);
document.documentElement.classList.remove('mobile-gesture-lock');
}, []);
const resetGesture = useCallback(
(messageEl?: HTMLElement | null) => {
(transformEl?: HTMLElement | null) => {
resetBackTransform();
resetReplyTransform(messageEl);
resetReplyTransform(transformEl);
unlockScroll();
gestureRef.current = { ...IDLE_STATE };
},
@@ -150,6 +160,14 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
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;
@@ -183,9 +201,9 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
const bridge = mobileSwipeReplyBridgeRef.current;
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
const { messageEl, messageId } = gesture;
const { messageEl, messageId, transformEl } = gesture;
resetGesture(messageEl);
resetGesture(transformEl);
if (shouldReply && bridge) {
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
@@ -232,6 +250,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
startX: touch.clientX,
startY: touch.clientY,
messageEl: null,
transformEl: null,
messageId: null,
edgeBack: true,
offset: 0,
@@ -246,6 +265,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
startX: touch.clientX,
startY: touch.clientY,
messageEl: canStartReply ? messageEl : null,
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
messageId: canStartReply ? messageId : null,
edgeBack: false,
offset: 0,
@@ -277,7 +297,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
if (deltaX > 0 && backEnabled) {
lockGesture('back', contentRef.current);
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId) {
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId && gesture.transformEl) {
lockGesture('reply', gesture.messageEl);
} else {
resetGesture();
@@ -296,14 +316,13 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
return;
}
if (gesture.phase === 'reply' && gesture.messageEl) {
if (gesture.phase === 'reply' && gesture.transformEl && gesture.messageEl) {
if (!evt.cancelable) return;
evt.preventDefault();
const offset = Math.max(deltaX, -REPLY_MAX);
gesture.offset = offset;
setElementTransform(gesture.messageEl, offset);
updateReplyIndicator(gesture.messageEl, offset);
applyReplyTransform(gesture.transformEl, gesture.messageEl, offset);
}
};
@@ -329,26 +348,18 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
resetGesture();
};
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, updateReplyIndicator]);
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, applyReplyTransform]);
useEffect(() => {
resetGesture();
}, [canGoBack, resetGesture]);
}, [canGoBack, pathname, resetGesture]);
if (!compact) {
return <>{children}</>;
}
const showBackChrome = backEnabled;
return (
<div ref={rootRef} className={css.SwipeBackRoot}>
{showBackChrome && (
<div className={css.SwipeBackUnderlay} aria-hidden>
<div className={css.SwipeBackSidebarPeek} />
<div className={css.SwipeBackChannelPeek} />
</div>
)}
<div ref={contentRef} className={css.SwipeBackContent}>
{children}
</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({
@@ -13,36 +13,53 @@ export const SwipeBackRoot = style({
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 SwipeBackUnderlay = style({
export const CompactDetailRoot = style({
position: 'relative',
minWidth: 0,
overflow: 'hidden',
});
export const CompactNavUnderlay = style({
position: 'absolute',
inset: 0,
display: 'flex',
pointerEvents: 'none',
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 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,
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,
});

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

@@ -26,6 +26,7 @@ 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';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@@ -63,6 +64,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 +103,12 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
)
);
const composerPadding = isLandscape ? '0' : `0 ${config.space.S400}`;
const showHeader = !keyboardOpen;
return (
<Page ref={roomViewRef}>
<RoomViewHeader />
{showHeader && <RoomViewHeader />}
{activeThreadId ? (
<ThreadView room={room} threadRootId={activeThreadId} />
) : (
@@ -121,7 +126,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
</Box>
</MobileSwipeToReplyLayer>
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
<div style={{ padding: `0 ${config.space.S400}` }}>
<div style={{ padding: composerPadding }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}

File diff suppressed because it is too large Load Diff

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

@@ -521,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.116",
"version": "4.11.120",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"