Compare commits

...

5 Commits

Author SHA1 Message Date
GitHub Actions
b6000c1db8 chore: bump version to 4.11.115 [skip ci] 2026-07-09 07:42:59 +00:00
83b4cbbd8e chore: update cinny submodule to latest commit and modify versioning for Android app
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 13s
2026-07-09 17:42:28 +10:00
GitHub Actions
8753ed2600 chore: bump version to 4.11.114 [skip ci] 2026-07-09 07:23:56 +00:00
cced6b8ab9 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m41s
Build / create-release (push) Successful in 15s
2026-07-09 17:23:47 +10:00
82effc9680 chore: update cinny submodule to latest commit for improved navigation and routing 2026-07-09 17:23:45 +10:00
10 changed files with 838 additions and 3 deletions

View File

@@ -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.

View 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>
);
}

View File

@@ -0,0 +1,222 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useCompactNav } from '../../hooks/useCompactNav';
import { useBackRoute } from '../../hooks/useBackRoute';
import {
claimMobileGesture,
clearMobileGesture,
getActiveMobileGesture,
} from './mobileGestureArbitration';
import { useWindowPointerDrag } from './useWindowPointerDrag';
import * as css from './mobile-gestures.css';
const COMMIT_RATIO = 0.28;
const MIN_COMMIT_PX = 72;
const MAX_START_Y_RATIO = 0.88;
const DRAG_THRESHOLD = 8;
type DragState = {
pointerId: number;
startX: number;
startY: number;
moved: boolean;
offset: number;
};
type MobileSwipeBackPanelProps = {
children: ReactNode;
};
const readTransformOffset = (el: HTMLElement | null): number => {
if (!el) return 0;
const transform = window.getComputedStyle(el).transform;
if (!transform || transform === 'none') return 0;
return new DOMMatrix(transform).m41;
};
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 [animating, setAnimating] = useState(false);
const resetTransform = useCallback((animate = true) => {
const content = contentRef.current;
if (!content) return;
setAnimating(animate);
content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
content.style.transform = 'translateX(0px)';
if (dragRef.current) {
dragRef.current.offset = 0;
}
}, []);
const setTransform = useCallback((px: number, animate = false) => {
const content = contentRef.current;
if (!content) return;
if (dragRef.current) {
dragRef.current.offset = 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 releaseCapture = useCallback((pointerId: number) => {
const root = rootRef.current;
if (root?.hasPointerCapture(pointerId)) {
root.releasePointerCapture(pointerId);
}
}, []);
const endDrag = useCallback(
(pointerId: number) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== pointerId) return;
releaseCapture(pointerId);
dragRef.current = null;
clearMobileGesture(pointerId);
if (!drag.moved) {
resetTransform(false);
return;
}
const width = rootRef.current?.clientWidth ?? window.innerWidth;
const currentOffset = Math.max(
drag.offset,
readTransformOffset(contentRef.current)
);
const shouldCommit = currentOffset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX);
if (shouldCommit) {
commitBack();
return;
}
resetTransform(true);
},
[commitBack, releaseCapture, resetTransform]
);
const processPointerMove = useCallback(
(evt: { pointerId: number; clientX: number; clientY: number; preventDefault?: () => void }) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== evt.pointerId) return;
const activeGesture = getActiveMobileGesture(evt.pointerId);
if (activeGesture && activeGesture !== 'back') return;
const deltaX = evt.clientX - drag.startX;
const deltaY = evt.clientY - drag.startY;
if (!drag.moved) {
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
dragRef.current = null;
clearMobileGesture(evt.pointerId);
return;
}
if (deltaX <= 0) {
dragRef.current = null;
clearMobileGesture(evt.pointerId);
return;
}
if (!claimMobileGesture('back', evt.pointerId)) return;
drag.moved = true;
try {
rootRef.current?.setPointerCapture(evt.pointerId);
} catch {
// Ignore capture failures on Android WebView.
}
}
evt.preventDefault?.();
const width = rootRef.current?.clientWidth ?? window.innerWidth;
setTransform(Math.min(Math.max(deltaX, 0), width), false);
},
[setTransform]
);
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,
moved: false,
offset: 0,
};
},
[animating, enabled]
);
const isActivePointer = useCallback((pointerId: number) => {
return dragRef.current?.pointerId === pointerId;
}, []);
useWindowPointerDrag({
enabled,
isActivePointer,
onMove: processPointerMove,
onEnd: endDrag,
});
useEffect(() => {
resetTransform(false);
}, [enabled, resetTransform]);
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={rootRef}
className={css.SwipeBackRoot}
onPointerDown={handlePointerDown}
onPointerMove={processPointerMove}
onPointerUp={(evt) => endDrag(evt.pointerId)}
onPointerCancel={(evt) => endDrag(evt.pointerId)}
onLostPointerCapture={(evt) => endDrag(evt.pointerId)}
>
<div className={css.SwipeBackUnderlay} aria-hidden>
<div className={css.SwipeBackSidebarPeek} />
<div className={css.SwipeBackChannelPeek} />
</div>
<div ref={contentRef} className={css.SwipeBackContent}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
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 {
claimMobileGesture,
clearMobileGesture,
getActiveMobileGesture,
} from './mobileGestureArbitration';
import { useWindowPointerDrag } from './useWindowPointerDrag';
import * as css from './mobile-gestures.css';
const SWIPE_THRESHOLD = 56;
const MAX_SWIPE = 88;
const DRAG_THRESHOLD = 8;
type DragState = {
pointerId: number;
startX: number;
startY: number;
messageEl: HTMLElement;
messageId: string;
moved: boolean;
offset: number;
};
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 releaseCapture = useCallback((pointerId: number) => {
const layer = layerRef.current;
if (layer?.hasPointerCapture(pointerId)) {
layer.releasePointerCapture(pointerId);
}
}, []);
const resetGesture = useCallback(
(pointerId?: number) => {
if (pointerId !== undefined) {
releaseCapture(pointerId);
clearMobileGesture(pointerId);
}
clearMessageTransform();
dragRef.current = null;
setIndicatorTop(null);
setIndicatorActive(false);
},
[clearMessageTransform, releaseCapture]
);
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"], [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 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 endDrag = useCallback(
(pointerId: number) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== pointerId) return;
releaseCapture(pointerId);
const { messageEl, messageId, moved, offset } = drag;
dragRef.current = null;
clearMobileGesture(pointerId);
if (!moved) {
resetGesture();
return;
}
const shouldReply = Math.abs(offset) >= SWIPE_THRESHOLD;
clearMessageTransform(messageEl, true);
setIndicatorTop(null);
setIndicatorActive(false);
if (shouldReply) {
startReplyToEvent(room, messageId, setReplyDraft, editor);
}
},
[clearMessageTransform, editor, releaseCapture, resetGesture, room, setReplyDraft]
);
const processPointerMove = useCallback(
(evt: { pointerId: number; clientX: number; clientY: number; preventDefault?: () => void }) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== evt.pointerId) return;
const activeGesture = getActiveMobileGesture(evt.pointerId);
if (activeGesture && activeGesture !== 'reply') return;
const deltaX = evt.clientX - drag.startX;
const deltaY = evt.clientY - drag.startY;
if (!drag.moved) {
if (Math.abs(deltaX) < DRAG_THRESHOLD && Math.abs(deltaY) < DRAG_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
resetGesture(evt.pointerId);
return;
}
if (deltaX >= 0) {
resetGesture(evt.pointerId);
return;
}
if (!claimMobileGesture('reply', evt.pointerId)) return;
drag.moved = true;
try {
layerRef.current?.setPointerCapture(evt.pointerId);
} catch {
// Ignore capture failures on Android WebView.
}
}
evt.preventDefault?.();
const offset = Math.max(deltaX, -MAX_SWIPE);
drag.offset = offset;
drag.messageEl.style.transition = 'none';
drag.messageEl.style.transform = `translateX(${offset}px)`;
updateIndicator(drag.messageEl, offset);
},
[resetGesture, updateIndicator]
);
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,
offset: 0,
};
},
[enabled]
);
const isActivePointer = useCallback((pointerId: number) => {
return dragRef.current?.pointerId === pointerId;
}, []);
useWindowPointerDrag({
enabled,
isActivePointer,
onMove: processPointerMove,
onEnd: endDrag,
});
useEffect(() => {
resetGesture();
}, [room.roomId, resetGesture]);
if (!enabled) {
return <>{children}</>;
}
return (
<div
ref={layerRef}
className={css.SwipeToReplyLayer}
onPointerDown={handlePointerDown}
onPointerMove={processPointerMove}
onPointerUp={(evt) => endDrag(evt.pointerId)}
onPointerCancel={(evt) => endDrag(evt.pointerId)}
onLostPointerCapture={(evt) => endDrag(evt.pointerId)}
>
{children}
{indicatorTop !== null && (
<div
className={`${css.SwipeToReplyIndicator} ${
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
}`}
style={{ top: indicatorTop }}
aria-hidden
>
<Icon src={Icons.ReplyArrow} size="200" />
</div>
)}
</div>
);
}

View 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',
touchAction: 'pan-y',
});
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)',
});

View File

@@ -0,0 +1,27 @@
export type MobileGestureKind = 'back' | 'reply';
type ActiveGesture = {
kind: MobileGestureKind;
pointerId: number;
};
let activeGesture: ActiveGesture | null = null;
export const claimMobileGesture = (kind: MobileGestureKind, pointerId: number): boolean => {
if (!activeGesture || activeGesture.pointerId === pointerId) {
activeGesture = { kind, pointerId };
return true;
}
return activeGesture.kind === kind && activeGesture.pointerId === pointerId;
};
export const getActiveMobileGesture = (pointerId: number): MobileGestureKind | null => {
if (!activeGesture || activeGesture.pointerId !== pointerId) return null;
return activeGesture.kind;
};
export const clearMobileGesture = (pointerId: number) => {
if (activeGesture?.pointerId === pointerId) {
activeGesture = null;
}
};

View File

@@ -0,0 +1,46 @@
import { useEffect } from 'react';
type PointerLikeEvent = {
pointerId: number;
clientX: number;
clientY: number;
preventDefault?: () => void;
};
type UseWindowPointerDragOptions = {
enabled: boolean;
isActivePointer: (pointerId: number) => boolean;
onMove: (evt: PointerLikeEvent) => void;
onEnd: (pointerId: number) => void;
};
export const useWindowPointerDrag = ({
enabled,
isActivePointer,
onMove,
onEnd,
}: UseWindowPointerDragOptions) => {
useEffect(() => {
if (!enabled) return;
const handlePointerMove = (evt: PointerEvent) => {
if (!isActivePointer(evt.pointerId)) return;
onMove(evt);
};
const handlePointerEnd = (evt: PointerEvent) => {
if (!isActivePointer(evt.pointerId)) return;
onEnd(evt.pointerId);
};
window.addEventListener('pointermove', handlePointerMove, true);
window.addEventListener('pointerup', handlePointerEnd, true);
window.addEventListener('pointercancel', handlePointerEnd, true);
return () => {
window.removeEventListener('pointermove', handlePointerMove, true);
window.removeEventListener('pointerup', handlePointerEnd, true);
window.removeEventListener('pointercancel', handlePointerEnd, true);
};
}, [enabled, isActivePointer, onEnd, onMove]);
};

View 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>
);
}

View 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;
};

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.113",
"version": "4.11.115",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"