Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbcb291b9a | ||
| b7ec975fb0 | |||
|
|
3f5d5dec98 | ||
| c7b6e8f5f0 | |||
|
|
f709998c50 | ||
| ddcda65c1f | |||
| 6e32bf302a | |||
|
|
ca51f93ecd | ||
| 47c525cc5a | |||
| a282fce14a | |||
|
|
f1121839d4 | ||
| 585eeecb1b | |||
| 72dbbe29b9 | |||
|
|
b6000c1db8 | ||
| 83b4cbbd8e | |||
|
|
8753ed2600 | ||
| cced6b8ab9 | |||
| 82effc9680 |
@@ -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.
|
||||||
|
|||||||
40
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
40
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { useCompactNav, useIsCompactListRoute } from '../hooks/useCompactNav';
|
||||||
|
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outlet wrapper for route remounts + compact swipe-back chrome.
|
||||||
|
*
|
||||||
|
* On compact list routes the parent PageRoot already omits children. If this
|
||||||
|
* outlet is still mounted (stale stack / empty SpaceIndexRedirect), render
|
||||||
|
* nothing so an empty swipe shell cannot sit above the channel list and eat taps.
|
||||||
|
*/
|
||||||
|
export function AnimatedOutlet() {
|
||||||
|
const location = useLocation();
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const isListRoute = useIsCompactListRoute();
|
||||||
|
|
||||||
|
if (compact && isListRoute) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileSwipeGestureHost>
|
||||||
|
<div
|
||||||
|
key={location.pathname}
|
||||||
|
{...(!compact && { 'data-route-transition': 'true' })}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</MobileSwipeGestureHost>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { ReactNode, useLayoutEffect, 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 { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||||
|
import * as css from './mobile-gestures.css';
|
||||||
|
|
||||||
|
type MobileSwipeToReplyLayerProps = {
|
||||||
|
room: Room;
|
||||||
|
editor: Editor;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
|
||||||
|
const [indicatorActive, setIndicatorActive] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!compact) return;
|
||||||
|
|
||||||
|
mobileSwipeReplyBridgeRef.current = {
|
||||||
|
room,
|
||||||
|
editor,
|
||||||
|
setReplyDraft,
|
||||||
|
layerEl: layerRef.current,
|
||||||
|
setIndicator: (top, active) => {
|
||||||
|
setIndicatorTop(top);
|
||||||
|
setIndicatorActive(active);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (mobileSwipeReplyBridgeRef.current?.room.roomId === room.roomId) {
|
||||||
|
mobileSwipeReplyBridgeRef.current = null;
|
||||||
|
}
|
||||||
|
setIndicatorTop(null);
|
||||||
|
setIndicatorActive(false);
|
||||||
|
};
|
||||||
|
}, [compact, editor, room, setReplyDraft]);
|
||||||
|
|
||||||
|
if (!compact) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={layerRef} className={css.SwipeToReplyLayer}>
|
||||||
|
{children}
|
||||||
|
{indicatorTop !== null && (
|
||||||
|
<div
|
||||||
|
className={`${css.SwipeToReplyIndicator} ${
|
||||||
|
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
|
||||||
|
}`}
|
||||||
|
style={{ top: indicatorTop }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ReplyArrow} size="200" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
95
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const SwipeBackRoot = style({
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SwipeBackContent = style({
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.28)',
|
||||||
|
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({
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
});
|
||||||
|
|
||||||
|
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)',
|
||||||
|
});
|
||||||
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} />
|
||||||
|
));
|
||||||
165
overlay/src/app/features/room/RoomView.tsx
Normal file
165
overlay/src/app/features/room/RoomView.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
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';
|
||||||
|
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
|
||||||
|
|
||||||
|
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 { isLandscape, keyboardOpen } = useMobileKeyboardLayout();
|
||||||
|
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const composerPadding = isLandscape ? '0' : `0 ${config.space.S400}`;
|
||||||
|
const showHeader = !keyboardOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page ref={roomViewRef}>
|
||||||
|
{showHeader && <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: composerPadding }}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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
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;
|
||||||
|
};
|
||||||
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;
|
||||||
|
};
|
||||||
67
overlay/src/app/hooks/useMobileKeyboardLayout.ts
Normal file
67
overlay/src/app/hooks/useMobileKeyboardLayout.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const KEYBOARD_HEIGHT_THRESHOLD_PX = 120;
|
||||||
|
|
||||||
|
const getIsLandscape = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
if (window.matchMedia?.('(orientation: landscape)').matches) return true;
|
||||||
|
return window.innerWidth > window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKeyboardOpen = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
if (viewport) {
|
||||||
|
const obscured = window.innerHeight - viewport.height - viewport.offsetTop;
|
||||||
|
if (obscured > KEYBOARD_HEIGHT_THRESHOLD_PX) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!(active instanceof HTMLElement)) return false;
|
||||||
|
if (active.isContentEditable) return true;
|
||||||
|
const tag = active.tagName;
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MobileKeyboardLayout = {
|
||||||
|
isLandscape: boolean;
|
||||||
|
keyboardOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landscape + soft-keyboard layout signals for compact room chrome.
|
||||||
|
* Uses visualViewport when available (Capacitor/Android WebView).
|
||||||
|
*/
|
||||||
|
export const useMobileKeyboardLayout = (): MobileKeyboardLayout => {
|
||||||
|
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
|
||||||
|
const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
setIsLandscape(getIsLandscape());
|
||||||
|
setKeyboardOpen(getKeyboardOpen());
|
||||||
|
};
|
||||||
|
|
||||||
|
sync();
|
||||||
|
|
||||||
|
const orientationQuery = window.matchMedia?.('(orientation: landscape)');
|
||||||
|
orientationQuery?.addEventListener?.('change', sync);
|
||||||
|
window.addEventListener('resize', sync);
|
||||||
|
window.visualViewport?.addEventListener('resize', sync);
|
||||||
|
window.visualViewport?.addEventListener('scroll', sync);
|
||||||
|
window.addEventListener('focusin', sync);
|
||||||
|
window.addEventListener('focusout', sync);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
orientationQuery?.removeEventListener?.('change', sync);
|
||||||
|
window.removeEventListener('resize', sync);
|
||||||
|
window.visualViewport?.removeEventListener('resize', sync);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', sync);
|
||||||
|
window.removeEventListener('focusin', sync);
|
||||||
|
window.removeEventListener('focusout', sync);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isLandscape, keyboardOpen };
|
||||||
|
};
|
||||||
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 { keyboardOpen } = useMobileKeyboardLayout();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box grow="Yes">
|
||||||
|
{!keyboardOpen && <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);
|
||||||
|
};
|
||||||
@@ -184,6 +184,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 +521,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.113",
|
"version": "4.11.120",
|
||||||
"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"
|
||||||
|
|||||||
Reference in New Issue
Block a user