chore: update cinny submodule to latest commit for improved navigation and routing
This commit is contained in:
30
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
30
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for Outlet that adds route-based animation
|
||||||
|
* Forces remount on route change by using location as key
|
||||||
|
*/
|
||||||
|
export function AnimatedOutlet() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileSwipeBackPanel>
|
||||||
|
<div
|
||||||
|
key={location.pathname}
|
||||||
|
data-route-transition="true"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</MobileSwipeBackPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx
Normal file
180
overlay/src/app/components/mobile/MobileSwipeBackPanel.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||||
|
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||||
|
import * as css from './mobile-gestures.css';
|
||||||
|
|
||||||
|
const COMMIT_RATIO = 0.28;
|
||||||
|
const MIN_COMMIT_PX = 72;
|
||||||
|
const MAX_START_Y_RATIO = 0.88;
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
dragging: boolean;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MobileSwipeBackPanelProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) {
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const { canGoBack, goBack } = useBackRoute();
|
||||||
|
const enabled = compact && canGoBack;
|
||||||
|
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
|
const resetTransform = useCallback((animate = true) => {
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!content) return;
|
||||||
|
setAnimating(animate);
|
||||||
|
setOffset(0);
|
||||||
|
content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
|
||||||
|
content.style.transform = 'translateX(0px)';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTransform = useCallback((px: number, animate = false) => {
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!content) return;
|
||||||
|
setOffset(px);
|
||||||
|
content.style.transition = animate
|
||||||
|
? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||||
|
: 'none';
|
||||||
|
content.style.transform = `translateX(${px}px)`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commitBack = useCallback(() => {
|
||||||
|
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||||
|
setAnimating(true);
|
||||||
|
setTransform(width, true);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
goBack();
|
||||||
|
resetTransform(false);
|
||||||
|
setAnimating(false);
|
||||||
|
}, 180);
|
||||||
|
}, [goBack, resetTransform, setTransform]);
|
||||||
|
|
||||||
|
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element)) return true;
|
||||||
|
return Boolean(
|
||||||
|
target.closest(
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!enabled || animating || evt.button !== 0 || !evt.isPrimary) return;
|
||||||
|
if (shouldIgnoreTarget(evt.target)) return;
|
||||||
|
if (evt.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: evt.pointerId,
|
||||||
|
startX: evt.clientX,
|
||||||
|
startY: evt.clientY,
|
||||||
|
dragging: true,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
rootRef.current?.setPointerCapture(evt.pointerId);
|
||||||
|
},
|
||||||
|
[animating, enabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || !drag.dragging || drag.pointerId !== evt.pointerId) return;
|
||||||
|
|
||||||
|
const deltaX = evt.clientX - drag.startX;
|
||||||
|
const deltaY = evt.clientY - drag.startY;
|
||||||
|
|
||||||
|
if (!drag.moved) {
|
||||||
|
if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return;
|
||||||
|
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||||
|
dragRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deltaX <= 0) {
|
||||||
|
dragRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drag.moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
evt.preventDefault();
|
||||||
|
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||||
|
setTransform(Math.min(Math.max(deltaX, 0), width), false);
|
||||||
|
},
|
||||||
|
[setTransform]
|
||||||
|
);
|
||||||
|
|
||||||
|
const endDrag = useCallback(
|
||||||
|
(pointerId: number) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== pointerId) return;
|
||||||
|
|
||||||
|
if (rootRef.current?.hasPointerCapture(pointerId)) {
|
||||||
|
rootRef.current.releasePointerCapture(pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
dragRef.current = null;
|
||||||
|
|
||||||
|
if (!drag.moved) {
|
||||||
|
resetTransform(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||||
|
const shouldCommit = offset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX);
|
||||||
|
if (shouldCommit) {
|
||||||
|
commitBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTransform(true);
|
||||||
|
},
|
||||||
|
[commitBack, offset, resetTransform]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
endDrag(evt.pointerId);
|
||||||
|
},
|
||||||
|
[endDrag]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetTransform(false);
|
||||||
|
}, [enabled, resetTransform]);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={css.SwipeBackRoot}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
>
|
||||||
|
<div className={css.SwipeBackUnderlay} aria-hidden>
|
||||||
|
<div className={css.SwipeBackSidebarPeek} />
|
||||||
|
<div className={css.SwipeBackChannelPeek} />
|
||||||
|
</div>
|
||||||
|
<div ref={contentRef} className={css.SwipeBackContent}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx
Normal file
217
overlay/src/app/components/mobile/MobileSwipeToReplyLayer.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||||
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
|
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
||||||
|
import * as css from './mobile-gestures.css';
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD = 56;
|
||||||
|
const MAX_SWIPE = 88;
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
messageEl: HTMLElement;
|
||||||
|
messageId: string;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MobileSwipeToReplyLayerProps = {
|
||||||
|
room: Room;
|
||||||
|
editor: Editor;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const enabled = compact;
|
||||||
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
|
||||||
|
const [indicatorActive, setIndicatorActive] = useState(false);
|
||||||
|
|
||||||
|
const clearMessageTransform = useCallback((el?: HTMLElement | null, animate = true) => {
|
||||||
|
const target = el ?? dragRef.current?.messageEl;
|
||||||
|
if (!target) return;
|
||||||
|
target.style.transition = animate ? 'transform 0.18s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
|
||||||
|
target.style.transform = 'translateX(0px)';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetGesture = useCallback(() => {
|
||||||
|
clearMessageTransform();
|
||||||
|
dragRef.current = null;
|
||||||
|
setIndicatorTop(null);
|
||||||
|
setIndicatorActive(false);
|
||||||
|
}, [clearMessageTransform]);
|
||||||
|
|
||||||
|
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element)) return true;
|
||||||
|
if (
|
||||||
|
target.closest(
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-disable-swipe-reply="true"]'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let el: Element | null = target;
|
||||||
|
while (el && layerRef.current?.contains(el)) {
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
const { overflowX } = window.getComputedStyle(el);
|
||||||
|
if (
|
||||||
|
(overflowX === 'auto' || overflowX === 'scroll') &&
|
||||||
|
el.scrollWidth > el.clientWidth + 8
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
return target.closest('[data-message-id]') as HTMLElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
const messageRect = messageEl.getBoundingClientRect();
|
||||||
|
const layerRect = layer.getBoundingClientRect();
|
||||||
|
setIndicatorTop(messageRect.top - layerRect.top + messageRect.height / 2 - 18);
|
||||||
|
setIndicatorActive(Math.abs(offset) >= SWIPE_THRESHOLD * 0.65);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!enabled || evt.button !== 0 || !evt.isPrimary) return;
|
||||||
|
if (shouldIgnoreTarget(evt.target)) return;
|
||||||
|
|
||||||
|
const messageEl = findMessageElement(evt.target);
|
||||||
|
const messageId = messageEl?.getAttribute('data-message-id');
|
||||||
|
if (!messageEl || !messageId) return;
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: evt.pointerId,
|
||||||
|
startX: evt.clientX,
|
||||||
|
startY: evt.clientY,
|
||||||
|
messageEl,
|
||||||
|
messageId,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
layerRef.current?.setPointerCapture(evt.pointerId);
|
||||||
|
},
|
||||||
|
[enabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== evt.pointerId) return;
|
||||||
|
|
||||||
|
const deltaX = evt.clientX - drag.startX;
|
||||||
|
const deltaY = evt.clientY - drag.startY;
|
||||||
|
|
||||||
|
if (!drag.moved) {
|
||||||
|
if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return;
|
||||||
|
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||||
|
resetGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deltaX >= 0) {
|
||||||
|
resetGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drag.moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
evt.preventDefault();
|
||||||
|
const offset = Math.max(deltaX, -MAX_SWIPE);
|
||||||
|
drag.messageEl.style.transition = 'none';
|
||||||
|
drag.messageEl.style.transform = `translateX(${offset}px)`;
|
||||||
|
updateIndicator(drag.messageEl, offset);
|
||||||
|
},
|
||||||
|
[resetGesture, updateIndicator]
|
||||||
|
);
|
||||||
|
|
||||||
|
const endDrag = useCallback(
|
||||||
|
(pointerId: number) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== pointerId) return;
|
||||||
|
|
||||||
|
if (layerRef.current?.hasPointerCapture(pointerId)) {
|
||||||
|
layerRef.current.releasePointerCapture(pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageEl, messageId, moved } = drag;
|
||||||
|
dragRef.current = null;
|
||||||
|
|
||||||
|
if (!moved) {
|
||||||
|
resetGesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matrix = window.getComputedStyle(messageEl).transform;
|
||||||
|
const offset =
|
||||||
|
matrix && matrix !== 'none'
|
||||||
|
? Number(new DOMMatrix(matrix).m41)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const shouldReply = Math.abs(offset) >= SWIPE_THRESHOLD;
|
||||||
|
clearMessageTransform(messageEl, true);
|
||||||
|
setIndicatorTop(null);
|
||||||
|
setIndicatorActive(false);
|
||||||
|
|
||||||
|
if (shouldReply) {
|
||||||
|
startReplyToEvent(room, messageId, setReplyDraft, editor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearMessageTransform, editor, resetGesture, room, setReplyDraft]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback(
|
||||||
|
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
endDrag(evt.pointerId);
|
||||||
|
},
|
||||||
|
[endDrag]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => resetGesture, [room.roomId, resetGesture]);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={layerRef}
|
||||||
|
className={css.SwipeToReplyLayer}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{indicatorTop !== null && (
|
||||||
|
<div
|
||||||
|
className={`${css.SwipeToReplyIndicator} ${
|
||||||
|
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
|
||||||
|
}`}
|
||||||
|
style={{ top: indicatorTop }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ReplyArrow} size="200" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
78
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const SwipeBackRoot = style({
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SwipeBackUnderlay = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SwipeBackSidebarPeek = style({
|
||||||
|
width: toRem(66),
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SwipeBackChannelPeek = style({
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SwipeBackContent = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)',
|
||||||
|
willChange: 'transform',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SwipeToReplyLayer = style({
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
touchAction: 'pan-y',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SwipeToReplyIndicator = style({
|
||||||
|
position: 'absolute',
|
||||||
|
right: config.space.S300,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: toRem(36),
|
||||||
|
height: toRem(36),
|
||||||
|
borderRadius: config.radii.Pill,
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
color: color.Primary.OnContainer,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'scale(0.85)',
|
||||||
|
transition: 'opacity 0.12s ease, transform 0.12s ease',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SwipeToReplyIndicatorActive = style({
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'scale(1)',
|
||||||
|
});
|
||||||
160
overlay/src/app/features/room/RoomView.tsx
Normal file
160
overlay/src/app/features/room/RoomView.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
import { Box, Text, config } from 'folds';
|
||||||
|
import { EventType, Room } from 'matrix-js-sdk';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useEditor } from '../../components/editor';
|
||||||
|
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||||
|
import { RoomTimeline } from './RoomTimeline';
|
||||||
|
import { RoomViewTyping } from './RoomViewTyping';
|
||||||
|
import { RoomTombstone } from './RoomTombstone';
|
||||||
|
import { RoomInput } from './RoomInput';
|
||||||
|
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||||
|
import { Page } from '../../components/page';
|
||||||
|
import { RoomViewHeader } from './RoomViewHeader';
|
||||||
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
|
import { editableActiveElement } from '../../utils/dom';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
import { ThreadView } from './ThreadView';
|
||||||
|
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
|
||||||
|
|
||||||
|
const FN_KEYS_REGEX = /^F\d+$/;
|
||||||
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
|
const { code } = evt;
|
||||||
|
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FN_KEYS_REGEX.test(code)) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
code.startsWith('OS') ||
|
||||||
|
code.startsWith('Meta') ||
|
||||||
|
code.startsWith('Shift') ||
|
||||||
|
code.startsWith('Alt') ||
|
||||||
|
code.startsWith('Control') ||
|
||||||
|
code.startsWith('Arrow') ||
|
||||||
|
code.startsWith('Page') ||
|
||||||
|
code.startsWith('End') ||
|
||||||
|
code.startsWith('Home') ||
|
||||||
|
code === 'Tab' ||
|
||||||
|
code === 'Space' ||
|
||||||
|
code === 'Enter' ||
|
||||||
|
code === 'NumLock' ||
|
||||||
|
code === 'ScrollLock'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||||
|
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||||
|
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
|
||||||
|
const { roomId } = room;
|
||||||
|
const editor = useEditor();
|
||||||
|
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||||
|
const powerLevels = usePowerLevelsContext();
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
|
||||||
|
|
||||||
|
useKeyDown(
|
||||||
|
window,
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (editableActiveElement()) return;
|
||||||
|
const portalContainer = document.getElementById('portalContainer');
|
||||||
|
if (portalContainer && portalContainer.children.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldFocusMessageField(evt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
if (evt.key.length === 1) {
|
||||||
|
editor.insertText(evt.key);
|
||||||
|
}
|
||||||
|
} else if (isKeyHotkey('mod+v', evt)) {
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page ref={roomViewRef}>
|
||||||
|
<RoomViewHeader />
|
||||||
|
{activeThreadId ? (
|
||||||
|
<ThreadView room={room} threadRootId={activeThreadId} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MobileSwipeToReplyLayer room={room} editor={editor}>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<RoomTimeline
|
||||||
|
key={roomId}
|
||||||
|
room={room}
|
||||||
|
eventId={eventId}
|
||||||
|
roomInputRef={roomInputRef}
|
||||||
|
editor={editor}
|
||||||
|
/>
|
||||||
|
<RoomViewTyping room={room} />
|
||||||
|
</Box>
|
||||||
|
</MobileSwipeToReplyLayer>
|
||||||
|
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
|
||||||
|
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||||
|
{tombstoneEvent ? (
|
||||||
|
<RoomTombstone
|
||||||
|
roomId={roomId}
|
||||||
|
body={tombstoneEvent.getContent().body}
|
||||||
|
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{canMessage && (
|
||||||
|
<RoomInput
|
||||||
|
room={room}
|
||||||
|
editor={editor}
|
||||||
|
roomId={roomId}
|
||||||
|
fileDropContainerRef={roomViewRef}
|
||||||
|
ref={roomInputRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!canMessage && (
|
||||||
|
<RoomInputPlaceholder
|
||||||
|
style={{ padding: config.space.S200 }}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
>
|
||||||
|
<Text align="Center">You do not have permission to post in this room</Text>
|
||||||
|
</RoomInputPlaceholder>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
overlay/src/app/features/room/replyToMessage.ts
Normal file
41
overlay/src/app/features/room/replyToMessage.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { RelationType, Room } from 'matrix-js-sdk';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { getEditedEvent } from '../../utils/room';
|
||||||
|
import { IReplyDraft } from '../../state/room/roomInputDrafts';
|
||||||
|
|
||||||
|
export const startReplyToEvent = (
|
||||||
|
room: Room,
|
||||||
|
eventId: string,
|
||||||
|
setReplyDraft: (draft: IReplyDraft | undefined) => void,
|
||||||
|
editor?: Editor,
|
||||||
|
startThread = false
|
||||||
|
): boolean => {
|
||||||
|
const replyEvt = room.findEventById(eventId);
|
||||||
|
if (!replyEvt || replyEvt.isRedacted()) return false;
|
||||||
|
|
||||||
|
const editedReply = getEditedEvent(eventId, replyEvt, room.getUnfilteredTimelineSet());
|
||||||
|
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
|
const { body, formatted_body: formattedBody } = content;
|
||||||
|
const senderId = replyEvt.getSender();
|
||||||
|
|
||||||
|
if (!senderId || typeof body !== 'string') return false;
|
||||||
|
|
||||||
|
const relation = startThread
|
||||||
|
? { rel_type: RelationType.Thread, event_id: eventId }
|
||||||
|
: replyEvt.getWireContent()['m.relates_to'];
|
||||||
|
|
||||||
|
setReplyDraft({
|
||||||
|
userId: senderId,
|
||||||
|
eventId,
|
||||||
|
body,
|
||||||
|
formattedBody,
|
||||||
|
relation,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user