Compare commits

..

8 Commits

Author SHA1 Message Date
GitHub Actions
8753ed2600 chore: bump version to 4.11.114 [skip ci] 2026-07-09 07:23:56 +00:00
cced6b8ab9 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 4m41s
Build / create-release (push) Successful in 15s
2026-07-09 17:23:47 +10:00
82effc9680 chore: update cinny submodule to latest commit for improved navigation and routing 2026-07-09 17:23:45 +10:00
GitHub Actions
deefb40277 chore: bump version to 4.11.113 [skip ci] 2026-07-09 06:56:00 +00:00
b496c70190 chore: bump cinny submodule to 9c7f7189 (compact nav and back routing)
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m33s
Build / create-release (push) Successful in 16s
2026-07-09 16:55:52 +10:00
GitHub Actions
5a4384ea1b chore: bump version to 4.11.112 [skip ci] 2026-07-08 17:41:01 +00:00
cc974414f8 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 4m12s
Build / create-release (push) Successful in 14s
2026-07-09 03:40:53 +10:00
3245f41073 refactor: remove Android typography fix and associated styles from ClientNonUIFeatures 2026-07-09 03:40:51 +10:00
14 changed files with 1346 additions and 40 deletions

2
cinny

Submodule cinny updated: b4258278d8...9c7f71896c

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
/**
* Wrapper for Outlet that adds route-based animation
* Forces remount on route change by using location as key
*/
export function AnimatedOutlet() {
const location = useLocation();
return (
<MobileSwipeBackPanel>
<div
key={location.pathname}
data-route-transition="true"
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Outlet />
</div>
</MobileSwipeBackPanel>
);
}

View File

@@ -0,0 +1,180 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useCompactNav } from '../../hooks/useCompactNav';
import { useBackRoute } from '../../hooks/useBackRoute';
import * as css from './mobile-gestures.css';
const COMMIT_RATIO = 0.28;
const MIN_COMMIT_PX = 72;
const MAX_START_Y_RATIO = 0.88;
type DragState = {
pointerId: number;
startX: number;
startY: number;
dragging: boolean;
moved: boolean;
};
type MobileSwipeBackPanelProps = {
children: ReactNode;
};
export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) {
const compact = useCompactNav();
const { canGoBack, goBack } = useBackRoute();
const enabled = compact && canGoBack;
const rootRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const [offset, setOffset] = useState(0);
const [animating, setAnimating] = useState(false);
const resetTransform = useCallback((animate = true) => {
const content = contentRef.current;
if (!content) return;
setAnimating(animate);
setOffset(0);
content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
content.style.transform = 'translateX(0px)';
}, []);
const setTransform = useCallback((px: number, animate = false) => {
const content = contentRef.current;
if (!content) return;
setOffset(px);
content.style.transition = animate
? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)'
: 'none';
content.style.transform = `translateX(${px}px)`;
}, []);
const commitBack = useCallback(() => {
const width = rootRef.current?.clientWidth ?? window.innerWidth;
setAnimating(true);
setTransform(width, true);
window.setTimeout(() => {
goBack();
resetTransform(false);
setAnimating(false);
}, 180);
}, [goBack, resetTransform, setTransform]);
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
)
);
};
const handlePointerDown = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
if (!enabled || animating || evt.button !== 0 || !evt.isPrimary) return;
if (shouldIgnoreTarget(evt.target)) return;
if (evt.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
dragRef.current = {
pointerId: evt.pointerId,
startX: evt.clientX,
startY: evt.clientY,
dragging: true,
moved: false,
};
rootRef.current?.setPointerCapture(evt.pointerId);
},
[animating, enabled]
);
const handlePointerMove = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || !drag.dragging || drag.pointerId !== evt.pointerId) return;
const deltaX = evt.clientX - drag.startX;
const deltaY = evt.clientY - drag.startY;
if (!drag.moved) {
if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
dragRef.current = null;
return;
}
if (deltaX <= 0) {
dragRef.current = null;
return;
}
drag.moved = true;
}
evt.preventDefault();
const width = rootRef.current?.clientWidth ?? window.innerWidth;
setTransform(Math.min(Math.max(deltaX, 0), width), false);
},
[setTransform]
);
const endDrag = useCallback(
(pointerId: number) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== pointerId) return;
if (rootRef.current?.hasPointerCapture(pointerId)) {
rootRef.current.releasePointerCapture(pointerId);
}
dragRef.current = null;
if (!drag.moved) {
resetTransform(false);
return;
}
const width = rootRef.current?.clientWidth ?? window.innerWidth;
const shouldCommit = offset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX);
if (shouldCommit) {
commitBack();
return;
}
resetTransform(true);
},
[commitBack, offset, resetTransform]
);
const handlePointerUp = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
endDrag(evt.pointerId);
},
[endDrag]
);
useEffect(() => {
resetTransform(false);
}, [enabled, resetTransform]);
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={rootRef}
className={css.SwipeBackRoot}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div className={css.SwipeBackUnderlay} aria-hidden>
<div className={css.SwipeBackSidebarPeek} />
<div className={css.SwipeBackChannelPeek} />
</div>
<div ref={contentRef} className={css.SwipeBackContent}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
import React, { ReactNode, useCallback, useEffect, 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 * as css from './mobile-gestures.css';
const SWIPE_THRESHOLD = 56;
const MAX_SWIPE = 88;
type DragState = {
pointerId: number;
startX: number;
startY: number;
messageEl: HTMLElement;
messageId: string;
moved: boolean;
};
type MobileSwipeToReplyLayerProps = {
room: Room;
editor: Editor;
children: ReactNode;
};
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)';
}, []);
const resetGesture = useCallback(() => {
clearMessageTransform();
dragRef.current = null;
setIndicatorTop(null);
setIndicatorActive(false);
}, [clearMessageTransform]);
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
if (
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-disable-swipe-reply="true"]'
)
) {
return true;
}
let el: Element | null = target;
while (el && layerRef.current?.contains(el)) {
if (el instanceof HTMLElement) {
const { overflowX } = window.getComputedStyle(el);
if (
(overflowX === 'auto' || overflowX === 'scroll') &&
el.scrollWidth > el.clientWidth + 8
) {
return true;
}
}
el = el.parentElement;
}
return false;
};
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
if (!(target instanceof Element)) return null;
return target.closest('[data-message-id]') as HTMLElement | null;
};
const updateIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
const layer = layerRef.current;
if (!layer) return;
const messageRect = messageEl.getBoundingClientRect();
const layerRect = layer.getBoundingClientRect();
setIndicatorTop(messageRect.top - layerRect.top + messageRect.height / 2 - 18);
setIndicatorActive(Math.abs(offset) >= SWIPE_THRESHOLD * 0.65);
}, []);
const handlePointerDown = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
if (!enabled || evt.button !== 0 || !evt.isPrimary) return;
if (shouldIgnoreTarget(evt.target)) return;
const messageEl = findMessageElement(evt.target);
const messageId = messageEl?.getAttribute('data-message-id');
if (!messageEl || !messageId) return;
dragRef.current = {
pointerId: evt.pointerId,
startX: evt.clientX,
startY: evt.clientY,
messageEl,
messageId,
moved: false,
};
layerRef.current?.setPointerCapture(evt.pointerId);
},
[enabled]
);
const handlePointerMove = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== evt.pointerId) return;
const deltaX = evt.clientX - drag.startX;
const deltaY = evt.clientY - drag.startY;
if (!drag.moved) {
if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
resetGesture();
return;
}
if (deltaX >= 0) {
resetGesture();
return;
}
drag.moved = true;
}
evt.preventDefault();
const offset = Math.max(deltaX, -MAX_SWIPE);
drag.messageEl.style.transition = 'none';
drag.messageEl.style.transform = `translateX(${offset}px)`;
updateIndicator(drag.messageEl, offset);
},
[resetGesture, updateIndicator]
);
const endDrag = useCallback(
(pointerId: number) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== pointerId) return;
if (layerRef.current?.hasPointerCapture(pointerId)) {
layerRef.current.releasePointerCapture(pointerId);
}
const { messageEl, messageId, moved } = drag;
dragRef.current = null;
if (!moved) {
resetGesture();
return;
}
const matrix = window.getComputedStyle(messageEl).transform;
const offset =
matrix && matrix !== 'none'
? Number(new DOMMatrix(matrix).m41)
: 0;
const shouldReply = Math.abs(offset) >= SWIPE_THRESHOLD;
clearMessageTransform(messageEl, true);
setIndicatorTop(null);
setIndicatorActive(false);
if (shouldReply) {
startReplyToEvent(room, messageId, setReplyDraft, editor);
}
},
[clearMessageTransform, editor, resetGesture, room, setReplyDraft]
);
const handlePointerUp = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
endDrag(evt.pointerId);
},
[endDrag]
);
useEffect(() => resetGesture, [room.roomId, resetGesture]);
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={layerRef}
className={css.SwipeToReplyLayer}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{children}
{indicatorTop !== null && (
<div
className={`${css.SwipeToReplyIndicator} ${
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
}`}
style={{ top: indicatorTop }}
aria-hidden
>
<Icon src={Icons.ReplyArrow} size="200" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const SwipeBackRoot = style({
position: 'relative',
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
});
export const SwipeBackUnderlay = style({
position: 'absolute',
inset: 0,
display: 'flex',
pointerEvents: 'none',
zIndex: 0,
});
export const SwipeBackSidebarPeek = style({
width: toRem(66),
flexShrink: 0,
backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
});
export const SwipeBackChannelPeek = style({
flex: 1,
minWidth: 0,
backgroundColor: color.Background.Container,
});
export const SwipeBackContent = style({
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)',
willChange: 'transform',
});
export const SwipeToReplyLayer = style({
position: 'relative',
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
touchAction: 'pan-y',
});
export const SwipeToReplyIndicator = style({
position: 'absolute',
right: config.space.S300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(36),
height: toRem(36),
borderRadius: config.radii.Pill,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
pointerEvents: 'none',
zIndex: 2,
opacity: 0,
transform: 'scale(0.85)',
transition: 'opacity 0.12s ease, transform 0.12s ease',
});
export const SwipeToReplyIndicatorActive = style({
opacity: 1,
transform: 'scale(1)',
});

View File

@@ -0,0 +1,160 @@
import React, { useCallback, useRef } from 'react';
import { Box, Text, config } from 'folds';
import { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useEditor } from '../../components/editor';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page';
import { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom';
import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { ThreadView } from './ThreadView';
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
const { code } = evt;
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
return false;
}
if (FN_KEYS_REGEX.test(code)) return false;
if (
code.startsWith('OS') ||
code.startsWith('Meta') ||
code.startsWith('Shift') ||
code.startsWith('Alt') ||
code.startsWith('Control') ||
code.startsWith('Arrow') ||
code.startsWith('Page') ||
code.startsWith('End') ||
code.startsWith('Home') ||
code === 'Tab' ||
code === 'Space' ||
code === 'Enter' ||
code === 'NumLock' ||
code === 'ScrollLock'
) {
return false;
}
return true;
};
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { roomId } = room;
const editor = useEditor();
const mx = useMatrixClient();
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
useKeyDown(
window,
useCallback(
(evt) => {
if (editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
}
if (shouldFocusMessageField(evt)) {
evt.preventDefault();
ReactEditor.focus(editor);
if (evt.key.length === 1) {
editor.insertText(evt.key);
}
} else if (isKeyHotkey('mod+v', evt)) {
ReactEditor.focus(editor);
}
},
[editor]
)
);
return (
<Page ref={roomViewRef}>
<RoomViewHeader />
{activeThreadId ? (
<ThreadView room={room} threadRootId={activeThreadId} />
) : (
<>
<MobileSwipeToReplyLayer room={room} editor={editor}>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
<RoomViewTyping room={room} />
</Box>
</MobileSwipeToReplyLayer>
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box>
</>
)}
</Page>
);
}

View File

@@ -0,0 +1,41 @@
import { RelationType, Room } from 'matrix-js-sdk';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { getEditedEvent } from '../../utils/room';
import { IReplyDraft } from '../../state/room/roomInputDrafts';
export const startReplyToEvent = (
room: Room,
eventId: string,
setReplyDraft: (draft: IReplyDraft | undefined) => void,
editor?: Editor,
startThread = false
): boolean => {
const replyEvt = room.findEventById(eventId);
if (!replyEvt || replyEvt.isRedacted()) return false;
const editedReply = getEditedEvent(eventId, replyEvt, room.getUnfilteredTimelineSet());
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const senderId = replyEvt.getSender();
if (!senderId || typeof body !== 'string') return false;
const relation = startThread
? { rel_type: RelationType.Thread, event_id: eventId }
: replyEvt.getWireContent()['m.relates_to'];
setReplyDraft({
userId: senderId,
eventId,
body,
formattedBody,
relation,
});
if (editor) {
setTimeout(() => ReactEditor.focus(editor), 100);
}
return true;
};

View File

@@ -35,11 +35,9 @@ import {
isTauri,
isElectron,
isCapacitorNative,
applyAndroidTypographyFix,
sendNotification,
setupNotificationTapListener,
} from '../../utils/tauri';
import '../../styles/android-typography.css';
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
import {
startBackgroundSync,
@@ -86,14 +84,6 @@ function EmojiStyleFeature() {
return null;
}
function AndroidTypographyFeature() {
useEffect(() => {
applyAndroidTypographyFix();
}, []);
return null;
}
function PageZoomFeature() {
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
@@ -694,7 +684,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
<EmojiStyleFeature />
<AndroidTypographyFeature />
<PageZoomFeature />
<FaviconUpdater />
<InviteNotifications />

View File

@@ -1,8 +0,0 @@
/*
* Android WebView renders Inter Variable with broken word/letter spacing.
* Applied when html.android-capacitor is set (see applyAndroidTypographyFix).
*/
html.android-capacitor body {
letter-spacing: normal;
word-spacing: normal;
}

View File

@@ -256,31 +256,14 @@ export const isCapacitorAndroid = (): boolean => {
};
/**
* Android WebView mishandles Inter Variable — swap to static Inter / system fonts.
* Android WebView: keep emoji fonts out of the body text stack and use system UI fonts.
*/
export const applyAndroidTypographyFix = (): void => {
if (!isCapacitorAndroid()) return;
const root = document.documentElement;
root.classList.add('android-capacitor');
void (async () => {
try {
await import('@fontsource/inter/400.css');
await import('@fontsource/inter/500.css');
await import('@fontsource/inter/600.css');
root.style.setProperty(
'--font-secondary',
`'Inter', system-ui, Roboto, 'Noto Sans', var(--font-emoji), sans-serif`
);
} catch (err) {
console.warn('[applyAndroidTypographyFix] Falling back to system font:', err);
root.style.setProperty(
'--font-secondary',
`system-ui, Roboto, 'Noto Sans', var(--font-emoji), sans-serif`
);
}
})();
root.style.setProperty('--font-secondary', 'system-ui, Roboto, "Noto Sans", sans-serif');
};
/**

18
overlay/src/font-setup.ts Normal file
View File

@@ -0,0 +1,18 @@
const cap =
typeof window !== 'undefined'
? (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor
: undefined;
export const isCapacitorAndroid = Boolean(
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
);
if (isCapacitorAndroid) {
document.documentElement.classList.add('android-capacitor');
document.documentElement.style.setProperty(
'--font-secondary',
'system-ui, Roboto, "Noto Sans", sans-serif'
);
} else {
await import('@fontsource-variable/inter');
}

506
overlay/src/index.css Normal file
View File

@@ -0,0 +1,506 @@
/* Twemoji font (Twitter emoji) */
@font-face {
font-family: Twemoji;
src: url('../public/font/Twemoji.Mozilla.v15.1.0.woff2'),
url('../public/font/Twemoji.Mozilla.v15.1.0.ttf');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
/* Apple Color Emoji - uses local Apple Color Emoji if available (macOS/iOS),
falls back to Segoe UI Emoji (Windows) or Noto (Linux/Android) */
@font-face {
font-family: AppleColorEmoji;
src: local('Apple Color Emoji'),
local('Segoe UI Emoji'),
local('Noto Color Emoji');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
/* System emoji - uses native OS emoji */
@font-face {
font-family: SystemEmoji;
src: local('Apple Color Emoji'),
local('Segoe UI Emoji'),
local('Segoe UI Symbol'),
local('Noto Color Emoji'),
local('Android Emoji'),
local('EmojiOne Color');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
:root {
--tc-link: hsl(213deg 100% 45%);
/* user mxid colors */
--mx-uc-1: hsl(208, 100%, 45%);
--mx-uc-2: hsl(302, 100%, 30%);
--mx-uc-3: hsl(163, 100%, 30%);
--mx-uc-4: hsl(343, 100%, 45%);
--mx-uc-5: hsl(24, 100%, 45%);
--mx-uc-6: hsl(181, 100%, 30%);
--mx-uc-7: hsl(242, 100%, 45%);
--mx-uc-8: hsl(94, 100%, 35%);
--font-emoji: 'AppleColorEmoji';
--font-secondary: 'InterVariable', sans-serif;
}
.dark-theme,
.butter-theme,
.discord-theme,
.discord-darker-theme,
.twilight-theme,
.mocha-theme {
--tc-link: hsl(213deg 100% 80%);
--mx-uc-1: hsl(208, 100%, 75%);
--mx-uc-2: hsl(301, 100%, 80%);
--mx-uc-3: hsl(163, 100%, 70%);
--mx-uc-4: hsl(343, 100%, 75%);
--mx-uc-5: hsl(24, 100%, 70%);
--mx-uc-6: hsl(181, 100%, 60%);
--mx-uc-7: hsl(243, 100%, 80%);
--mx-uc-8: hsl(94, 100%, 80%);
--font-secondary: 'InterVariable', sans-serif;
}
html {
height: 100%;
overflow: hidden;
/* Enable safe area variables */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
}
body {
margin: 0;
padding: 0;
/* Safe area insets for mobile devices (notch, navigation bar) */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
height: 100%;
font-family: var(--font-secondary);
font-size: 16px;
font-weight: 400;
/* Default to dark theme background for safe areas */
background-color: #262626;
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
font-variant-ligatures: no-contextual;
}
/* 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;
}
/* Theme-specific body backgrounds for safe area padding */
body.light-theme {
background-color: #F0F0F0;
}
body.silver-theme {
background-color: #DEDEDE;
}
body.dark-theme {
background-color: #262626;
}
body.butter-theme {
background-color: #1A1916;
}
body.discord-theme {
background-color: #2B2D31;
}
body.discord-darker-theme {
background-color: #1E1F22;
}
body.twilight-theme {
background: linear-gradient(135deg, #14121F 0%, #1C1A2E 50%, #24223D 100%);
}
body.mocha-theme {
background: linear-gradient(135deg, #1A1614 0%, #242019 50%, #2D2721 100%);
}
#root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #262626;
}
.twilight-theme #root {
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
}
.mocha-theme #root {
background: linear-gradient(180deg, rgba(36, 32, 25, 0.5) 0%, rgba(45, 39, 33, 0.3) 100%);
}
/* Twilight theme gradient overlays */
.twilight-theme [role="navigation"],
.twilight-theme aside {
background: linear-gradient(180deg, rgba(20, 18, 31, 0.8) 0%, rgba(28, 26, 46, 0.9) 100%);
backdrop-filter: blur(8px);
}
.twilight-theme main {
background: linear-gradient(135deg, rgba(24, 22, 45, 0.4) 0%, rgba(36, 34, 61, 0.3) 100%);
}
.twilight-theme header {
background: linear-gradient(90deg, rgba(20, 18, 31, 0.9) 0%, rgba(28, 26, 46, 0.8) 100%);
}
/* CSS animations for route transitions */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Apply transition to route changes - works on ALL themes */
[data-route-transition="true"] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Twilight theme enhanced transitions */
.twilight-theme [data-route-transition="true"] {
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Also apply to main content areas for double coverage */
.twilight-theme [data-room-view],
.twilight-theme [data-space-view],
.twilight-theme [data-inbox-view],
.twilight-theme [data-explore-view] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Smooth transitions for interactive elements */
.twilight-theme button,
.twilight-theme [role="button"],
.twilight-theme a,
.twilight-theme input,
.twilight-theme textarea {
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.twilight-theme button:hover,
.twilight-theme [role="button"]:hover {
transform: translateY(-1px);
}
.twilight-theme button:active,
.twilight-theme [role="button"]:active {
transform: translateY(0);
}
/* Message transitions */
.twilight-theme [data-message-item] {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.twilight-theme [data-message-item]:hover {
background: linear-gradient(90deg, rgba(62, 58, 106, 0.15) 0%, rgba(86, 80, 150, 0.1) 100%);
transform: translateX(2px);
}
/* Selected item indicator */
.twilight-theme [data-selected="true"],
.twilight-theme [aria-selected="true"],
.twilight-theme [aria-current="true"] {
position: relative;
transition: all 0.2s ease;
}
.twilight-theme [data-selected="true"]::before,
.twilight-theme [aria-selected="true"]::before,
.twilight-theme [aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #8B7FFF 0%, #6E62E8 100%);
transition: width 0.2s ease;
}
/* Scrollbar styling */
.twilight-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.twilight-theme ::-webkit-scrollbar-track {
background: rgba(20, 18, 31, 0.5);
}
.twilight-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
border-radius: 5px;
border: 2px solid rgba(20, 18, 31, 0.5);
transition: background 0.2s ease;
}
.twilight-theme ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
}
/* Mocha theme gradient overlays */
.mocha-theme [role="navigation"],
.mocha-theme aside {
background: linear-gradient(180deg, rgba(26, 22, 20, 0.8) 0%, rgba(36, 32, 25, 0.9) 100%);
backdrop-filter: blur(8px);
}
.mocha-theme main {
background: linear-gradient(135deg, rgba(36, 32, 25, 0.4) 0%, rgba(45, 39, 33, 0.3) 100%);
}
.mocha-theme header {
background: linear-gradient(90deg, rgba(26, 22, 20, 0.9) 0%, rgba(36, 32, 25, 0.8) 100%);
}
/* Mocha theme enhanced transitions */
.mocha-theme [data-route-transition="true"] {
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.mocha-theme [data-room-view],
.mocha-theme [data-space-view],
.mocha-theme [data-inbox-view],
.mocha-theme [data-explore-view] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Smooth transitions for interactive elements */
.mocha-theme button,
.mocha-theme [role="button"],
.mocha-theme a,
.mocha-theme input,
.mocha-theme textarea {
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.mocha-theme button:hover,
.mocha-theme [role="button"]:hover {
transform: translateY(-1px);
}
.mocha-theme button:active,
.mocha-theme [role="button"]:active {
transform: translateY(0);
}
/* Message transitions */
.mocha-theme [data-message-item] {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.mocha-theme [data-message-item]:hover {
background: linear-gradient(90deg, rgba(84, 73, 63, 0.15) 0%, rgba(112, 97, 81, 0.1) 100%);
transform: translateX(2px);
}
/* Selected item indicator */
.mocha-theme [data-selected="true"],
.mocha-theme [aria-selected="true"],
.mocha-theme [aria-current="true"] {
position: relative;
transition: all 0.2s ease;
}
.mocha-theme [data-selected="true"]::before,
.mocha-theme [aria-selected="true"]::before,
.mocha-theme [aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #D4A574 0%, #B48660 100%);
transition: width 0.2s ease;
}
/* Scrollbar styling */
.mocha-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.mocha-theme ::-webkit-scrollbar-track {
background: rgba(26, 22, 20, 0.5);
}
.mocha-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
border-radius: 5px;
border: 2px solid rgba(26, 22, 20, 0.5);
transition: background 0.2s ease;
}
.mocha-theme ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #625548 0%, #7E6D5A 100%);
}
/* Subtle dot pattern overlay */
.mocha-theme::after {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(212, 165, 116, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
.mocha-theme #root {
position: relative;
z-index: 1;
}
/* Focus states */
.mocha-theme input:focus,
.mocha-theme textarea:focus,
.mocha-theme button:focus-visible {
outline: 2px solid rgba(212, 165, 116, 0.5);
outline-offset: 2px;
transition: outline 0.2s ease;
}
/* Subtle dot pattern overlay */
.twilight-theme::after {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(139, 127, 255, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
.twilight-theme #root {
position: relative;
z-index: 1;
}
/* Focus states */
.twilight-theme input:focus,
.twilight-theme textarea:focus,
.twilight-theme button:focus-visible {
outline: 2px solid rgba(139, 127, 255, 0.5);
outline-offset: 2px;
transition: outline 0.2s ease;
}
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent;
}
a {
color: var(--tc-link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
[data-mx-spoiler][aria-pressed='true'] a {
color: transparent;
pointer-events: none;
}
b {
font-weight: 500;
}
label {
margin: 0;
padding: 0;
}
button,
textarea {
margin: 0;
padding: 0;
background-color: transparent;
color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
letter-spacing: inherit;
border: none;
}
button {
max-width: 100%;
text-transform: none;
text-align: inherit;
overflow: visible;
-webkit-appearance: button;
}
textarea,
input,
input[type],
input[type='text'],
input[type='username'],
input[type='password'],
input[type='email'],
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
textarea {
color: inherit;
word-spacing: inherit;
}
audio:not([controls]) {
display: none !important;
}
/* Android WebView: Inter Variable + emoji fonts in the text stack break space metrics */
html.android-capacitor,
html.android-capacitor.dark-theme,
html.android-capacitor.butter-theme,
html.android-capacitor.discord-theme,
html.android-capacitor.discord-darker-theme,
html.android-capacitor.twilight-theme,
html.android-capacitor.mocha-theme,
html.android-capacitor.light-theme,
html.android-capacitor.silver-theme {
--font-secondary: system-ui, Roboto, 'Noto Sans', sans-serif;
}
html.android-capacitor body {
font-family: system-ui, Roboto, 'Noto Sans', sans-serif;
letter-spacing: normal;
word-spacing: normal;
font-variant-ligatures: normal;
}

112
overlay/src/index.tsx Normal file
View File

@@ -0,0 +1,112 @@
/* eslint-disable import/first */
import React from 'react';
import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
await import('./font-setup');
import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds';
enableMapSet();
import './index.css';
import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
// import i18n (needs to be bundled ;))
import './app/i18n';
document.body.classList.add(configClass, varsClass);
// Apply safe area insets for mobile devices
applySafeAreaInsets();
// Enable View Transitions API for smooth navigation
enableViewTransitionsForNavigation();
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =
import.meta.env.MODE === 'production'
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
navigator.serviceWorker.register(swUrl);
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'token' && event.data?.responseKey) {
const getCurrentAccessToken = async () => {
const { getCurrentAccessToken: getToken } = await import('./app/utils/auth');
return getToken();
};
getCurrentAccessToken().then((token) => {
event.source!.postMessage({
responseKey: event.data.responseKey,
token,
});
});
}
});
}
const isElectron = (): boolean => 'electron' in window;
async function checkForUpdates() {
if (isElectron()) {
console.log('Update check skipped - Electron handles updates natively');
return;
}
if (!isTauri() || isElectron()) {
console.log('Update check skipped - not running in Tauri');
return;
}
console.log('Checking for updates...');
try {
const { check } = await import('@tauri-apps/plugin-updater');
const { ask } = await import('@tauri-apps/plugin-dialog');
const { relaunch } = await import('@tauri-apps/plugin-process');
const update = await check();
console.log('Update check result:', update);
if (update) {
console.log(`Update available: ${update.version}`);
const shouldUpdate = await ask(
`A new version (${update.version}) is available. Would you like to update now?`,
{ title: 'Update Available', kind: 'info' }
);
if (shouldUpdate) {
console.log('User chose to update, downloading...');
await update.downloadAndInstall();
await relaunch();
}
} else {
console.log('App is up to date');
}
} catch (error) {
console.error('Failed to check for updates:', error);
}
}
const mountApp = () => {
const rootContainer = document.getElementById('root');
if (rootContainer === null) {
console.error('Root container element not found!');
return;
}
const root = createRoot(rootContainer);
root.render(<App />);
};
mountApp();
setTimeout(() => {
checkForUpdates();
}, 3000);

View File

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