Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d6f6ff42 | ||
| 97f0a15ae2 | |||
| 4cf19131bf | |||
|
|
83137c92a9 | ||
| 426ddf5650 | |||
| 7800e7ced7 | |||
|
|
a5afa6b493 | ||
| afeb45f59d | |||
| 6f45f94f19 | |||
|
|
fbcb291b9a | ||
| b7ec975fb0 | |||
|
|
3f5d5dec98 | ||
| c7b6e8f5f0 | |||
|
|
f709998c50 | ||
| ddcda65c1f | |||
| 6e32bf302a | |||
|
|
ca51f93ecd | ||
| 47c525cc5a | |||
| a282fce14a | |||
|
|
f1121839d4 | ||
| 585eeecb1b | |||
| 72dbbe29b9 | |||
|
|
b6000c1db8 | ||
| 83b4cbbd8e |
@@ -27,8 +27,8 @@ android {
|
|||||||
applicationId "com.paarrot.app"
|
applicationId "com.paarrot.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1783526741
|
versionCode 1783582912
|
||||||
versionName "2026-07-08.160541.766"
|
versionName "2026-07-09.074152.772"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {}
|
"dependencies": {
|
||||||
|
"@capacitor/status-bar": "8.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
|
import { useCompactNav, useIsCompactListRoute } from '../hooks/useCompactNav';
|
||||||
|
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper for Outlet that adds route-based animation
|
* Outlet wrapper for route remounts + compact swipe-back chrome.
|
||||||
* Forces remount on route change by using location as key
|
*
|
||||||
|
* 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() {
|
export function AnimatedOutlet() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const isListRoute = useIsCompactListRoute();
|
||||||
|
|
||||||
|
if (compact && isListRoute) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileSwipeBackPanel>
|
<MobileSwipeGestureHost>
|
||||||
<div
|
<div
|
||||||
key={location.pathname}
|
key={location.pathname}
|
||||||
data-route-transition="true"
|
{...(!compact && { 'data-route-transition': 'true' })}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -25,6 +35,6 @@ export function AnimatedOutlet() {
|
|||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</MobileSwipeBackPanel>
|
</MobileSwipeGestureHost>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
79
overlay/src/app/components/editor/Editor.css.ts
Normal file
79
overlay/src/app/components/editor/Editor.css.ts
Normal 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,
|
||||||
|
});
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
368
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
368
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||||
|
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||||
|
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
||||||
|
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||||
|
import * as css from './mobile-gestures.css';
|
||||||
|
|
||||||
|
const EDGE_WIDTH = 28;
|
||||||
|
const BACK_COMMIT_RATIO = 0.25;
|
||||||
|
const BACK_COMMIT_MIN = 56;
|
||||||
|
const REPLY_THRESHOLD = 48;
|
||||||
|
const REPLY_MAX = 96;
|
||||||
|
const AXIS_LOCK_PX = 10;
|
||||||
|
const AXIS_DOMINANCE = 1.2;
|
||||||
|
const MAX_START_Y_RATIO = 0.88;
|
||||||
|
|
||||||
|
type GesturePhase = 'idle' | 'pending' | 'back' | 'reply';
|
||||||
|
|
||||||
|
type GestureState = {
|
||||||
|
phase: GesturePhase;
|
||||||
|
touchId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
messageEl: HTMLElement | null;
|
||||||
|
transformEl: HTMLElement | null;
|
||||||
|
messageId: string | null;
|
||||||
|
edgeBack: boolean;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IDLE_STATE: GestureState = {
|
||||||
|
phase: 'idle',
|
||||||
|
touchId: -1,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
messageEl: null,
|
||||||
|
transformEl: null,
|
||||||
|
messageId: null,
|
||||||
|
edgeBack: false,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
type MobileSwipeGestureHostProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element)) return true;
|
||||||
|
return Boolean(
|
||||||
|
target.closest(
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element)) return true;
|
||||||
|
return Boolean(
|
||||||
|
target.closest(
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
return target.closest('[data-message-id]') as HTMLElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findReplyTransformTarget = (messageEl: HTMLElement): HTMLElement => {
|
||||||
|
const content = messageEl.querySelector('[data-swipe-reply-content]');
|
||||||
|
if (content instanceof HTMLElement) return content;
|
||||||
|
return messageEl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyBackOffset = (dx: number, width: number): number => {
|
||||||
|
const positive = Math.max(0, dx);
|
||||||
|
if (positive <= width) return positive;
|
||||||
|
return width + (positive - width) * 0.22;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setElementTransform = (el: HTMLElement | null, px: number) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.transition = 'none';
|
||||||
|
el.style.transform = `translateX(${px}px)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetElementTransform = (el: HTMLElement | null) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.transition = 'none';
|
||||||
|
el.style.transform = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) {
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { canGoBack, goBack } = useBackRoute();
|
||||||
|
const backEnabled = compact && canGoBack;
|
||||||
|
const replyEnabled = compact;
|
||||||
|
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const gestureRef = useRef<GestureState>({ ...IDLE_STATE });
|
||||||
|
const scrollLockRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const unlockScroll = useCallback(() => {
|
||||||
|
if (scrollLockRef.current) {
|
||||||
|
scrollLockRef.current.style.overflow = '';
|
||||||
|
scrollLockRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const lockScroll = useCallback((anchor: HTMLElement) => {
|
||||||
|
unlockScroll();
|
||||||
|
let el: HTMLElement | null = anchor;
|
||||||
|
while (el && el !== document.body) {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
if (
|
||||||
|
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
|
||||||
|
el.scrollHeight > el.clientHeight + 1
|
||||||
|
) {
|
||||||
|
scrollLockRef.current = el;
|
||||||
|
el.style.overflow = 'hidden';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
}, [unlockScroll]);
|
||||||
|
|
||||||
|
const resetBackTransform = useCallback(() => {
|
||||||
|
resetElementTransform(contentRef.current);
|
||||||
|
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetReplyTransform = useCallback((transformEl?: HTMLElement | null) => {
|
||||||
|
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||||
|
const el = transformEl ?? gestureRef.current.transformEl;
|
||||||
|
resetElementTransform(el);
|
||||||
|
bridge?.setIndicator(null, false);
|
||||||
|
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetGesture = useCallback(
|
||||||
|
(transformEl?: HTMLElement | null) => {
|
||||||
|
resetBackTransform();
|
||||||
|
resetReplyTransform(transformEl);
|
||||||
|
unlockScroll();
|
||||||
|
gestureRef.current = { ...IDLE_STATE };
|
||||||
|
},
|
||||||
|
[resetBackTransform, resetReplyTransform, unlockScroll]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateReplyIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
|
||||||
|
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||||
|
if (!bridge?.layerEl) return;
|
||||||
|
const messageRect = messageEl.getBoundingClientRect();
|
||||||
|
const layerRect = bridge.layerEl.getBoundingClientRect();
|
||||||
|
const top = messageRect.top - layerRect.top + messageRect.height / 2 - 18;
|
||||||
|
bridge.setIndicator(top, Math.abs(offset) >= REPLY_THRESHOLD * 0.6);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyReplyTransform = useCallback(
|
||||||
|
(transformEl: HTMLElement, messageEl: HTMLElement, offset: number) => {
|
||||||
|
setElementTransform(transformEl, offset);
|
||||||
|
updateReplyIndicator(messageEl, offset);
|
||||||
|
},
|
||||||
|
[updateReplyIndicator]
|
||||||
|
);
|
||||||
|
|
||||||
|
const lockGesture = useCallback(
|
||||||
|
(phase: 'back' | 'reply', scrollAnchor?: HTMLElement | null) => {
|
||||||
|
gestureRef.current.phase = phase;
|
||||||
|
document.documentElement.classList.add('mobile-gesture-lock');
|
||||||
|
if (scrollAnchor) {
|
||||||
|
lockScroll(scrollAnchor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[lockScroll]
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishGesture = useCallback(() => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (gesture.phase === 'idle' || gesture.phase === 'pending') {
|
||||||
|
resetGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture.phase === 'back') {
|
||||||
|
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||||
|
const shouldCommit =
|
||||||
|
gesture.offset >= Math.max(width * BACK_COMMIT_RATIO, BACK_COMMIT_MIN);
|
||||||
|
|
||||||
|
resetGesture();
|
||||||
|
if (shouldCommit) {
|
||||||
|
goBack();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
|
||||||
|
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||||
|
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
|
||||||
|
const { messageEl, messageId, transformEl } = gesture;
|
||||||
|
|
||||||
|
resetGesture(transformEl);
|
||||||
|
|
||||||
|
if (shouldReply && bridge) {
|
||||||
|
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [goBack, resetGesture]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!compact) return;
|
||||||
|
|
||||||
|
const getActiveTouch = (evt: TouchEvent, touchId: number) =>
|
||||||
|
Array.from(evt.touches).find((touch) => touch.identifier === touchId);
|
||||||
|
|
||||||
|
const getEndedTouch = (evt: TouchEvent, touchId: number) =>
|
||||||
|
Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId);
|
||||||
|
|
||||||
|
const handleTouchStart = (evt: TouchEvent) => {
|
||||||
|
if (gestureRef.current.phase !== 'idle') return;
|
||||||
|
|
||||||
|
const touch = evt.changedTouches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const target = evt.target;
|
||||||
|
if (touch.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
|
||||||
|
|
||||||
|
const messageEl = findMessageElement(target);
|
||||||
|
const messageId = messageEl?.getAttribute('data-message-id') ?? null;
|
||||||
|
|
||||||
|
const canStartBack = backEnabled && !shouldIgnoreBackTarget(target);
|
||||||
|
const canStartReply =
|
||||||
|
replyEnabled &&
|
||||||
|
messageEl &&
|
||||||
|
messageId &&
|
||||||
|
!shouldIgnoreReplyTarget(target) &&
|
||||||
|
mobileSwipeReplyBridgeRef.current;
|
||||||
|
|
||||||
|
if (!canStartBack && !canStartReply) return;
|
||||||
|
|
||||||
|
if (touch.clientX <= EDGE_WIDTH && canStartBack) {
|
||||||
|
evt.preventDefault();
|
||||||
|
gestureRef.current = {
|
||||||
|
phase: 'back',
|
||||||
|
touchId: touch.identifier,
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
messageEl: null,
|
||||||
|
transformEl: null,
|
||||||
|
messageId: null,
|
||||||
|
edgeBack: true,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
document.documentElement.classList.add('mobile-gesture-lock');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gestureRef.current = {
|
||||||
|
phase: 'pending',
|
||||||
|
touchId: touch.identifier,
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
messageEl: canStartReply ? messageEl : null,
|
||||||
|
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
|
||||||
|
messageId: canStartReply ? messageId : null,
|
||||||
|
edgeBack: false,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (evt: TouchEvent) => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (gesture.phase === 'idle') return;
|
||||||
|
|
||||||
|
const touch = getActiveTouch(evt, gesture.touchId);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const deltaX = touch.clientX - gesture.startX;
|
||||||
|
const deltaY = touch.clientY - gesture.startY;
|
||||||
|
|
||||||
|
if (gesture.phase === 'pending') {
|
||||||
|
if (Math.abs(deltaX) < AXIS_LOCK_PX && Math.abs(deltaY) < AXIS_LOCK_PX) return;
|
||||||
|
|
||||||
|
const horizontal = Math.abs(deltaX) >= Math.abs(deltaY) * AXIS_DOMINANCE;
|
||||||
|
const vertical = Math.abs(deltaY) >= Math.abs(deltaX) * AXIS_DOMINANCE;
|
||||||
|
|
||||||
|
if (vertical) {
|
||||||
|
resetGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!horizontal) return;
|
||||||
|
|
||||||
|
if (deltaX > 0 && backEnabled) {
|
||||||
|
lockGesture('back', contentRef.current);
|
||||||
|
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId && gesture.transformEl) {
|
||||||
|
lockGesture('reply', gesture.messageEl);
|
||||||
|
} else {
|
||||||
|
resetGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture.phase === 'back') {
|
||||||
|
if (!evt.cancelable) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||||
|
const offset = applyBackOffset(deltaX, width);
|
||||||
|
gesture.offset = offset;
|
||||||
|
setElementTransform(contentRef.current, offset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture.phase === 'reply' && gesture.transformEl && gesture.messageEl) {
|
||||||
|
if (!evt.cancelable) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
const offset = Math.max(deltaX, -REPLY_MAX);
|
||||||
|
gesture.offset = offset;
|
||||||
|
applyReplyTransform(gesture.transformEl, gesture.messageEl, offset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (evt: TouchEvent) => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (gesture.phase === 'idle') return;
|
||||||
|
|
||||||
|
const touch = getEndedTouch(evt, gesture.touchId);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
finishGesture();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: false });
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { capture: true, passive: false });
|
||||||
|
document.addEventListener('touchend', handleTouchEnd, { capture: true });
|
||||||
|
document.addEventListener('touchcancel', handleTouchEnd, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart, { capture: true });
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove, { capture: true });
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd, { capture: true });
|
||||||
|
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
|
||||||
|
resetGesture();
|
||||||
|
};
|
||||||
|
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, applyReplyTransform]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetGesture();
|
||||||
|
}, [canGoBack, pathname, resetGesture]);
|
||||||
|
|
||||||
|
if (!compact) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={css.SwipeBackRoot}>
|
||||||
|
<div ref={contentRef} className={css.SwipeBackContent}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,13 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||||
import * as css from './mobile-gestures.css';
|
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 = {
|
type MobileSwipeToReplyLayerProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -28,178 +16,40 @@ type MobileSwipeToReplyLayerProps = {
|
|||||||
|
|
||||||
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
|
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
|
||||||
const compact = useCompactNav();
|
const compact = useCompactNav();
|
||||||
const enabled = compact;
|
|
||||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||||
const layerRef = useRef<HTMLDivElement>(null);
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
const dragRef = useRef<DragState | null>(null);
|
|
||||||
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
|
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
|
||||||
const [indicatorActive, setIndicatorActive] = useState(false);
|
const [indicatorActive, setIndicatorActive] = useState(false);
|
||||||
|
|
||||||
const clearMessageTransform = useCallback((el?: HTMLElement | null, animate = true) => {
|
useLayoutEffect(() => {
|
||||||
const target = el ?? dragRef.current?.messageEl;
|
if (!compact) return;
|
||||||
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(() => {
|
mobileSwipeReplyBridgeRef.current = {
|
||||||
clearMessageTransform();
|
room,
|
||||||
dragRef.current = null;
|
editor,
|
||||||
setIndicatorTop(null);
|
setReplyDraft,
|
||||||
setIndicatorActive(false);
|
layerEl: layerRef.current,
|
||||||
}, [clearMessageTransform]);
|
setIndicator: (top, active) => {
|
||||||
|
setIndicatorTop(top);
|
||||||
|
setIndicatorActive(active);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
return () => {
|
||||||
if (!(target instanceof Element)) return true;
|
if (mobileSwipeReplyBridgeRef.current?.room.roomId === room.roomId) {
|
||||||
if (
|
mobileSwipeReplyBridgeRef.current = null;
|
||||||
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);
|
setIndicatorTop(null);
|
||||||
setIndicatorActive(false);
|
setIndicatorActive(false);
|
||||||
|
};
|
||||||
|
}, [compact, editor, room, setReplyDraft]);
|
||||||
|
|
||||||
if (shouldReply) {
|
if (!compact) {
|
||||||
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 <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={layerRef} className={css.SwipeToReplyLayer}>
|
||||||
ref={layerRef}
|
|
||||||
className={css.SwipeToReplyLayer}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
{indicatorTop !== null && (
|
{indicatorTop !== null && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const SwipeBackRoot = style({
|
export const SwipeBackRoot = style({
|
||||||
@@ -11,38 +11,56 @@ export const SwipeBackRoot = style({
|
|||||||
flexDirection: 'column',
|
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({
|
export const SwipeBackContent = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
backgroundColor: color.Background.Container,
|
backgroundColor: color.Background.Container,
|
||||||
boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)',
|
boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.28)',
|
||||||
willChange: 'transform',
|
willChange: 'transform',
|
||||||
|
touchAction: 'pan-y',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CompactDetailRoot = style({
|
||||||
|
position: 'relative',
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CompactNavUnderlay = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 0,
|
||||||
|
// Keep underlay tappable if the detail layer is empty/transparent
|
||||||
|
// (e.g. space index after leaving DMs). Opaque detail content still
|
||||||
|
// sits above and receives taps first.
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CompactDetailLayer = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
// Pass through taps when outlet content is null/empty; real pages
|
||||||
|
// re-enable pointer events on their root via the child rule below.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${CompactDetailLayer} > *`, {
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SwipeToReplyLayer = style({
|
export const SwipeToReplyLayer = style({
|
||||||
@@ -51,7 +69,6 @@ export const SwipeToReplyLayer = style({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
touchAction: 'pan-y',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SwipeToReplyIndicator = style({
|
export const SwipeToReplyIndicator = style({
|
||||||
|
|||||||
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal file
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { IReplyDraft } from '../../state/room/roomInputDrafts';
|
||||||
|
|
||||||
|
export type MobileSwipeReplyBridge = {
|
||||||
|
room: Room;
|
||||||
|
editor: Editor;
|
||||||
|
setReplyDraft: (draft: IReplyDraft | undefined) => void;
|
||||||
|
layerEl: HTMLElement | null;
|
||||||
|
setIndicator: (top: number | null, active: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mobileSwipeReplyBridgeRef: { current: MobileSwipeReplyBridge | null } = {
|
||||||
|
current: null,
|
||||||
|
};
|
||||||
206
overlay/src/app/components/page/Page.tsx
Normal file
206
overlay/src/app/components/page/Page.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
|
||||||
|
import { Box, Header, Line, Scroll, Text, as } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import * as css from './style.css';
|
||||||
|
import * as swipeCss from '../mobile/mobile-gestures.css';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||||
|
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||||
|
|
||||||
|
type PageRootProps = {
|
||||||
|
nav: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageRoot({ nav, children }: PageRootProps) {
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
const showCompactMaster = useShowCompactMasterView();
|
||||||
|
const compact = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
|
// Master list routes: only the channel/space list is interactive.
|
||||||
|
// Never mount the detail/underlay stack here — an empty detail layer would
|
||||||
|
// sit above the list with pointer-events and freeze taps (DM → space bug).
|
||||||
|
if (compact && showCompactMaster) {
|
||||||
|
return (
|
||||||
|
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||||
|
{nav}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact && !showCompactMaster) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
className={classNames(ContainerColor({ variant: 'Background' }), swipeCss.CompactDetailRoot)}
|
||||||
|
>
|
||||||
|
{nav && (
|
||||||
|
<Box className={swipeCss.CompactNavUnderlay} aria-hidden>
|
||||||
|
{nav}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box grow="Yes" className={swipeCss.CompactDetailLayer}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||||
|
{nav}
|
||||||
|
{screenSize !== ScreenSize.Mobile && (
|
||||||
|
<Line variant="Background" size="300" direction="Vertical" />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientDrawerLayoutProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
export function PageNav({
|
||||||
|
size,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ClientDrawerLayoutProps & css.PageNavVariants & { className?: string }) {
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
const isMobile = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
grow={isMobile ? 'Yes' : undefined}
|
||||||
|
className={classNames(css.PageNav({ size }), className)}
|
||||||
|
shrink={isMobile ? 'Yes' : 'No'}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{children}
|
||||||
|
<SidebarDockedCallPanel />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
||||||
|
({ className, outlined, ...props }, ref) => (
|
||||||
|
<Header
|
||||||
|
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||||
|
variant="Background"
|
||||||
|
size="600"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export function PageNavContent({
|
||||||
|
scrollRef,
|
||||||
|
scrollProps,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Scroll
|
||||||
|
ref={scrollRef}
|
||||||
|
variant="Background"
|
||||||
|
direction="Vertical"
|
||||||
|
size="300"
|
||||||
|
hideTrack
|
||||||
|
visibility="Hover"
|
||||||
|
{...scrollProps}
|
||||||
|
>
|
||||||
|
<div className={css.PageNavContent}>{children}</div>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Page = as<'div'>(({ className, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
export const PageHeader = as<'div', css.PageHeaderVariants>(
|
||||||
|
({ className, outlined, balance, ...props }, ref) => (
|
||||||
|
<Header
|
||||||
|
as="header"
|
||||||
|
size="600"
|
||||||
|
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PageContent = as<'div'>(({ className, ...props }, ref) => (
|
||||||
|
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
export function PageHeroEmpty({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="200"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
className={classNames(css.PageHeroSection, className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export function PageHero({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subTitle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
title: ReactNode;
|
||||||
|
subTitle: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Box direction="Column" alignItems="Center" gap="200">
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
<Box as="h2" direction="Column" gap="200" alignItems="Center">
|
||||||
|
<Text align="Center" size="H2">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text align="Center" priority="400">
|
||||||
|
{subTitle}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
|
||||||
|
<div className={classNames(css.PageContentCenter, className)} {...props} ref={ref} />
|
||||||
|
));
|
||||||
@@ -26,6 +26,8 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
|
|||||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
import { ThreadView } from './ThreadView';
|
import { ThreadView } from './ThreadView';
|
||||||
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
|
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 FN_KEYS_REGEX = /^F\d+$/;
|
||||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
@@ -63,6 +65,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const { immersive } = useMobileKeyboardLayout();
|
||||||
|
|
||||||
const { roomId } = room;
|
const { roomId } = room;
|
||||||
const editor = useEditor();
|
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 (
|
return (
|
||||||
<Page ref={roomViewRef}>
|
<Page ref={roomViewRef}>
|
||||||
<RoomViewHeader />
|
{showHeader && <RoomViewHeader />}
|
||||||
{activeThreadId ? (
|
{activeThreadId ? (
|
||||||
<ThreadView room={room} threadRootId={activeThreadId} />
|
<ThreadView room={room} threadRootId={activeThreadId} />
|
||||||
) : (
|
) : (
|
||||||
@@ -120,8 +127,21 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
<RoomViewTyping room={room} />
|
<RoomViewTyping room={room} />
|
||||||
</Box>
|
</Box>
|
||||||
</MobileSwipeToReplyLayer>
|
</MobileSwipeToReplyLayer>
|
||||||
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
|
<Box
|
||||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
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 ? (
|
{tombstoneEvent ? (
|
||||||
<RoomTombstone
|
<RoomTombstone
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
@@ -151,7 +171,12 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
{!immersive &&
|
||||||
|
(hideActivity ? (
|
||||||
|
<RoomViewFollowingPlaceholder />
|
||||||
|
) : (
|
||||||
|
<RoomViewFollowing room={room} />
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
49
overlay/src/app/features/room/RoomViewFollowing.css.ts
Normal file
49
overlay/src/app/features/room/RoomViewFollowing.css.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
1405
overlay/src/app/features/room/message/Message.tsx
Normal file
1405
overlay/src/app/features/room/message/Message.tsx
Normal file
File diff suppressed because it is too large
Load Diff
63
overlay/src/app/features/room/room-composer-mobile.css.ts
Normal file
63
overlay/src/app/features/room/room-composer-mobile.css.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
30
overlay/src/app/hooks/useCompactNav.ts
Normal file
30
overlay/src/app/hooks/useCompactNav.ts
Normal 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;
|
||||||
|
};
|
||||||
97
overlay/src/app/hooks/useMobileKeyboardLayout.ts
Normal file
97
overlay/src/app/hooks/useMobileKeyboardLayout.ts
Normal 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 };
|
||||||
|
};
|
||||||
57
overlay/src/app/hooks/useViewTransitions.ts
Normal file
57
overlay/src/app/hooks/useViewTransitions.ts
Normal 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]
|
||||||
|
);
|
||||||
|
};
|
||||||
20
overlay/src/app/pages/MobileFriendly.tsx
Normal file
20
overlay/src/app/pages/MobileFriendly.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type MobileFriendlyClientNavProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Server sidebar stays mounted on compact detail routes for swipe-back reveal. */
|
||||||
|
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MobileFriendlyPageNavProps = {
|
||||||
|
path: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Channel list stays mounted on compact detail routes for swipe-back reveal. */
|
||||||
|
export function MobileFriendlyPageNav({ path: _path, children }: MobileFriendlyPageNavProps) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
21
overlay/src/app/pages/client/ClientLayout.tsx
Normal file
21
overlay/src/app/pages/client/ClientLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
overlay/src/app/utils/viewTransitions.ts
Normal file
81
overlay/src/app/utils/viewTransitions.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -97,6 +97,11 @@ body {
|
|||||||
font-variant-ligatures: no-contextual;
|
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 */
|
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
|
||||||
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
|
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -184,6 +189,23 @@ body.mocha-theme {
|
|||||||
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disable route slide animation on compact/mobile — conflicts with swipe gestures */
|
||||||
|
@media (max-width: 750px) {
|
||||||
|
[data-route-transition='true'] {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.mobile-gesture-lock,
|
||||||
|
html.mobile-gesture-lock body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.mobile-gesture-lock [data-timeline-scroll] {
|
||||||
|
overflow: hidden !important;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Twilight theme enhanced transitions */
|
/* Twilight theme enhanced transitions */
|
||||||
.twilight-theme [data-route-transition="true"] {
|
.twilight-theme [data-route-transition="true"] {
|
||||||
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -504,3 +526,53 @@ html.android-capacitor body {
|
|||||||
word-spacing: normal;
|
word-spacing: normal;
|
||||||
font-variant-ligatures: 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
16
package-lock.json
generated
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.77",
|
"version": "4.11.121",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.77",
|
"version": "4.11.121",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "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": {
|
"devDependencies": {
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
@@ -82,6 +83,15 @@
|
|||||||
"@capacitor/core": ">=8.0.0"
|
"@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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.114",
|
"version": "4.11.123",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "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": {
|
"devDependencies": {
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user