Compare commits

..

28 Commits

Author SHA1 Message Date
GitHub Actions
8d2e35d1bc chore: bump version to 4.11.107 [skip ci] 2026-05-13 00:40:41 +00:00
6845398755 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m32s
Build / create-release (push) Successful in 14s
2026-05-13 10:40:34 +10:00
be52b0fa08 fix(mobile): update version code and name, adjust padding in ShareRoomPicker component 2026-05-13 10:40:31 +10:00
GitHub Actions
298bcfd1e3 chore: bump version to 4.11.106 [skip ci] 2026-05-13 00:24:11 +00:00
fce1736312 fix(mobile): increase top padding to 60px for status bar, reduce dialog height
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m44s
Build / create-release (push) Successful in 14s
2026-05-13 10:24:02 +10:00
GitHub Actions
e11304d093 chore: bump version to 4.11.105 [skip ci] 2026-05-13 00:15:56 +00:00
60d76d5165 feat(mobile): add confirmation dialog back to share picker with backdrop
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m35s
Build / create-release (push) Successful in 14s
2026-05-13 10:15:47 +10:00
GitHub Actions
373e700a07 chore: bump version to 4.11.104 [skip ci] 2026-05-13 00:13:42 +00:00
f7b25261cc fix(mobile): make share picker fullscreen with full-width search, remove confirmation dialog
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Has started running
Build / create-release (push) Has been cancelled
2026-05-13 10:13:35 +10:00
GitHub Actions
41e1b0db9d chore: bump version to 4.11.103 [skip ci] 2026-05-13 00:08:24 +00:00
d1022635d2 feat(mobile): improve share dialog UI with padding, full-width search, and confirmation
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 14s
2026-05-13 10:08:16 +10:00
GitHub Actions
2e6be4b0b3 chore: bump version to 4.11.102 [skip ci] 2026-05-12 23:59:14 +00:00
0d97676edd fix(mobile): send shared content immediately instead of staging in draft
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m51s
Build / create-release (push) Successful in 14s
2026-05-13 09:59:07 +10:00
GitHub Actions
f8e8058de3 chore: bump version to 4.11.101 [skip ci] 2026-05-12 23:54:07 +00:00
a1577a3b97 fix(mobile): navigate to room after share picker selection
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m28s
Build / create-release (push) Successful in 15s
2026-05-13 09:53:57 +10:00
GitHub Actions
80f1df6588 chore: bump version to 4.11.100 [skip ci] 2026-05-12 23:44:23 +00:00
375fa74309 fix: restore live server validation while keeping input stable during typing
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m48s
Build / create-release (push) Successful in 14s
2026-05-13 09:44:14 +10:00
GitHub Actions
0862094e00 chore: bump version to 4.11.99 [skip ci] 2026-05-12 23:41:58 +00:00
45d66bbe28 fix(mobile): prevent server input focus loss and text modification while typing
Some checks failed
Build / increment-version (push) Successful in 6s
Build / create-release (push) Has been cancelled
Build / build-android (push) Has been cancelled
2026-05-13 09:41:49 +10:00
GitHub Actions
919bc310a7 chore: bump version to 4.11.98 [skip ci] 2026-05-12 23:32:30 +00:00
badcfb160c fix(mobile): handle Android keyboard autocorrect text duplication in overlay
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-05-13 09:32:22 +10:00
GitHub Actions
1b35f71bb8 chore: bump version to 4.11.97 [skip ci] 2026-05-12 22:59:45 +00:00
96349bf200 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m26s
Build / create-release (push) Successful in 14s
2026-05-13 08:59:38 +10:00
472d30940d chore: remove extra whitespace in README.md 2026-05-13 08:59:36 +10:00
GitHub Actions
ae9d2bfeb6 chore: bump version to 4.11.96 [skip ci] 2026-05-12 22:52:08 +00:00
61b82d1f40 fix: use consistent signing cert for all builds to prevent update conflicts
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m53s
Build / create-release (push) Successful in 14s
2026-05-13 08:51:57 +10:00
GitHub Actions
b008011d5f chore: bump version to 4.11.95 [skip ci] 2026-05-12 22:42:45 +00:00
938f000f65 chore: remove extra newline in README.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m44s
Build / create-release (push) Successful in 14s
2026-05-13 08:42:35 +10:00
10 changed files with 688 additions and 165 deletions

4
android/.gitignore vendored
View File

@@ -52,10 +52,8 @@ captures/
# Comment next line if keeping position of elements in Navigation Editor is relevant for you # Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml .idea/navEditor.xml
# Keystore files - NEVER commit these! # Keystore files
*.jks *.jks
*.keystore
keystore.properties
# External native build folder generated in Android Studio 2.2 and later # External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild .externalNativeBuild

View File

@@ -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 1777176952 versionCode 1778632712
versionName "2026-04-26.041552.151" versionName "2026-05-13.003832.344"
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.

View File

@@ -0,0 +1,4 @@
storeFile=../paarrot-release.keystore
storePassword=paarrot2026
keyAlias=paarrot
keyPassword=paarrot2026

Binary file not shown.

2
cinny

Submodule cinny updated: 7ef9939d8a...b2fd65c8cb

View File

@@ -0,0 +1,269 @@
/* eslint-disable no-param-reassign */
import React, {
ClipboardEventHandler,
KeyboardEventHandler,
ReactNode,
forwardRef,
useCallback,
useState,
useEffect,
useRef,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
import {
Slate,
Editable,
withReact,
RenderLeafProps,
RenderElementProps,
RenderPlaceholderProps,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
import { createCommandElement } from './utils';
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];
const withInline = (editor: Editor): Editor => {
const { isInline } = editor;
editor.isInline = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
element.type
) || isInline(element);
return editor;
};
const withVoid = (editor: Editor): Editor => {
const { isVoid } = editor;
editor.isVoid = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
isVoid(element);
return editor;
};
type CommandValidatorFn = (commandName: string) => boolean;
let commandValidator: CommandValidatorFn | null = null;
export const setCommandValidator = (validator: CommandValidatorFn) => {
commandValidator = validator;
};
const withCommandAutoConvert = (editor: Editor): Editor => {
// Removed auto-conversion on space to prevent focus issues
// Commands are now highlighted visually via decorations
// and executed when Enter is pressed
return editor;
};
export const useEditor = (): Editor => {
const [editor] = useState(() =>
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
);
return editor;
};
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
editor: Editor;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler;
onPaste?: ClipboardEventHandler;
};
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
editableName,
top,
bottom,
before,
after,
maxHeight = '50vh',
editor,
placeholder,
onKeyDown,
onKeyUp,
onChange,
onPaste,
},
ref
) => {
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
);
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
const decorate = useCallback(([node, path]: [any, number[]]) => {
const ranges: any[] = [];
// Only decorate text nodes in the first paragraph
if (
path.length === 2 &&
path[0] === 0 &&
path[1] === 0 &&
SlateText.isText(node)
) {
const firstChild = editor.children[0];
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
const [firstInline, secondInline] = firstChild.children;
// Don't decorate if we already have a CommandElement
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
return ranges;
}
// Check if the text matches /command pattern
const text = node.text;
const match = text.match(/^(\s*\/\w+)/);
if (match && commandValidator) {
const commandText = match[1].trim();
const commandName = commandText.substring(1);
if (commandValidator(commandName)) {
// Create decoration for the command text
ranges.push({
anchor: { path, offset: 0 },
focus: { path, offset: match[1].length },
pendingCommand: true,
});
}
}
}
}
return ranges;
}, [editor]);
const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
onKeyDown?.(evt);
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
if (shortcutToggled) evt.preventDefault();
},
[editor, onKeyDown]
);
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle autocorrect replacement that causes text duplication
if (inputEvent.inputType === 'insertReplacementText' ||
inputEvent.inputType === 'insertFromComposition') {
const { selection } = editor;
if (!selection) return;
// Get the data being inserted
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (data) {
event.preventDefault();
// If there's selected text, delete it first
if (selection && !Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
}
// Insert the replacement text
editor.insertText(data);
}
}
},
[editor]
);
const editableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
if (!editableElement) return;
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
return () => {
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
};
}, [handleBeforeInput]);
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
{/* Inner component to style the actual text position and appearance */}
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
{children}
</Text>
</span>
),
[]
);
return (
<div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before}
</Box>
)}
<Scroll
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={{ maxHeight }}
size="300"
visibility="Hover"
hideTrack
ref={editableRef}
>
<Editable
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}
renderElement={renderElement}
renderLeaf={renderLeaf}
decorate={decorate}
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}
/>
</Scroll>
{after && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{after}
</Box>
)}
</Box>
{bottom}
</Slate>
</div>
);
}
);

View File

@@ -5,6 +5,7 @@ import FocusTrap from 'focus-trap-react';
import { import {
Avatar, Avatar,
Box, Box,
Button,
Header, Header,
Icon, Icon,
IconButton, IconButton,
@@ -62,6 +63,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const allRoomsSet = useAllJoinedRoomsSet(); const allRoomsSet = useAllJoinedRoomsSet();
@@ -107,149 +109,208 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => { const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
const roomId = evt.currentTarget.getAttribute('data-room-id'); const roomId = evt.currentTarget.getAttribute('data-room-id');
if (roomId) { if (roomId) {
onPick(roomId); setConfirmRoomId(roomId);
} }
}; };
const handleConfirm = useCallback(() => {
if (confirmRoomId) {
onPick(confirmRoomId);
}
}, [confirmRoomId, onPick]);
const handleCancelConfirm = useCallback(() => {
setConfirmRoomId(null);
}, []);
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80); const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
const fileCount = share.files.length; const fileCount = share.files.length;
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <>
<OverlayCenter> <Overlay open>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
onDeactivate: onDismiss, onDeactivate: onDismiss,
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}}
>
<Box
grow="Yes"
direction="Column"
style={{
position: 'fixed',
inset: 0,
zIndex: 999,
backgroundColor: '#1a1d23',
paddingTop: '60px',
}} }}
> >
<Modal size="300"> <Header
<Box grow="Yes" direction="Column" style={{ maxHeight: '85vh' }}> size="500"
<Header style={{ padding: config.space.S400 }}
size="500" >
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }} <Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
> <Text size="H4">Share to Room</Text>
<Box grow="Yes" direction="Column" gap="100"> {(previewText || fileCount > 0) && (
<Text size="H4">Share to Room</Text> <Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{(previewText || fileCount > 0) && ( {previewText
<Text size="T200" priority="300" style={{ opacity: 0.7 }}> ? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
{previewText : `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"` </Text>
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`} )}
</Text> </Box>
)} <Box shrink="No">
</Box> <IconButton size="300" radii="300" onClick={onDismiss}>
<Box shrink="No"> <Icon src={Icons.Cross} />
<IconButton size="300" radii="300" onClick={onDismiss}> </IconButton>
<Icon src={Icons.Cross} /> </Box>
</IconButton> </Header>
</Box>
</Header>
<Box <Box
style={{ style={{
padding: `0 ${config.space.S300}`, padding: config.space.S200,
paddingTop: config.space.S200, paddingTop: config.space.S300,
paddingBottom: config.space.S200, }}
}} >
> <Input
<Input onChange={handleSearchChange}
onChange={handleSearchChange} before={<Icon size="200" src={Icons.Search} />}
before={<Icon size="200" src={Icons.Search} />} placeholder="Search rooms…"
placeholder="Search rooms…" size="400"
size="400" variant="Background"
variant="Background" outlined
outlined autoFocus
autoFocus style={{ width: '100%' }}
/> />
</Box> </Box>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}> <Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Box style={{ padding: config.space.S300 }} direction="Column">
{vItems.length === 0 && (
<Box <Box
style={{ padding: config.space.S300, paddingTop: 0 }} style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column" direction="Column"
gap="100"
> >
{vItems.length === 0 && ( <Text size="H6" align="Center">
<Box {searchResult ? 'No Match Found' : 'No Rooms'}
style={{ padding: `${config.space.S700} 0` }} </Text>
alignItems="Center" <Text size="T200" align="Center" priority="300">
justifyContent="Center" {searchResult
direction="Column" ? `No rooms found for "${searchResult.query}".`
gap="100" : 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
> >
<Text size="H6" align="Center"> <MenuItem
{searchResult ? 'No Match Found' : 'No Rooms'} data-room-id={roomId}
</Text> onClick={handleRoomClick}
<Text size="T200" align="Center" priority="300"> variant="Surface"
{searchResult size="400"
? `No rooms found for "${searchResult.query}".` radii="400"
: 'You have no rooms to share into.'} before={
</Text> <Avatar size="300" radii={isDm ? '400' : '300'}>
</Box> {avatarSrc ? (
)} <RoomAvatar
<Box roomId={roomId}
style={{ position: 'relative', height: virtualizer.getTotalSize() }} src={avatarSrc}
> alt={room.name}
{vItems.map((vItem) => { renderFallback={() => (
const roomId = items[vItem.index]; <Text as="span" size="H6">
const room = getRoom(roomId); {nameInitials(room.name)}
if (!room) return null; </Text>
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon room={room} size="200" filled />
)} )}
</Avatar> />
} ) : (
> <RoomIcon room={room} size="200" filled />
<Box grow="Yes" direction="Column"> )}
<Text size="B400" truncate> </Avatar>
{room.name} }
</Text> >
</Box> <Box grow="Yes" direction="Column">
</MenuItem> <Text size="B400" truncate>
</VirtualTile> {room.name}
); </Text>
})} </Box>
</MenuItem>
</VirtualTile>
);
})}
</Box>
</Box>
</Scroll>
</Box>
</FocusTrap>
</Overlay>
{confirmRoomId && (() => {
const room = getRoom(confirmRoomId);
const roomName = room?.name ?? confirmRoomId;
const isDm = mDirects.has(confirmRoomId);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleCancelConfirm,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" style={{ maxWidth: '280px', maxHeight: 'fit-content' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
<Text size="T300" priority="300">
Send to <strong>{roomName}</strong>?
</Text>
</Box>
<Box gap="200" justifyContent="End">
<Button
onClick={handleCancelConfirm}
variant="Secondary"
size="400"
>
<Text size="B400">Cancel</Text>
</Button>
<Button
onClick={handleConfirm}
variant="Primary"
size="400"
>
<Text size="B400">Share</Text>
</Button>
</Box> </Box>
</Box> </Box>
</Scroll> </Modal>
</Box> </FocusTrap>
</Modal> </OverlayCenter>
</FocusTrap> </Overlay>
</OverlayCenter> );
</Overlay> })()}
</>
); );
} }

View File

@@ -0,0 +1,159 @@
import React, {
ChangeEventHandler,
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import {
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useDebounce } from '../../hooks/useDebounce';
import { stopPropagation } from '../../utils/keyboard';
export function ServerPicker({
server,
serverList,
allowCustomServer,
onServerChange,
}: {
server: string;
serverList: string[];
allowCustomServer?: boolean;
onServerChange: (server: string) => void;
}) {
const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
const serverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Only sync input when server changes externally (e.g., from menu selection)
// and input is not focused to avoid cursor jumping during typing
if (
serverInputRef.current &&
serverInputRef.current.value !== server &&
document.activeElement !== serverInputRef.current
) {
serverInputRef.current.value = server;
}
}, [server]);
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer) debounceServerSelect(inputServer);
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer && inputServer !== server) {
onServerChange(inputServer);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
setServerMenuAnchor(undefined);
}
if (evt.key === 'Enter') {
evt.preventDefault();
const inputServer = evt.currentTarget.value.trim();
if (inputServer) onServerChange(inputServer);
}
};
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
const selectedServer = evt.currentTarget.getAttribute('data-server');
if (selectedServer) {
onServerChange(selectedServer);
}
setServerMenuAnchor(undefined);
};
const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
setServerMenuAnchor(target.getBoundingClientRect());
};
return (
<Input
ref={serverInputRef}
style={{ paddingRight: config.space.S200 }}
variant={allowCustomServer ? 'Background' : 'Surface'}
outlined
defaultValue={server}
onChange={handleServerChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
size="500"
readOnly={!allowCustomServer}
onClick={allowCustomServer ? undefined : handleOpenServerMenu}
after={
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
<PopOut
anchor={serverMenuAnchor}
position="Bottom"
align="End"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setServerMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Homeserver List</Text>
</Header>
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
{serverList?.map((serverName) => (
<MenuItem
key={serverName}
radii="300"
aria-pressed={serverName === server}
data-server={serverName}
onClick={handleServerSelect}
>
<Text>{serverName}</Text>
</MenuItem>
))}
</div>
</Menu>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenServerMenu}
variant={allowCustomServer ? 'Background' : 'Surface'}
size="300"
aria-pressed={!!serverMenuAnchor}
radii="300"
>
<Icon src={Icons.ChevronBottom} />
</IconButton>
</PopOut>
)
}
/>
);
}

View File

@@ -517,86 +517,118 @@ function TaskbarFlashStopper() {
function AndroidShareIntentHandler() { function AndroidShareIntentHandler() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const navigate = useNavigate();
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null); const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const [pickedRoomId, setPickedRoomId] = useState<string | null>(null); const mDirects = useAtomValue(mDirectAtom);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__')); const roomToParents = useAtomValue(roomToParentsAtom);
const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__'));
const applyPendingShare = useCallback( const applyPendingShare = useCallback(
async (share: AndroidSharePayload, roomId: string) => { async (share: AndroidSharePayload, roomId: string) => {
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return false; if (!room) return false;
// Send text message if present
const nextParts = [share.subject?.trim(), share.text?.trim()].filter( const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
(value): value is string => !!value (value): value is string => !!value
); );
if (nextParts.length > 0) { if (nextParts.length > 0) {
const existingText = toPlainText(msgDraft, isMarkdown).trim(); const textContent = {
const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n'); msgtype: 'm.text' as const,
setMsgDraft(plainToEditorInput(nextText, isMarkdown)); body: nextParts.join('\n'),
};
await mx.sendMessage(roomId, textContent);
} }
// Upload and send files if present
if (share.files.length > 0) { if (share.files.length > 0) {
const uploadItems: TUploadItem[] = [];
for (const sharedFile of share.files) { for (const sharedFile of share.files) {
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt); const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
let fileToUpload = originalFile;
let encInfo: any = undefined;
if (room.hasEncryptionStateEvent()) { if (room.hasEncryptionStateEvent()) {
const encrypted = await encryptFile(originalFile); const encrypted = await encryptFile(originalFile);
uploadItems.push({ fileToUpload = encrypted.file;
file: encrypted.file, encInfo = encrypted.encInfo;
originalFile,
metadata: { markedAsSpoiler: false },
encInfo: encrypted.encInfo,
});
continue;
} }
uploadItems.push({ // Upload file
file: originalFile, const uploadResult = await mx.uploadContent(fileToUpload);
originalFile, const mxc = uploadResult?.content_uri;
metadata: { markedAsSpoiler: false }, if (!mxc) continue;
encInfo: undefined,
});
}
setUploadItems({ // Determine message type and send
type: 'PUT', const fileType = originalFile.type;
item: uploadItems, let msgtype = 'm.file' as const;
}); if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
const fileContent: any = {
msgtype,
body: originalFile.name,
filename: originalFile.name,
info: {
mimetype: originalFile.type,
size: originalFile.size,
},
};
if (encInfo) {
fileContent.file = {
...encInfo,
url: mxc,
};
} else {
fileContent.url = mxc;
}
await mx.sendMessage(roomId, fileContent);
}
} }
await clearPendingAndroidShare(); await clearPendingAndroidShare();
return true; return true;
}, },
[isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems] [mx]
); );
const handlePickRoom = useCallback( const handlePickRoom = useCallback(
(roomId: string) => { (roomId: string) => {
setPickedRoomId(roomId);
if (!pendingShare) return; if (!pendingShare) return;
applyPendingShare(pendingShare, roomId) applyPendingShare(pendingShare, roomId)
.then((applied) => { .then((applied) => {
if (applied) { if (applied) {
setPendingShare(null); setPendingShare(null);
setPickedRoomId(null);
// Navigate to the selected room
const isDirect = mDirects.has(roomId);
if (isDirect) {
navigate(getDirectRoomPath(roomId));
} else {
const parents = roomToParents.get(roomId);
const parent = parents && parents.length > 0 ? parents[0] : undefined;
if (parent) {
navigate(getSpaceRoomPath(parent, roomId));
} else {
navigate(getHomeRoomPath(roomId));
}
}
} }
}) })
.catch((err) => { .catch((err) => {
console.error('[AndroidShare] Failed to apply share after pick:', err); console.error('[AndroidShare] Failed to apply share after pick:', err);
setPendingShare(null); setPendingShare(null);
setPickedRoomId(null);
}); });
}, },
[applyPendingShare, pendingShare] [applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
); );
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
clearPendingAndroidShare().catch(() => {}); clearPendingAndroidShare().catch(() => {});
setPendingShare(null); setPendingShare(null);
setPickedRoomId(null);
}, []); }, []);
useEffect(() => { useEffect(() => {

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.94", "version": "4.11.107",
"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"