Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6000c1db8 | ||
| 83b4cbbd8e | |||
|
|
8753ed2600 | ||
| cced6b8ab9 | |||
| 82effc9680 | |||
|
|
deefb40277 | ||
| b496c70190 | |||
|
|
5a4384ea1b | ||
| cc974414f8 | |||
| 3245f41073 |
@@ -27,8 +27,8 @@ android {
|
||||
applicationId "com.paarrot.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1783526741
|
||||
versionName "2026-07-08.160541.766"
|
||||
versionCode 1783582912
|
||||
versionName "2026-07-09.074152.772"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
2
cinny
2
cinny
Submodule cinny updated: b4258278d8...9c7f71896c
30
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
30
overlay/src/app/components/AnimatedOutlet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx
Normal file
222
overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
230
overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx
Normal file
230
overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
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 {
|
||||
claimMobileGesture,
|
||||
clearMobileGesture,
|
||||
getActiveMobileGesture,
|
||||
} from './mobileGestureArbitration';
|
||||
import { useWindowPointerDrag } from './useWindowPointerDrag';
|
||||
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;
|
||||
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 releaseCapture = useCallback((pointerId: number) => {
|
||||
const layer = layerRef.current;
|
||||
if (layer?.hasPointerCapture(pointerId)) {
|
||||
layer.releasePointerCapture(pointerId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetGesture = useCallback(
|
||||
(pointerId?: number) => {
|
||||
if (pointerId !== undefined) {
|
||||
releaseCapture(pointerId);
|
||||
clearMobileGesture(pointerId);
|
||||
}
|
||||
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"]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
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) {
|
||||
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)}
|
||||
>
|
||||
{children}
|
||||
{indicatorTop !== null && (
|
||||
<div
|
||||
className={`${css.SwipeToReplyIndicator} ${
|
||||
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
|
||||
}`}
|
||||
style={{ top: indicatorTop }}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon src={Icons.ReplyArrow} size="200" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
79
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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',
|
||||
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)',
|
||||
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)',
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
46
overlay/src/app/components/mobile/useWindowPointerDrag.ts
Normal file
46
overlay/src/app/components/mobile/useWindowPointerDrag.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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]);
|
||||
};
|
||||
160
overlay/src/app/features/room/RoomView.tsx
Normal file
160
overlay/src/app/features/room/RoomView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
overlay/src/app/features/room/replyToMessage.ts
Normal file
41
overlay/src/app/features/room/replyToMessage.ts
Normal 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;
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
18
overlay/src/font-setup.ts
Normal 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
506
overlay/src/index.css
Normal 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
112
overlay/src/index.tsx
Normal 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);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.111",
|
||||
"version": "4.11.115",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user