+ {children}
+ {indicatorTop !== null && (
+
+
+
+ )}
+
+ );
+}
diff --git a/overlay/src/app/components/mobile/mobile-gestures.css.ts b/overlay/src/app/components/mobile/mobile-gestures.css.ts
new file mode 100644
index 0000000..a5eb552
--- /dev/null
+++ b/overlay/src/app/components/mobile/mobile-gestures.css.ts
@@ -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)',
+});
diff --git a/overlay/src/app/features/room/RoomView.tsx b/overlay/src/app/features/room/RoomView.tsx
new file mode 100644
index 0000000..47e478d
--- /dev/null
+++ b/overlay/src/app/features/room/RoomView.tsx
@@ -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