6 Commits

Author SHA1 Message Date
3488606b68 feat: Add dynamic width calculation for carousel items based on image aspect ratio 2026-05-17 13:46:12 +10:00
a3caafaf20 feat: Implement image carousel functionality with metadata handling and UI enhancements 2026-05-17 13:24:44 +10:00
b9b0dce40b fix: Remove unnecessary size prop from Modal in ShareRoomPicker component 2026-05-13 11:12:14 +10:00
b2fd65c8cb feat: Enhance editor functionality with autocorrect handling and text replacement
feat: Implement confirmation dialog for room sharing in ShareRoomPicker

fix: Improve server input synchronization and blur handling in ServerPicker

feat: Integrate UnifiedPush for background sync in Android, including pusher management

refactor: Streamline Android share handling and file upload processes

fix: Update notification handling to use Capacitor's local notifications API
2026-05-13 10:40:30 +10:00
7ef9939d8a fix: Change Code component display style from inline-block to inline 2026-05-13 08:00:22 +10:00
43bd6320b8 Refactor code structure for improved readability and maintainability 2026-05-13 05:47:11 +10:00
21 changed files with 1479 additions and 312 deletions

BIN
build_output.txt Normal file

Binary file not shown.

View File

@@ -9,13 +9,30 @@
"xmr.se" "xmr.se"
], ],
"allowCustomHomeservers": true, "allowCustomHomeservers": true,
"calling": {
"livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
},
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": [ "spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:envs.net",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
], ],
"rooms": [ "rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
], ],
"servers": [] "servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
}, },
"hashRouter": { "hashRouter": {

35
package-lock.json generated
View File

@@ -12,8 +12,10 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@capacitor/core": "8.3.4",
"@capacitor/local-notifications": "8.2.0",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@paarrot/plugin-manager": "file:../../PluginManager", "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.24.1", "@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",
@@ -114,15 +116,6 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"../../PluginManager": {
"name": "@paarrot/plugin-manager",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^25.6.0",
"rimraf": "^5.0.0",
"typescript": "^5.4.0"
}
},
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -1678,6 +1671,24 @@
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@capacitor/core": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
"integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
"integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@emotion/hash": { "node_modules/@emotion/hash": {
"version": "0.9.2", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
@@ -2344,8 +2355,8 @@
} }
}, },
"node_modules/@paarrot/plugin-manager": { "node_modules/@paarrot/plugin-manager": {
"resolved": "../../PluginManager", "version": "1.0.0",
"link": true "resolved": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git#6c389381df3966568a40c39d8661f9a4300f7819"
}, },
"node_modules/@react-aria/breadcrumbs": { "node_modules/@react-aria/breadcrumbs": {
"version": "3.5.20", "version": "3.5.20",

View File

@@ -23,6 +23,8 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@capacitor/core": "8.3.4",
"@capacitor/local-notifications": "8.2.0",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git", "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.24.1", "@tanstack/react-query": "5.24.1",

View File

@@ -6,6 +6,8 @@ import React, {
forwardRef, forwardRef,
useCallback, useCallback,
useState, useState,
useEffect,
useRef,
} from 'react'; } from 'react';
import { Box, Scroll, Text } from 'folds'; import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate'; import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
@@ -167,6 +169,48 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[editor, onKeyDown] [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( const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => ( ({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}> <span {...attributes} className={css.EditorPlaceholderContainer}>
@@ -196,6 +240,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
size="300" size="300"
visibility="Hover" visibility="Hover"
hideTrack hideTrack
ref={editableRef}
> >
<Editable <Editable
data-editable-name={editableName} data-editable-name={editableName}

View File

@@ -21,7 +21,6 @@ import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { useEmojiUsage } from '../../hooks/useEmojiUsage'; import { useEmojiUsage } from '../../hooks/useEmojiUsage';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix'; import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
import { editableActiveElement, targetFromEvent } from '../../utils/dom'; import { editableActiveElement, targetFromEvent } from '../../utils/dom';
@@ -76,7 +75,6 @@ const useGroups = (
const mx = useMatrixClient(); const mx = useMatrixClient();
const { getTopEmojis } = useEmojiUsage(); const { getTopEmojis } = useEmojiUsage();
const recentEmojis = useRecentEmoji(mx, 21);
const labels = useEmojiGroupLabels(); const labels = useEmojiGroupLabels();
const emojiGroupItems = useMemo(() => { const emojiGroupItems = useMemo(() => {
@@ -99,12 +97,6 @@ const useGroups = (
} }
} }
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
items: recentEmojis,
});
imagePacks.forEach((pack) => { imagePacks.forEach((pack) => {
let label = pack.meta.name; let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
@@ -127,7 +119,7 @@ const useGroups = (
}); });
return g; return g;
}, [mx, recentEmojis, labels, imagePacks, tab]); }, [mx, labels, imagePacks, tab, getTopEmojis]);
const stickerGroupItems = useMemo(() => { const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = []; const g: StickerGroupItem[] = [];
@@ -212,13 +204,6 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
icon={Icons.Heart} icon={Icons.Heart}
onClick={handleScrollToGroup} onClick={handleScrollToGroup}
/> />
<GroupIcon
active={activeGroupId === RECENT_GROUP_ID}
id={RECENT_GROUP_ID}
label="Recent"
icon={Icons.RecentClock}
onClick={handleScrollToGroup}
/>
</SidebarStack> </SidebarStack>
{packs.length > 0 && ( {packs.length > 0 && (
<SidebarStack> <SidebarStack>

View File

@@ -81,6 +81,9 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
if (typeof body !== 'string') return <BrokenContent />; if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body).trim(); const trimmedBody = trimReplyFromBody(body).trim();
const trimmedCustomBody = typeof customBody === 'string'
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
@@ -93,7 +96,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
> >
{renderBody({ {renderBody({
body: trimmedBody, body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined, customBody: trimmedCustomBody,
})} })}
{edited && <MessageEditedContent />} {edited && <MessageEditedContent />}
</MessageTextBody> </MessageTextBody>
@@ -119,7 +122,10 @@ export function MEmote({
const { body, formatted_body: customBody } = content; const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />; if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body); const trimmedBody = trimReplyFromBody(body).trim();
const trimmedCustomBody = typeof customBody === 'string'
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
@@ -133,7 +139,7 @@ export function MEmote({
<b>{`${displayName} `}</b> <b>{`${displayName} `}</b>
{renderBody({ {renderBody({
body: trimmedBody, body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined, customBody: trimmedCustomBody,
})} })}
{edited && <MessageEditedContent />} {edited && <MessageEditedContent />}
</MessageTextBody> </MessageTextBody>
@@ -152,7 +158,10 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
const { body, formatted_body: customBody } = content; const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />; if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body); const trimmedBody = trimReplyFromBody(body).trim();
const trimmedCustomBody = typeof customBody === 'string'
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
@@ -165,7 +174,7 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
> >
{renderBody({ {renderBody({
body: trimmedBody, body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined, customBody: trimmedCustomBody,
})} })}
{edited && <MessageEditedContent />} {edited && <MessageEditedContent />}
</MessageTextBody> </MessageTextBody>

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,16 +109,26 @@ 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,
@@ -125,16 +137,25 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Modal size="300"> <Box
<Box grow="Yes" direction="Column" style={{ maxHeight: '85vh' }}> grow="Yes"
direction="Column"
style={{
position: 'fixed',
inset: 0,
zIndex: 999,
backgroundColor: '#1a1d23',
paddingTop: '60px',
}}
>
<Header <Header
size="500" size="500"
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }} style={{ padding: config.space.S400 }}
> >
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
<Text size="H4">Share to Room</Text> <Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && ( {(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7 }}> <Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{previewText {previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"` ? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`} : `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
@@ -150,9 +171,8 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
<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
@@ -163,14 +183,12 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
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 <Box style={{ padding: config.space.S300 }} direction="Column">
style={{ padding: config.space.S300, paddingTop: 0 }}
direction="Column"
>
{vItems.length === 0 && ( {vItems.length === 0 && (
<Box <Box
style={{ padding: `${config.space.S700} 0` }} style={{ padding: `${config.space.S700} 0` }}
@@ -189,9 +207,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
</Text> </Text>
</Box> </Box>
)} )}
<Box <Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
style={{ position: 'relative', height: virtualizer.getTotalSize() }}
>
{vItems.map((vItem) => { {vItems.map((vItem) => {
const roomId = items[vItem.index]; const roomId = items[vItem.index];
const room = getRoom(roomId); const room = getRoom(roomId);
@@ -247,9 +263,54 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
</Box> </Box>
</Scroll> </Scroll>
</Box> </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 style={{ maxWidth: '280px' }}>
<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>
</Modal> </Modal>
</FocusTrap> </FocusTrap>
</OverlayCenter> </OverlayCenter>
</Overlay> </Overlay>
); );
})()}
</>
);
} }

View File

@@ -125,6 +125,17 @@ import { useOtherUserColor } from '../../hooks/useUserColor';
import { getMemberAvatarMxc } from '../../utils/room'; import { getMemberAvatarMxc } from '../../utils/room';
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter'; import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
/**
* Creates a UUID used to group image events into one carousel.
*/
const createCarouselUuid = (): string => {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
fileDropContainerRef: RefObject<HTMLElement>; fileDropContainerRef: RefObject<HTMLElement>;
@@ -305,12 +316,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}; };
const handleSendUpload = async (uploads: UploadSuccess[]) => { const handleSendUpload = async (uploads: UploadSuccess[]) => {
const imageUploads = uploads.filter((upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
return !!fileItem && fileItem.file.type.startsWith('image');
});
const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined;
const imageUploadIndexByFile = new Map(
imageUploads.map((upload, index) => [upload.file, index] as const)
);
const contentsPromises = uploads.map(async (upload) => { const contentsPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file); const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload'); if (!fileItem) throw new Error('Broken upload');
if (fileItem.file.type.startsWith('image')) { if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, upload.mxc); const imageIndex = imageUploadIndexByFile.get(upload.file);
return getImageMsgContent(
mx,
fileItem,
upload.mxc,
carouselUuid !== undefined && imageIndex !== undefined
? {
uuid: carouselUuid,
index: imageIndex,
total: imageUploads.length,
}
: undefined
);
} }
if (fileItem.file.type.startsWith('video')) { if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, upload.mxc); return getVideoMsgContent(mx, fileItem, upload.mxc);
@@ -405,10 +438,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return; return;
} }
if (plainText === '') return;
let body = plainText.trim(); let body = plainText.trim();
let formattedBody = customHtml.trim(); let formattedBody = customHtml.trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
if (body === '') return;
// Apply emoticon conversion if enabled // Apply emoticon conversion if enabled
if (autoConvertEmoticons) { if (autoConvertEmoticons) {

View File

@@ -1,5 +1,6 @@
import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { DefaultReset, config } from 'folds'; import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const TimelineFloat = recipe({ export const TimelineFloat = recipe({
base: [ base: [
@@ -28,3 +29,81 @@ export const TimelineFloat = recipe({
}); });
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>; export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
export const CarouselScroller = style([
DefaultReset,
{
display: 'flex',
gap: config.space.S200,
overflowX: 'auto',
overflowY: 'hidden',
paddingBottom: config.space.S100,
width: '100%',
cursor: 'grab',
userSelect: 'none',
},
]);
export const CarouselScrollerDragging = style([
DefaultReset,
{
cursor: 'grabbing',
},
]);
export const CarouselItem = style([
DefaultReset,
{
flex: '0 0 auto',
maxWidth: '100%',
},
]);
export const CarouselEdgeGradient = recipe({
base: [
DefaultReset,
{
position: 'absolute',
top: 0,
height: '100%',
width: toRem(16),
pointerEvents: 'none',
zIndex: 1,
},
],
variants: {
position: {
Left: {
left: 0,
background: `linear-gradient(to right, ${color.Surface.Container}, rgba(116, 116, 116, 0))`,
},
Right: {
right: 0,
background: `linear-gradient(to left, ${color.Surface.Container}, rgba(116, 116, 116, 0))`,
},
},
},
});
export const CarouselScrollBtn = recipe({
base: [
DefaultReset,
{
position: 'absolute',
top: '50%',
zIndex: 2,
},
],
variants: {
position: {
Left: {
left: 0,
transform: 'translate(-25%, -50%)',
},
Right: {
right: 0,
transform: 'translate(25%, -50%)',
},
},
},
});

View File

@@ -19,6 +19,7 @@ import {
IContent, IContent,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
MsgType,
Room, Room,
RoomEvent, RoomEvent,
RoomEventHandlerMap, RoomEventHandlerMap,
@@ -35,6 +36,7 @@ import {
Chip, Chip,
ContainerColor, ContainerColor,
Icon, Icon,
IconButton,
Icons, Icons,
Line, Line,
Scroll, Scroll,
@@ -62,6 +64,7 @@ import {
Time, Time,
MessageNotDecryptedContent, MessageNotDecryptedContent,
RedactedContent, RedactedContent,
MImage,
MSticker, MSticker,
ImageContent, ImageContent,
EventContent, EventContent,
@@ -105,6 +108,12 @@ import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import {
IImageContent,
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent'; import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -146,6 +155,55 @@ interface CallEventInfo {
mEvent: MatrixEvent; mEvent: MatrixEvent;
} }
interface CarouselEventMetadata {
uuid: string;
index: number;
total: number;
content: IImageContent;
}
interface CarouselRenderableEvent {
mEvent: MatrixEvent;
mEventId: string;
item: number;
content: IImageContent;
index: number;
}
/**
* Extracts carousel metadata from an m.image room message.
*/
const getCarouselEventMetadata = (mEvent: MatrixEvent): CarouselEventMetadata | undefined => {
if (mEvent.getType() !== MessageEvent.RoomMessage || mEvent.isRedacted()) {
return undefined;
}
const content = mEvent.getContent() as IImageContent;
const uuid = content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME];
const index = content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME];
const total = content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME];
if (content.msgtype !== MsgType.Image) {
return undefined;
}
if (typeof uuid !== 'string') {
return undefined;
}
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
return undefined;
}
if (typeof total !== 'number' || !Number.isInteger(total) || total < 1) {
return undefined;
}
return {
uuid,
index,
total,
content,
};
};
const TimelineFloat = as<'div', css.TimelineFloatVariants>( const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => ( ({ position, className, ...props }, ref) => (
<Box <Box
@@ -169,6 +227,198 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
) )
); );
type CarouselScrollerProps = {
children: React.ReactNode;
};
const CarouselScroller = ({ children }: CarouselScrollerProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
const backAnchorRef = useRef<HTMLDivElement>(null);
const frontAnchorRef = useRef<HTMLDivElement>(null);
const [backVisible, setBackVisible] = useState(true);
const [frontVisible, setFrontVisible] = useState(true);
const dragStateRef = useRef({
startX: 0,
startScrollLeft: 0,
dragging: false,
moved: false,
suppressClick: false,
});
const [isDragging, setIsDragging] = useState(false);
const intersectionObserver = useIntersectionObserver(
useCallback((entries) => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
if (backEntry) {
setBackVisible(backEntry.isIntersecting);
}
if (frontEntry) {
setFrontVisible(frontEntry.isIntersecting);
}
}, []),
useCallback(
() => ({
root: scrollRef.current,
rootMargin: '10px',
}),
[]
)
);
useEffect(() => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
if (backAnchor) intersectionObserver?.observe(backAnchor);
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
return () => {
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
};
}, [intersectionObserver]);
const handleMouseDown = (evt: React.MouseEvent<HTMLDivElement>) => {
if (evt.button !== 0) return;
const scroll = scrollRef.current;
if (!scroll) return;
const dragState = dragStateRef.current;
dragState.startX = evt.clientX;
dragState.startScrollLeft = scroll.scrollLeft;
dragState.dragging = true;
dragState.moved = false;
dragState.suppressClick = false;
setIsDragging(true);
};
const handleMouseMove = (evt: React.MouseEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (!dragState.dragging) {
return;
}
evt.preventDefault();
const scroll = scrollRef.current;
if (!scroll) return;
const deltaX = evt.clientX - dragState.startX;
if (Math.abs(deltaX) > 5) {
dragState.moved = true;
}
scroll.scrollLeft = dragState.startScrollLeft - deltaX;
};
const endDrag = () => {
const dragState = dragStateRef.current;
if (!dragState.dragging) return;
if (dragState.moved) {
dragState.suppressClick = true;
}
dragState.dragging = false;
setIsDragging(false);
};
const handleClickCapture = (evt: React.MouseEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (!dragState.suppressClick) {
return;
}
dragState.suppressClick = false;
evt.preventDefault();
evt.stopPropagation();
};
const handleScrollFront = () => {
const scroll = scrollRef.current;
if (!scroll) return;
scroll.scrollTo({
left: scroll.scrollLeft + scroll.offsetWidth / 1.3,
behavior: 'smooth',
});
};
const handleScrollBack = () => {
const scroll = scrollRef.current;
if (!scroll) return;
scroll.scrollTo({
left: scroll.scrollLeft - scroll.offsetWidth / 1.3,
behavior: 'smooth',
});
};
return (
<Box style={{ position: 'relative' }}>
<Box
ref={scrollRef}
className={classNames(css.CarouselScroller, isDragging && css.CarouselScrollerDragging)}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onClickCapture={handleClickCapture}
>
<div ref={backAnchorRef} />
{children}
<div ref={frontAnchorRef} />
</Box>
{!backVisible && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Left' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Left' })}
variant="Secondary"
radii="Pill"
size="300"
outlined
onClick={handleScrollBack}
>
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</>
)}
{!frontVisible && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Right' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Right' })}
variant="Primary"
radii="Pill"
size="300"
outlined
onClick={handleScrollFront}
>
<Icon size="300" src={Icons.ArrowRight} />
</IconButton>
</>
)}
</Box>
);
};
const getCarouselItemWidth = (content: IImageContent): string => {
const width = content.info?.w;
const height = content.info?.h;
if (!width || !height || height <= 0) {
return 'min(24rem, 72vw)';
}
const aspectRatio = width / height;
const baseHeightRem = 22;
const computedWidthRem = Math.max(11, Math.min(24, baseHeightRem * aspectRatio));
return `min(${computedWidthRem}rem, 72vw)`;
};
export const getLiveTimeline = (room: Room): EventTimeline => export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline(); room.getUnfilteredTimelineSet().getLiveTimeline();
@@ -1748,6 +1998,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let isPrevRendered = false; let isPrevRendered = false;
let newDivider = false; let newDivider = false;
let dayDivider = false; let dayDivider = false;
const groupedCarouselEventIds = new Set<string>();
// Clear pending call events at the start of each render // Clear pending call events at the start of each render
pendingCallEventsRef.current = []; pendingCallEventsRef.current = [];
@@ -1869,6 +2120,110 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
); );
}; };
const renderImageCarouselMessage = (
carouselEvents: CarouselRenderableEvent[],
mEventId: string,
mEvent: MatrixEvent,
item: number,
timelineSet: EventTimelineSet,
collapse: boolean
): React.ReactNode => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const orderedCarouselEvents = [...carouselEvents].sort((a, b) => a.index - b.index);
return (
<Message
key={`carousel-${mEventId}`}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onOpenThread={handleOpenThread}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/>
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<CarouselScroller>
{orderedCarouselEvents.map((carouselEvent) => (
<Box
key={carouselEvent.mEventId}
className={css.CarouselItem}
style={{ width: getCarouselItemWidth(carouselEvent.content) }}
>
<MImage
content={carouselEvent.content}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={messageLayout === MessageLayout.Bubble}
/>
</Box>
))}
</CarouselScroller>
)}
</Message>
);
};
const eventRenderer = (item: number, index: number, allItems: number[]) => { const eventRenderer = (item: number, index: number, allItems: number[]) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null; if (!eventTimeline) return null;
@@ -1902,8 +2257,67 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
prevEvent.getType() === mEvent.getType() && prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const rawEventJSX = reactionOrEditEvent(mEvent) if (groupedCarouselEventIds.has(mEventId)) {
? null prevEvent = mEvent;
isPrevRendered = true;
return null;
}
const getEventByItem = (
timelineItem: number
): { event: MatrixEvent; eventId: string } | undefined => {
const [tm, bIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, timelineItem);
if (!tm) return undefined;
const event = getTimelineEvent(tm, getTimelineRelativeIndex(timelineItem, bIndex));
const eventId = event?.getId();
if (!event || !eventId) return undefined;
return { event, eventId };
};
const carouselMetadata = getCarouselEventMetadata(mEvent);
const carouselEvents: CarouselRenderableEvent[] = [];
if (carouselMetadata && eventSender) {
carouselEvents.push({
mEvent,
mEventId,
item,
content: carouselMetadata.content,
index: carouselMetadata.index,
});
for (let nextIndex = index + 1; nextIndex < allItems.length; nextIndex += 1) {
const nextEvt = getEventByItem(allItems[nextIndex]);
if (!nextEvt) break;
const nextSender = nextEvt.event.getSender();
const nextMeta = getCarouselEventMetadata(nextEvt.event);
if (!nextMeta || nextSender !== eventSender || nextMeta.uuid !== carouselMetadata.uuid) {
break;
}
groupedCarouselEventIds.add(nextEvt.eventId);
carouselEvents.push({
mEvent: nextEvt.event,
mEventId: nextEvt.eventId,
item: allItems[nextIndex],
content: nextMeta.content,
index: nextMeta.index,
});
if (carouselEvents.length >= carouselMetadata.total) {
break;
}
}
}
let rawEventJSX: React.ReactNode = null;
if (!reactionOrEditEvent(mEvent)) {
rawEventJSX =
carouselEvents.length > 1
? renderImageCarouselMessage(carouselEvents, mEventId, mEvent, item, timelineSet, collapsed)
: renderMatrixEvent( : renderMatrixEvent(
mEvent.getType(), mEvent.getType(),
typeof mEvent.getStateKey() === 'string', typeof mEvent.getStateKey() === 'string',
@@ -1913,6 +2327,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
timelineSet, timelineSet,
collapsed collapsed
); );
}
// Check if this is a call event that should be grouped // Check if this is a call event that should be grouped
const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode); const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);

View File

@@ -62,6 +62,8 @@ import {
import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useEmojiUsage } from '../../../hooks/useEmojiUsage';
import { emojis } from '../../../plugins/emoji';
import * as css from './styles.css'; import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers'; import { EventReaders } from '../../../components/event-readers';
import { TextViewer } from '../../../components/text-viewer'; import { TextViewer } from '../../../components/text-viewer';
@@ -71,8 +73,6 @@ import { ReactionViewer } from '../reaction-viewer';
import { MessageEditor } from './MessageEditor'; import { MessageEditor } from './MessageEditor';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { useEmojiUsage } from '../../../hooks/useEmojiUsage';
import { emojis } from '../../../plugins/emoji';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to'; import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers'; import { getViaServers } from '../../../plugins/via-servers';
@@ -93,10 +93,13 @@ type MessageQuickReactionsProps = {
}; };
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
({ onReaction, ...props }, ref) => { ({ onReaction, ...props }, ref) => {
const mx = useMatrixClient(); const { getTopEmojis } = useEmojiUsage();
const recentEmojis = useRecentEmoji(mx, 4); const topShortcodes = getTopEmojis(4);
const mostUsedEmojis = topShortcodes
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
.filter((e): e is IEmoji => e !== undefined);
if (recentEmojis.length === 0) return <span />; if (mostUsedEmojis.length === 0) return <span />;
return ( return (
<> <>
<Box <Box
@@ -107,7 +110,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
{...props} {...props}
ref={ref} ref={ref}
> >
{recentEmojis.map((emoji) => ( {mostUsedEmojis.map((emoji) => (
<IconButton <IconButton
key={emoji.unicode} key={emoji.unicode}
className={css.MessageQuickReaction} className={css.MessageQuickReaction}
@@ -776,7 +779,6 @@ export const Message = as<'div', MessageProps>(
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderPresence = useUserPresence(senderId); const senderPresence = useUserPresence(senderId);
const { getMostUsedEmoji } = useEmojiUsage();
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover }); const { hoverProps } = useHover({ onHoverChange: setHover });
@@ -978,24 +980,6 @@ export const Message = as<'div', MessageProps>(
<div className={css.MessageOptionsBase}> <div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant"> <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100"> <Box gap="100">
{canSendReaction && (() => {
const mostUsedShortcode = getMostUsedEmoji();
const mostUsedEmoji = mostUsedShortcode
? emojis.find((e) => e.shortcode === mostUsedShortcode)
: null;
return mostUsedEmoji ? (
<IconButton
onClick={() => onReactionToggle(mEvent.getId()!, mostUsedEmoji.unicode)}
variant="SurfaceVariant"
size="300"
radii="300"
title={`Quick react with ${mostUsedEmoji.unicode}`}
>
<Text size="T300">{mostUsedEmoji.unicode}</Text>
</IconButton>
) : null;
})()}
{canSendReaction && ( {canSendReaction && (
<PopOut <PopOut
position="Bottom" position="Bottom"

View File

@@ -4,6 +4,9 @@ import {
IThumbnailContent, IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME, MATRIX_BLUR_HASH_PROPERTY_NAME,
MATRIX_SPOILER_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME,
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { import {
getImageFileUrl, getImageFileUrl,
@@ -18,6 +21,12 @@ import { TUploadItem } from '../../state/room/roomInputDrafts';
import { encodeBlurHash } from '../../utils/blurHash'; import { encodeBlurHash } from '../../utils/blurHash';
import { scaleYDimension } from '../../utils/common'; import { scaleYDimension } from '../../utils/common';
type CarouselMetadata = {
uuid: string;
index: number;
total: number;
};
const generateThumbnailContent = async ( const generateThumbnailContent = async (
mx: MatrixClient, mx: MatrixClient,
img: HTMLImageElement | HTMLVideoElement, img: HTMLImageElement | HTMLVideoElement,
@@ -46,7 +55,8 @@ const generateThumbnailContent = async (
export const getImageMsgContent = async ( export const getImageMsgContent = async (
mx: MatrixClient, mx: MatrixClient,
item: TUploadItem, item: TUploadItem,
mxc: string mxc: string,
carouselMetadata?: CarouselMetadata
): Promise<IContent> => { ): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item; const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
@@ -74,6 +84,11 @@ export const getImageMsgContent = async (
} else { } else {
content.url = mxc; content.url = mxc;
} }
if (carouselMetadata) {
content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME] = carouselMetadata.uuid;
content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME] = carouselMetadata.index;
content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME] = carouselMetadata.total;
}
return content; return content;
}; };

View File

@@ -1,5 +1,6 @@
import React, { import React, {
ChangeEventHandler, ChangeEventHandler,
FocusEventHandler,
KeyboardEventHandler, KeyboardEventHandler,
MouseEventHandler, MouseEventHandler,
useEffect, useEffect,
@@ -39,8 +40,13 @@ export function ServerPicker({
const serverInputRef = useRef<HTMLInputElement>(null); const serverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
// sync input with it outside server changes // Only sync input when server changes externally (e.g., from menu selection)
if (serverInputRef.current && serverInputRef.current.value !== server) { // 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; serverInputRef.current.value = server;
} }
}, [server]); }, [server]);
@@ -52,6 +58,13 @@ export function ServerPicker({
if (inputServer) debounceServerSelect(inputServer); 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) => { const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (evt.key === 'ArrowDown') { if (evt.key === 'ArrowDown') {
evt.preventDefault(); evt.preventDefault();
@@ -85,6 +98,7 @@ export function ServerPicker({
outlined outlined
defaultValue={server} defaultValue={server}
onChange={handleServerChange} onChange={handleServerChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
size="500" size="500"
readOnly={!allowCustomServer} readOnly={!allowCustomServer}

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,
}); // 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;
} }
setUploadItems({ await mx.sendMessage(roomId, fileContent);
type: 'PUT', }
item: uploadItems,
});
} }
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

@@ -72,7 +72,7 @@ export const Code = style([
CodeFont, CodeFont,
{ {
padding: `0 ${config.space.S100}`, padding: `0 ${config.space.S100}`,
display: 'inline-block', display: 'inline',
overflow: 'hidden', overflow: 'hidden',
lineHeight: '1.5', lineHeight: '1.5',
}, },

View File

@@ -1,3 +1,5 @@
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
/** Metadata for a file received from an Android share intent. */ /** Metadata for a file received from an Android share intent. */
export type AndroidSharedFile = { export type AndroidSharedFile = {
path: string; path: string;
@@ -14,33 +16,58 @@ export type AndroidSharePayload = {
receivedAt: number; receivedAt: number;
}; };
/** Listener handle compatible with the mobile bridge signature. */ interface AndroidShareHandlerPlugin {
type PluginListenerHandle = { /** Returns the last pending share payload, if one exists. */
remove: () => Promise<void>; getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
}; /** Clears the last pending share payload after it has been consumed. */
clearPendingShare(): Promise<void>;
addListener(
eventName: 'shareReceived',
listenerFunc: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle>;
}
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
/** Returns true when the Android share bridge is available. */ /** Returns true when the Android share bridge is available. */
export const isAndroidShareSupported = (): boolean => false; export const isAndroidShareSupported = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/** Fetches the current pending native share payload. */ /** Fetches the current pending native share payload. */
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => { export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
return null; if (!isAndroidShareSupported()) return null;
const result = await AndroidShareHandler.getPendingShare();
return result.share;
}; };
/** Clears the native pending share payload. */ /** Clears the native pending share payload. */
export const clearPendingAndroidShare = async (): Promise<void> => undefined; export const clearPendingAndroidShare = async (): Promise<void> => {
if (!isAndroidShareSupported()) return;
await AndroidShareHandler.clearPendingShare();
};
/** Subscribes to new incoming native share payloads. */ /** Subscribes to new incoming native share payloads. */
export const listenForAndroidShares = async ( export const listenForAndroidShares = async (
_listener: (payload: AndroidSharePayload) => void listener: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle | undefined> => { ): Promise<PluginListenerHandle | undefined> => {
return undefined; if (!isAndroidShareSupported()) return undefined;
return AndroidShareHandler.addListener('shareReceived', listener);
}; };
/** Converts a cached native shared file into a browser File for upload. */ /** Converts a cached native shared file into a browser File for upload. */
export const materializeSharedFile = async ( export const materializeSharedFile = async (
_sharedFile: AndroidSharedFile, sharedFile: AndroidSharedFile,
_receivedAt: number receivedAt: number
): Promise<File> => { ): Promise<File> => {
throw new Error('Android share intents are only available in the mobile overlay build.'); const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to read shared file: ${sharedFile.name}`);
}
const blob = await response.blob();
return new File([blob], sharedFile.name, {
type: sharedFile.mimeType || blob.type,
lastModified: receivedAt,
});
}; };

View File

@@ -1,8 +1,6 @@
import type { MatrixClient } from 'matrix-js-sdk'; import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
/**
* Status shape kept compatible with the mobile overlay implementation.
*/
type UnifiedPushStatus = { type UnifiedPushStatus = {
running: boolean; running: boolean;
endpoint: string; endpoint: string;
@@ -12,34 +10,509 @@ type UnifiedPushStatus = {
distributors: string[] | string; distributors: string[] | string;
}; };
/** Returns true when native Android background sync is available. */ type UnifiedPushEndpointEvent = {
export const isBackgroundSyncSupported = (): boolean => false; endpoint: string;
previousEndpoint: string;
instance: string;
};
type UnifiedPushUnregisteredEvent = {
previousEndpoint: string;
instance: string;
};
type UnifiedPushRegistrationFailedEvent = {
reason: string;
instance: string;
};
interface MatrixBackgroundSyncPlugin {
/** Persist credentials and request UnifiedPush registration. */
start(options: {
homeserverUrl: string;
accessToken: string;
userId: string;
deviceId: string;
}): Promise<void>;
/** Trigger a one-shot fetch as if a push ping arrived. */
triggerPing(options: { reason?: string }): Promise<void>;
/** Re-open distributor setup flow and retry registration. */
requestDistributorSetup(): Promise<{ success: boolean }>;
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
stop(): Promise<void>;
/**
* Notify the service whether the app UI is visible.
* When foreground is true, the service suppresses native notifications
* because the JS layer handles them via LocalNotifications.
*/
setAppForeground(options: { foreground: boolean }): Promise<void>;
/** Returns fetch state and current UnifiedPush registration details. */
getStatus(): Promise<UnifiedPushStatus>;
addListener(
eventName: 'unifiedPushNewEndpoint',
listenerFunc: (event: UnifiedPushEndpointEvent) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'unifiedPushUnregistered',
listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'unifiedPushRegistrationFailed',
listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
): Promise<PluginListenerHandle>;
}
type StoredPusherState = {
endpoint: string;
appId: string;
};
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
/** Returns true when the current platform is Android Capacitor. */
export const isBackgroundSyncSupported = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
const buildPusherAppId = (deviceId: string | null): string => {
const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE;
return raw.length > 64 ? raw.slice(0, 64) : raw;
};
const loadStoredPusherState = (
userId: string | null,
deviceId: string | null
): StoredPusherState | undefined => {
if (typeof window === 'undefined' || !window.localStorage) return undefined;
const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId));
if (!raw) return undefined;
try {
return JSON.parse(raw) as StoredPusherState;
} catch {
return undefined;
}
};
const saveStoredPusherState = (
userId: string | null,
deviceId: string | null,
state: StoredPusherState
): void => {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state));
};
const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
};
const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return [];
const trimmed = raw.trim();
if (!trimmed || trimmed === '[]') return [];
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return trimmed
.slice(1, -1)
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
return [trimmed];
};
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
try {
const discoveryUrl = new URL(endpoint);
discoveryUrl.pathname = '/_matrix/push/v1/notify';
discoveryUrl.search = '';
const response = await fetch(discoveryUrl.toString(), { method: 'GET' });
if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY;
const body = (await response.json()) as {
gateway?: string;
unifiedpush?: { gateway?: string };
};
if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') {
return discoveryUrl.toString();
}
} catch (err) {
console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err);
}
return DEFAULT_UNIFIED_PUSH_GATEWAY;
};
class AndroidUnifiedPushManager {
private client: MatrixClient | undefined;
private listenerHandles: PluginListenerHandle[] = [];
private listenersReady = false;
private distributorSetupAttempted = false;
private distributorPromptShown = false;
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
async start(mx: MatrixClient): Promise<void> {
console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
if (!isBackgroundSyncSupported()) {
console.log('[BackgroundSync] Background sync not supported on this platform');
return;
}
const homeserverUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const userId = mx.getUserId();
const deviceId = mx.getDeviceId();
console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
if (!homeserverUrl || !accessToken || !userId) {
console.warn('[BackgroundSync] Missing credentials, not starting');
return;
}
this.client = mx;
this.distributorSetupAttempted = false;
this.distributorPromptShown = false;
await this.ensureListeners();
try {
console.log('[BackgroundSync] Calling plugin.start()...');
await MatrixBackgroundSync.start({
homeserverUrl,
accessToken,
userId,
deviceId: deviceId ?? '',
});
console.log('[BackgroundSync] plugin.start() completed');
} catch (err) {
console.error('[BackgroundSync] plugin.start() failed:', err);
throw err;
}
await this.syncExistingEndpoint();
await this.ensureDistributorPromptFromStatus();
console.log('[BackgroundSync] UnifiedPush registration requested');
}
/** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */
async stop(): Promise<void> {
if (!isBackgroundSyncSupported()) return;
const client = this.client;
if (client) {
const deviceId = client.getDeviceId();
const userId = client.getUserId();
const status = await this.safeGetStatus();
const stored = loadStoredPusherState(userId, deviceId);
const endpoint = status?.endpoint || stored?.endpoint;
const appId = stored?.appId ?? buildPusherAppId(deviceId);
if (endpoint) {
await this.removePusher(client, endpoint, appId);
}
clearStoredPusherState(userId, deviceId);
}
await MatrixBackgroundSync.stop();
await this.disposeListeners();
this.client = undefined;
console.log('[BackgroundSync] UnifiedPush stopped');
}
/** Update the Matrix pusher when a new native endpoint is published. */
private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise<void> {
const client = this.client;
if (!client) return;
if (event.previousEndpoint) {
await this.removePusher(client, event.previousEndpoint);
}
await this.upsertPusher(client, event.endpoint);
}
/** Remove the Matrix pusher when UnifiedPush unregisters this instance. */
private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise<void> {
const client = this.client;
if (!client) return;
const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId());
const endpoint = event.previousEndpoint || stored?.endpoint;
const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId());
if (endpoint) {
await this.removePusher(client, endpoint, appId);
}
clearStoredPusherState(client.getUserId(), client.getDeviceId());
}
/** Log native registration failures so the missing distributor path is visible. */
private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
return;
}
this.distributorSetupAttempted = true;
void this.tryRequestDistributorSetup();
}
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
private async tryRequestDistributorSetup(): Promise<void> {
try {
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
const status = await this.safeGetStatus();
console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
const distributors = normalizeDistributors(status?.distributors ?? []);
if (distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
} catch (err) {
console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
}
}
/** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
private async ensureDistributorPromptFromStatus(): Promise<void> {
const status = await this.safeGetStatus();
if (!status) return;
const distributors = normalizeDistributors(status.distributors);
if (!status.registered && distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
}
/** Show a one-time actionable prompt when no distributor app is installed. */
private showNoDistributorPrompt(): void {
if (this.distributorPromptShown) return;
this.distributorPromptShown = true;
const message =
'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
const docsUrl = 'https://unifiedpush.org/users/distributors/';
if (typeof window === 'undefined') return;
try {
const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
if (openDocs) {
window.open(docsUrl, '_blank', 'noopener,noreferrer');
}
} catch {
window.alert(message);
}
}
/** Install plugin listeners once for the active Matrix client. */
private async ensureListeners(): Promise<void> {
if (this.listenersReady) return;
this.listenerHandles = [
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
void this.handleNewEndpoint(event).catch((err) => {
console.error('[BackgroundSync] handleNewEndpoint failed:', err);
});
}),
await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
void this.handleUnregistered(event);
}),
await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => {
this.handleRegistrationFailed(event);
}),
];
this.listenersReady = true;
}
/** Remove all plugin listeners when the manager stops. */
private async disposeListeners(): Promise<void> {
await Promise.all(this.listenerHandles.map((handle) => handle.remove()));
this.listenerHandles = [];
this.listenersReady = false;
}
/** Reconcile an already-persisted native endpoint after app startup. */
private async syncExistingEndpoint(): Promise<void> {
const client = this.client;
if (!client) return;
const status = await this.safeGetStatus();
console.log('[BackgroundSync] Native status before pusher sync:', status);
if (status?.registered && status.endpoint) {
await this.upsertPusher(client, status.endpoint);
}
}
/** Create or refresh the Matrix HTTP pusher for the current device. */
private async upsertPusher(mx: MatrixClient, endpoint: string): Promise<void> {
const deviceId = mx.getDeviceId();
const userId = mx.getUserId();
const appId = buildPusherAppId(deviceId);
const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
console.log('[BackgroundSync] Upserting Matrix pusher', {
appId,
endpoint,
gatewayUrl,
deviceId,
userId,
});
try {
await mx.setPusher({
kind: 'http',
app_id: appId,
pushkey: endpoint,
app_display_name: 'Paarrot',
device_display_name: deviceId ?? 'Android',
lang: 'en',
data: {
url: gatewayUrl,
format: 'event_id_only',
},
append: false,
device_id: deviceId ?? undefined,
} as unknown as IPusherRequest);
const pushers = (await mx.getPushers())?.pushers ?? [];
const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
console.log('[BackgroundSync] Matrix pusher upserted successfully', {
totalPushers: pushers.length,
foundThisPusher: Boolean(thisPusher),
});
} catch (err) {
console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
throw err;
}
saveStoredPusherState(userId, deviceId, { endpoint, appId });
}
/** Remove a previously-registered Matrix HTTP pusher for this device. */
private async removePusher(
mx: MatrixClient,
endpoint: string,
appId = buildPusherAppId(mx.getDeviceId())
): Promise<void> {
try {
await mx.setPusher({
pushkey: endpoint,
app_id: appId,
kind: null,
} as unknown as IPusherRequest);
} catch (err) {
console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err);
}
}
/** Read native registration state without failing the caller. */
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
try {
return await MatrixBackgroundSync.getStatus();
} catch (err) {
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
return undefined;
}
}
}
const unifiedPushManager = new AndroidUnifiedPushManager();
/** Start native UnifiedPush registration and sync the Matrix pusher. */ /** Start native UnifiedPush registration and sync the Matrix pusher. */
export const startBackgroundSync = async (_mx: MatrixClient): Promise<void> => undefined; export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
await unifiedPushManager.start(mx);
};
/** /**
* Manually triggers a one-shot native fetch. * Manually triggers a one-shot native fetch.
* Useful for diagnostics and bridge testing. * Useful for diagnostics and bridge testing.
*/ */
export const triggerBackgroundSyncPing = async (_reason?: string): Promise<void> => undefined; export const triggerBackgroundSyncPing = async (reason?: string): Promise<void> => {
if (!isBackgroundSyncSupported()) return;
try {
await MatrixBackgroundSync.triggerPing({ reason });
} catch (err) {
console.warn('[BackgroundSync] triggerPing failed:', err);
}
};
/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */ /** Stop UnifiedPush integration and remove the Matrix pusher for this session. */
export const stopBackgroundSync = async (): Promise<void> => undefined; export const stopBackgroundSync = async (): Promise<void> => {
await unifiedPushManager.stop();
};
/** /**
* Tells the native service whether the app UI is in the foreground. * Tells the native service whether the app UI is in the foreground.
* @param _foreground true if the WebView UI is currently visible * Call when document visibility changes so the service can suppress
* duplicate notifications while the JS layer is active.
* @param foreground true if the WebView UI is currently visible
*/ */
export const setAppForegroundState = async (_foreground: boolean): Promise<void> => undefined; export const setAppForegroundState = async (foreground: boolean): Promise<void> => {
if (!isBackgroundSyncSupported()) return;
try {
await MatrixBackgroundSync.setAppForeground({ foreground });
} catch (err) {
console.warn('[BackgroundSync] setAppForeground failed:', err);
}
};
/** /**
* Re-opens the UnifiedPush distributor selection dialog. * Re-opens the UnifiedPush distributor selection dialog.
* In desktop/web builds this is unsupported and always reports false. * Allows users to re-select or change their push notification distributor without terminal access.
* Useful when the current endpoint becomes unavailable.
*/ */
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => ({ export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
success: false, if (!isBackgroundSyncSupported()) {
}); console.warn('[BackgroundSync] Background sync not supported on this platform');
return { success: false };
}
try {
const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
return result ?? { success: false };
} catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false };
}
};
/** Returns the current native UnifiedPush status, or undefined if unavailable. */ /** Returns the current native UnifiedPush status, or undefined if unavailable. */
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => undefined; export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
if (!isBackgroundSyncSupported()) return undefined;
try {
return await MatrixBackgroundSync.getStatus();
} catch (err) {
console.warn('[BackgroundSync] getStatus failed:', err);
return undefined;
}
};

View File

@@ -119,48 +119,6 @@ let notificationTapCallback: ((path: string) => void) | null = null;
const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot'; const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot';
const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00'; const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00';
type CapacitorPermissionResult = {
display: string;
};
type CapacitorLocalNotificationChannel = {
id: string;
name: string;
description: string;
importance: number;
sound: string;
visibility: number;
vibration: boolean;
lights: boolean;
};
type CapacitorLocalNotificationItem = {
id: number;
title: string;
body: string;
channelId?: string;
smallIcon?: string;
iconColor?: string;
extra?: { path: string };
};
type CapacitorLocalNotificationsApi = {
addListener: (
eventName: string,
listenerFunc: (event: any) => void | Promise<void>
) => Promise<unknown>;
createChannel: (channel: CapacitorLocalNotificationChannel) => Promise<void>;
checkPermissions: () => Promise<CapacitorPermissionResult>;
requestPermissions: () => Promise<CapacitorPermissionResult>;
schedule: (options: { notifications: CapacitorLocalNotificationItem[] }) => Promise<void>;
};
const importCapacitorLocalNotifications = async (): Promise<CapacitorLocalNotificationsApi> => {
const pkg = '@capacitor' + '/local-notifications';
const mod = (await import(pkg)) as { LocalNotifications: CapacitorLocalNotificationsApi };
return mod.LocalNotifications;
};
/** /**
* Bring the Tauri window to the front and focus it * Bring the Tauri window to the front and focus it
*/ */
@@ -238,7 +196,7 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
if (isCapacitorNative()) { if (isCapacitorNative()) {
try { try {
const LocalNotifications = await importCapacitorLocalNotifications(); const { LocalNotifications } = await import('@capacitor/local-notifications');
await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => { await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => {
await focusWindow(); await focusWindow();
const path = event?.notification?.extra?.path; const path = event?.notification?.extra?.path;
@@ -378,7 +336,7 @@ const ensureCapacitorNotificationChannel = async (): Promise<void> => {
if (notificationChannelCreated || !isAndroid()) return; if (notificationChannelCreated || !isAndroid()) return;
try { try {
const LocalNotifications = await importCapacitorLocalNotifications(); const { LocalNotifications } = await import('@capacitor/local-notifications');
await LocalNotifications.createChannel({ await LocalNotifications.createChannel({
id: 'messages', id: 'messages',
name: 'Messages', name: 'Messages',
@@ -407,7 +365,7 @@ export const requestSystemNotificationPermission = async (): Promise<boolean> =>
if (isCapacitorNative()) { if (isCapacitorNative()) {
try { try {
const LocalNotifications = await importCapacitorLocalNotifications(); const { LocalNotifications } = await import('@capacitor/local-notifications');
let perm = await LocalNotifications.checkPermissions(); let perm = await LocalNotifications.checkPermissions();
if (perm.display !== 'granted') { if (perm.display !== 'granted') {
perm = await LocalNotifications.requestPermissions(); perm = await LocalNotifications.requestPermissions();
@@ -427,7 +385,7 @@ export const requestSystemNotificationPermission = async (): Promise<boolean> =>
export const getSystemNotificationPermissionState = async (): Promise<PermissionState> => { export const getSystemNotificationPermissionState = async (): Promise<PermissionState> => {
if (isCapacitorNative()) { if (isCapacitorNative()) {
try { try {
const LocalNotifications = await importCapacitorLocalNotifications(); const { LocalNotifications } = await import('@capacitor/local-notifications');
const perm = await LocalNotifications.checkPermissions(); const perm = await LocalNotifications.checkPermissions();
return perm.display === 'granted' ? 'granted' : 'prompt'; return perm.display === 'granted' ? 'granted' : 'prompt';
} catch (err) { } catch (err) {
@@ -513,7 +471,7 @@ export const sendNotification = async (options: {
if (isCapacitorNative()) { if (isCapacitorNative()) {
try { try {
const LocalNotifications = await importCapacitorLocalNotifications(); const { LocalNotifications } = await import('@capacitor/local-notifications');
let perm = await LocalNotifications.checkPermissions(); let perm = await LocalNotifications.checkPermissions();
if (perm.display !== 'granted') { if (perm.display !== 'granted') {
perm = await LocalNotifications.requestPermissions(); perm = await LocalNotifications.requestPermissions();

View File

@@ -4,6 +4,9 @@ import { MsgType } from 'matrix-js-sdk';
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash'; export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler'; export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason'; export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason';
export const PAARROT_CAROUSEL_UUID_PROPERTY_NAME = 'com.paarrot.carousel_uuid';
export const PAARROT_CAROUSEL_INDEX_PROPERTY_NAME = 'com.paarrot.carousel_index';
export const PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME = 'com.paarrot.carousel_total';
export type IImageInfo = { export type IImageInfo = {
w?: number; w?: number;
@@ -51,6 +54,9 @@ export type IImageContent = {
file?: IEncryptedFile; file?: IEncryptedFile;
[MATRIX_SPOILER_PROPERTY_NAME]?: boolean; [MATRIX_SPOILER_PROPERTY_NAME]?: boolean;
[MATRIX_SPOILER_REASON_PROPERTY_NAME]?: string; [MATRIX_SPOILER_REASON_PROPERTY_NAME]?: string;
[PAARROT_CAROUSEL_UUID_PROPERTY_NAME]?: string;
[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME]?: number;
[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME]?: number;
}; };
export type IVideoContent = { export type IVideoContent = {

View File

@@ -220,6 +220,7 @@ export default defineConfig({
server: { server: {
port: 38347, port: 38347,
host: '0.0.0.0', host: '0.0.0.0',
open: false,
cors: true, // Enable CORS for dev server cors: true, // Enable CORS for dev server
fs: { fs: {
// Allow serving files from one level up to the project root // Allow serving files from one level up to the project root