Compare commits

...

19 Commits

Author SHA1 Message Date
GitHub Actions
b260f741d5 chore: bump version to 4.11.124 [skip ci] 2026-07-11 11:41:37 +00:00
7655759bbc 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 4m51s
Build / create-release (push) Successful in 14s
2026-07-11 21:41:27 +10:00
d30b98c987 chore: mark subproject as dirty 2026-07-11 21:41:11 +10:00
GitHub Actions
70d6f6ff42 chore: bump version to 4.11.123 [skip ci] 2026-07-11 11:24:56 +00:00
97f0a15ae2 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 4m50s
Build / create-release (push) Successful in 14s
2026-07-11 21:24:47 +10:00
4cf19131bf chore: mark subproject as dirty and update package-lock.json with new version and dependencies 2026-07-11 21:24:39 +10:00
GitHub Actions
83137c92a9 chore: bump version to 4.11.122 [skip ci] 2026-07-11 11:18:23 +00:00
426ddf5650 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 7s
Build / build-android (push) Failing after 1m0s
Build / create-release (push) Has been skipped
2026-07-11 21:18:12 +10:00
7800e7ced7 refactor: implement immersive mode for mobile keyboard layout and update related styles 2026-07-11 21:18:06 +10:00
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
18 changed files with 694 additions and 16 deletions

View File

@@ -1,3 +1,5 @@
{
"dependencies": {}
"dependencies": {
"@capacitor/status-bar": "8.0.1"
}
}

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

@@ -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,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';
@@ -93,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;
@@ -350,7 +352,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
useEffect(() => {
resetGesture();
}, [canGoBack, resetGesture]);
}, [canGoBack, pathname, resetGesture]);
if (!compact) {
return <>{children}</>;

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({
@@ -36,7 +36,10 @@ export const CompactNavUnderlay = style({
display: 'flex',
flexDirection: 'column',
zIndex: 0,
pointerEvents: 'none',
// 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({
@@ -45,6 +48,19 @@ export const CompactDetailLayer = style({
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({

View File

@@ -18,6 +18,9 @@ export function PageRoot({ nav, children }: PageRootProps) {
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' })}>

View File

@@ -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 { immersive } = useMobileKeyboardLayout();
const { roomId } = room;
const editor = useEditor();
@@ -101,9 +104,13 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
)
);
const flushComposer = immersive;
const composerPadding = flushComposer ? '0' : `0 ${config.space.S400}`;
const showHeader = !immersive;
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}
>
{immersive && !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,12 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
</>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
{!immersive &&
(hideActivity ? (
<RoomViewFollowingPlaceholder />
) : (
<RoomViewFollowing room={room} />
))}
</Box>
</>
)}

View File

@@ -0,0 +1,49 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const RoomViewFollowingPlaceholder = style([
DefaultReset,
{
height: toRem(28),
},
]);
export const RoomViewFollowing = recipe({
base: [
DefaultReset,
{
minHeight: toRem(28),
padding: `0 ${config.space.S400}`,
width: '100%',
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
outline: 'none',
selectors: {
// Compact floating chip while the keyboard is open.
'[data-composer-flush="true"] &': {
minHeight: 0,
width: 'auto',
maxWidth: '100%',
padding: `${config.space.S100} ${config.space.S200}`,
backgroundColor: 'transparent',
},
},
},
],
variants: {
clickable: {
true: {
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
color: color.Primary.Main,
},
'&:active': {
color: color.Primary.Main,
},
},
},
},
},
});

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,79 @@
import { useCallback, ClipboardEventHandler } from 'react';
import { getDataTransferFiles } from '../utils/dom';
import { readClipboardImage, isTauri, isLinux } from '../utils/tauri';
const filesFromClipboardItems = (data: DataTransfer | null): File[] => {
if (!data?.items) return [];
const files: File[] = [];
for (let i = 0; i < data.items.length; i += 1) {
const item = data.items[i];
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
};
const filesFromHtmlImage = (data: DataTransfer | null): File[] => {
const html = data?.getData('text/html');
if (!html) return [];
const match = html.match(/<img[^>]+src=["'](data:image\/[a-zA-Z+]+;base64,[^"']+)["']/i);
if (!match?.[1]) return [];
try {
const dataUrl = match[1];
const [meta, base64] = dataUrl.split(',');
const mime = meta.match(/data:(image\/[a-zA-Z+]+);base64/i)?.[1] ?? 'image/png';
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const ext = mime.split('/')[1]?.replace('+xml', '') || 'png';
return [new File([bytes], `clipboard-image.${ext}`, { type: mime })];
} catch {
return [];
}
};
const getClipboardFiles = (data: DataTransfer | null): File[] => {
if (!data) return [];
const fromFiles = getDataTransferFiles(data);
if (fromFiles && fromFiles.length > 0) return fromFiles;
const fromItems = filesFromClipboardItems(data);
if (fromItems.length > 0) return fromItems;
return filesFromHtmlImage(data);
};
/**
* Paste handler for RoomInput. Supports clipboard images via files, DataTransferItem
* list, and HTML data-URL fallbacks (common on mobile WebViews).
*/
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
useCallback(
async (evt) => {
const files = getClipboardFiles(evt.clipboardData);
if (files.length > 0) {
evt.preventDefault();
evt.stopPropagation();
onPaste(files);
return;
}
// On Linux with Tauri, browser clipboard API doesn't work for images.
if (isTauri() && isLinux()) {
const clipboardImage = await readClipboardImage();
if (clipboardImage) {
evt.preventDefault();
evt.stopPropagation();
onPaste([clipboardImage]);
}
}
},
[onPaste]
);

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { isCapacitorNative } from '../utils/tauri';
const KEYBOARD_HEIGHT_THRESHOLD_PX = 120;
const IMMERSIVE_CLASS = 'immersive-landscape-keyboard';
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';
};
const setNativeStatusBarHidden = async (hidden: boolean) => {
if (!isCapacitorNative()) return;
try {
const { StatusBar } = await import('@capacitor/status-bar');
if (hidden) {
await StatusBar.hide();
} else {
await StatusBar.show();
}
} catch {
// Plugin may be missing in some builds; CSS safe-area collapse still applies.
}
};
export type MobileKeyboardLayout = {
isLandscape: boolean;
keyboardOpen: boolean;
/** Landscape + keyboard: compact immersive room chrome. */
immersive: 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);
const immersive = isLandscape && keyboardOpen;
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);
};
}, []);
useEffect(() => {
document.documentElement.classList.toggle(IMMERSIVE_CLASS, immersive);
void setNativeStatusBarHidden(immersive);
return () => {
document.documentElement.classList.remove(IMMERSIVE_CLASS);
void setNativeStatusBarHidden(false);
};
}, [immersive]);
return { isLandscape, keyboardOpen, immersive };
};

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,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 { immersive } = useMobileKeyboardLayout();
return (
<Box grow="Yes">
{!immersive && <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

@@ -97,6 +97,11 @@ body {
font-variant-ligatures: no-contextual;
}
/* Landscape + keyboard: reclaim status-bar inset while chrome is hidden. */
html.immersive-landscape-keyboard {
--safe-area-inset-top: 0px;
}
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
@@ -521,3 +526,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;
}

16
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{
"name": "paarrot",
"version": "4.11.77",
"version": "4.11.121",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paarrot",
"version": "4.11.77",
"version": "4.11.121",
"license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2"
"@capacitor/local-notifications": "8.0.2",
"@capacitor/status-bar": "8.0.1"
},
"devDependencies": {
"node-fetch": "3.3.2",
@@ -82,6 +83,15 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/status-bar": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.117",
"version": "4.11.124",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"
@@ -25,7 +25,8 @@
"@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2"
"@capacitor/local-notifications": "8.0.2",
"@capacitor/status-bar": "8.0.1"
},
"devDependencies": {
"node-fetch": "3.3.2",