Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca51f93ecd | ||
| 47c525cc5a | |||
| a282fce14a | |||
|
|
f1121839d4 | ||
| 585eeecb1b | |||
| 72dbbe29b9 | |||
|
|
b6000c1db8 | ||
| 83b4cbbd8e | |||
|
|
8753ed2600 | ||
| cced6b8ab9 | |||
| 82effc9680 | |||
|
|
deefb40277 | ||
| b496c70190 |
@@ -27,8 +27,8 @@ android {
|
||||
applicationId "com.paarrot.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1783526741
|
||||
versionName "2026-07-08.160541.766"
|
||||
versionCode 1783582912
|
||||
versionName "2026-07-09.074152.772"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
2
cinny
2
cinny
Submodule cinny updated: b4258278d8...9c7f71896c
32
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
32
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useCompactNav } from '../hooks/useCompactNav';
|
||||
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
|
||||
|
||||
/**
|
||||
* Wrapper for Outlet that adds route-based animation
|
||||
* Forces remount on route change by using location as key
|
||||
*/
|
||||
export function AnimatedOutlet() {
|
||||
const location = useLocation();
|
||||
const compact = useCompactNav();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
366
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
366
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
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 { 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, 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>
|
||||
);
|
||||
}
|
||||
79
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
79
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const SwipeBackRoot = style({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
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,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const CompactDetailLayer = style({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
203
overlay/src/app/components/page/Page.tsx
Normal file
203
overlay/src/app/components/page/Page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
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;
|
||||
|
||||
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} />
|
||||
));
|
||||
160
overlay/src/app/features/room/RoomView.tsx
Normal file
160
overlay/src/app/features/room/RoomView.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useEditor } from '../../components/editor';
|
||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||
import { RoomTimeline } from './RoomTimeline';
|
||||
import { RoomViewTyping } from './RoomViewTyping';
|
||||
import { RoomTombstone } from './RoomTombstone';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||
import { Page } from '../../components/page';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { editableActiveElement } from '../../utils/dom';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||
import { ThreadView } from './ThreadView';
|
||||
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
|
||||
|
||||
const FN_KEYS_REGEX = /^F\d+$/;
|
||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
const { code } = evt;
|
||||
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (FN_KEYS_REGEX.test(code)) return false;
|
||||
|
||||
if (
|
||||
code.startsWith('OS') ||
|
||||
code.startsWith('Meta') ||
|
||||
code.startsWith('Shift') ||
|
||||
code.startsWith('Alt') ||
|
||||
code.startsWith('Control') ||
|
||||
code.startsWith('Arrow') ||
|
||||
code.startsWith('Page') ||
|
||||
code.startsWith('End') ||
|
||||
code.startsWith('Home') ||
|
||||
code === 'Tab' ||
|
||||
code === 'Space' ||
|
||||
code === 'Enter' ||
|
||||
code === 'NumLock' ||
|
||||
code === 'ScrollLock'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
||||
|
||||
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (editableActiveElement()) return;
|
||||
const portalContainer = document.getElementById('portalContainer');
|
||||
if (portalContainer && portalContainer.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (shouldFocusMessageField(evt)) {
|
||||
evt.preventDefault();
|
||||
ReactEditor.focus(editor);
|
||||
if (evt.key.length === 1) {
|
||||
editor.insertText(evt.key);
|
||||
}
|
||||
} else if (isKeyHotkey('mod+v', evt)) {
|
||||
ReactEditor.focus(editor);
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<RoomViewHeader />
|
||||
{activeThreadId ? (
|
||||
<ThreadView room={room} threadRootId={activeThreadId} />
|
||||
) : (
|
||||
<>
|
||||
<MobileSwipeToReplyLayer room={room} editor={editor}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
</MobileSwipeToReplyLayer>
|
||||
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
{tombstoneEvent ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
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;
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -184,6 +184,23 @@ body.mocha-theme {
|
||||
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 [data-route-transition="true"] {
|
||||
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.112",
|
||||
"version": "4.11.117",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user