Compare commits

..

11 Commits

Author SHA1 Message Date
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
7 changed files with 435 additions and 5 deletions

View File

@@ -1,7 +1,7 @@
# Paarrot
Paarrot is a Matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron and based on Cinny.
## Download
Installers for Windows and Linux can be downloaded from [releases](http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases).

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
.idea/navEditor.xml
# Keystore files - NEVER commit these!
# Keystore files
*.jks
*.keystore
keystore.properties
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild

View File

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

Binary file not shown.

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

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

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