Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
298bcfd1e3 | ||
| fce1736312 | |||
|
|
e11304d093 | ||
| 60d76d5165 | |||
|
|
373e700a07 | ||
| f7b25261cc | |||
|
|
41e1b0db9d | ||
| d1022635d2 | |||
|
|
2e6be4b0b3 | ||
| 0d97676edd | |||
|
|
f8e8058de3 | ||
| a1577a3b97 | |||
|
|
80f1df6588 | ||
| 375fa74309 | |||
|
|
0862094e00 | ||
| 45d66bbe28 | |||
|
|
919bc310a7 | ||
| badcfb160c | |||
|
|
1b35f71bb8 | ||
| 96349bf200 | |||
| 472d30940d | |||
|
|
ae9d2bfeb6 | ||
| 61b82d1f40 | |||
|
|
b008011d5f | ||
| 938f000f65 | |||
|
|
7fdf369883 | ||
| c9883a1279 | |||
|
|
5f09b0dd71 | ||
| 0e436f076e | |||
| 2281b31894 |
4
android/.gitignore
vendored
4
android/.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Android App Signing Setup
|
||||
|
||||
|
||||
## Why This Matters
|
||||
Android requires apps to be signed with the same key for updates to work. Without proper signing, users cannot update your app - they must uninstall and reinstall, losing all data.
|
||||
|
||||
|
||||
@@ -35,8 +35,16 @@ android {
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
|
||||
4
android/keystore.properties
Normal file
4
android/keystore.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
storeFile=../paarrot-release.keystore
|
||||
storePassword=paarrot2026
|
||||
keyAlias=paarrot
|
||||
keyPassword=paarrot2026
|
||||
BIN
android/paarrot-release.keystore
Normal file
BIN
android/paarrot-release.keystore
Normal file
Binary file not shown.
269
overlay/src/app/components/editor/Editor.tsx
Normal file
269
overlay/src/app/components/editor/Editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -62,6 +63,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
|
||||
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRoomsSet = useAllJoinedRoomsSet();
|
||||
@@ -107,149 +109,202 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
||||
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const roomId = evt.currentTarget.getAttribute('data-room-id');
|
||||
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 fileCount = share.files.length;
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: onDismiss,
|
||||
escapeDeactivates: stopPropagation,
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: onDismiss,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 999,
|
||||
backgroundColor: '#1a1d23',
|
||||
}}
|
||||
>
|
||||
<Modal size="300">
|
||||
<Box grow="Yes" direction="Column" style={{ maxHeight: '85vh' }}>
|
||||
<Header
|
||||
size="500"
|
||||
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }}
|
||||
>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="H4">Share to Room</Text>
|
||||
{(previewText || fileCount > 0) && (
|
||||
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
||||
{previewText
|
||||
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
||||
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton size="300" radii="300" onClick={onDismiss}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Header>
|
||||
<Header
|
||||
size="500"
|
||||
style={{ padding: config.space.S400, paddingTop: '60px' }}
|
||||
>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="H4">Share to Room</Text>
|
||||
{(previewText || fileCount > 0) && (
|
||||
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
||||
{previewText
|
||||
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
||||
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton size="300" radii="300" onClick={onDismiss}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
padding: `0 ${config.space.S300}`,
|
||||
paddingTop: config.space.S200,
|
||||
paddingBottom: config.space.S200,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
onChange={handleSearchChange}
|
||||
before={<Icon size="200" src={Icons.Search} />}
|
||||
placeholder="Search rooms…"
|
||||
size="400"
|
||||
variant="Background"
|
||||
outlined
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
onChange={handleSearchChange}
|
||||
before={<Icon size="200" src={Icons.Search} />}
|
||||
placeholder="Search rooms…"
|
||||
size="400"
|
||||
variant="Background"
|
||||
outlined
|
||||
autoFocus
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</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, paddingTop: config.space.S300 }} direction="Column">
|
||||
{vItems.length === 0 && (
|
||||
<Box
|
||||
style={{ padding: config.space.S300, paddingTop: 0 }}
|
||||
style={{ padding: `${config.space.S700} 0` }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
>
|
||||
{vItems.length === 0 && (
|
||||
<Box
|
||||
style={{ padding: `${config.space.S700} 0` }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
<Text size="H6" align="Center">
|
||||
{searchResult ? 'No Match Found' : 'No Rooms'}
|
||||
</Text>
|
||||
<Text size="T200" align="Center" priority="300">
|
||||
{searchResult
|
||||
? `No rooms found for "${searchResult.query}".`
|
||||
: '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">
|
||||
{searchResult ? 'No Match Found' : 'No Rooms'}
|
||||
</Text>
|
||||
<Text size="T200" align="Center" priority="300">
|
||||
{searchResult
|
||||
? `No rooms found for "${searchResult.query}".`
|
||||
: '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}
|
||||
>
|
||||
<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 />
|
||||
<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>
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="B400" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon room={room} size="200" filled />
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="B400" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</FocusTrap>
|
||||
|
||||
{confirmRoomId && (() => {
|
||||
const room = getRoom(confirmRoomId);
|
||||
const roomName = room?.name ?? confirmRoomId;
|
||||
const isDm = mDirects.has(confirmRoomId);
|
||||
return (
|
||||
<OverlayCenter style={{ zIndex: 9999 }}>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: handleCancelConfirm,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="300" style={{ zIndex: 9999 }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S400 }}>
|
||||
<Box direction="Column" gap="200">
|
||||
<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>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
);
|
||||
})()}
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
159
overlay/src/app/pages/auth/ServerPicker.tsx
Normal file
159
overlay/src/app/pages/auth/ServerPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -517,86 +517,118 @@ function TaskbarFlashStopper() {
|
||||
|
||||
function AndroidShareIntentHandler() {
|
||||
const mx = useMatrixClient();
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const navigate = useNavigate();
|
||||
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
||||
const [pickedRoomId, setPickedRoomId] = useState<string | null>(null);
|
||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__'));
|
||||
const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__'));
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
||||
const applyPendingShare = useCallback(
|
||||
async (share: AndroidSharePayload, roomId: string) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
// Send text message if present
|
||||
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
||||
(value): value is string => !!value
|
||||
);
|
||||
if (nextParts.length > 0) {
|
||||
const existingText = toPlainText(msgDraft, isMarkdown).trim();
|
||||
const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n');
|
||||
setMsgDraft(plainToEditorInput(nextText, isMarkdown));
|
||||
const textContent = {
|
||||
msgtype: 'm.text' as const,
|
||||
body: nextParts.join('\n'),
|
||||
};
|
||||
await mx.sendMessage(roomId, textContent);
|
||||
}
|
||||
|
||||
// Upload and send files if present
|
||||
if (share.files.length > 0) {
|
||||
const uploadItems: TUploadItem[] = [];
|
||||
for (const sharedFile of share.files) {
|
||||
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
||||
|
||||
let fileToUpload = originalFile;
|
||||
let encInfo: any = undefined;
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encrypted = await encryptFile(originalFile);
|
||||
uploadItems.push({
|
||||
file: encrypted.file,
|
||||
originalFile,
|
||||
metadata: { markedAsSpoiler: false },
|
||||
encInfo: encrypted.encInfo,
|
||||
});
|
||||
continue;
|
||||
fileToUpload = encrypted.file;
|
||||
encInfo = encrypted.encInfo;
|
||||
}
|
||||
|
||||
uploadItems.push({
|
||||
file: originalFile,
|
||||
originalFile,
|
||||
metadata: { markedAsSpoiler: false },
|
||||
encInfo: undefined,
|
||||
});
|
||||
}
|
||||
// Upload file
|
||||
const uploadResult = await mx.uploadContent(fileToUpload);
|
||||
const mxc = uploadResult?.content_uri;
|
||||
if (!mxc) continue;
|
||||
|
||||
setUploadItems({
|
||||
type: 'PUT',
|
||||
item: uploadItems,
|
||||
});
|
||||
// Determine message type and send
|
||||
const fileType = originalFile.type;
|
||||
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();
|
||||
return true;
|
||||
},
|
||||
[isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems]
|
||||
[mx]
|
||||
);
|
||||
|
||||
const handlePickRoom = useCallback(
|
||||
(roomId: string) => {
|
||||
setPickedRoomId(roomId);
|
||||
if (!pendingShare) return;
|
||||
|
||||
applyPendingShare(pendingShare, roomId)
|
||||
.then((applied) => {
|
||||
if (applied) {
|
||||
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) => {
|
||||
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
||||
setPendingShare(null);
|
||||
setPickedRoomId(null);
|
||||
});
|
||||
},
|
||||
[applyPendingShare, pendingShare]
|
||||
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
clearPendingAndroidShare().catch(() => {});
|
||||
setPendingShare(null);
|
||||
setPickedRoomId(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.92",
|
||||
"version": "4.11.106",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user