Compare commits
7 Commits
4fdedb87c8
...
carousel-s
| Author | SHA1 | Date | |
|---|---|---|---|
| a3caafaf20 | |||
| b9b0dce40b | |||
| b2fd65c8cb | |||
| 7ef9939d8a | |||
| 43bd6320b8 | |||
| aaf8089ba6 | |||
| 38a43a3a99 |
BIN
build_output.txt
Normal file
BIN
build_output.txt
Normal file
Binary file not shown.
19
config.json
19
config.json
@@ -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
35
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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 { isUserId, mxcUrlToHttp } from '../../utils/matrix';
|
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
|
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
|
||||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
import { EmojiBoardTab, EmojiType } from './types';
|
import { EmojiBoardTab, EmojiType } from './types';
|
||||||
import { VirtualTile } from '../virtualizer';
|
import { VirtualTile } from '../virtualizer';
|
||||||
|
|
||||||
|
const MOST_USED_GROUP_ID = 'most_used_group';
|
||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
const SEARCH_GROUP_ID = 'search_group';
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
|
|
||||||
@@ -72,19 +73,29 @@ const useGroups = (
|
|||||||
imagePacks: ImagePack[]
|
imagePacks: ImagePack[]
|
||||||
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const { getTopEmojis } = useEmojiUsage();
|
||||||
|
|
||||||
const recentEmojis = useRecentEmoji(mx, 21);
|
|
||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
|
|
||||||
const emojiGroupItems = useMemo(() => {
|
const emojiGroupItems = useMemo(() => {
|
||||||
const g: EmojiGroupItem[] = [];
|
const g: EmojiGroupItem[] = [];
|
||||||
if (tab !== EmojiBoardTab.Emoji) return g;
|
if (tab !== EmojiBoardTab.Emoji) return g;
|
||||||
|
|
||||||
|
// Add most-used emojis
|
||||||
|
const topShortcodes = getTopEmojis(21);
|
||||||
|
if (topShortcodes.length > 0) {
|
||||||
|
const mostUsedEmojis = topShortcodes
|
||||||
|
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
|
||||||
|
.filter((e): e is IEmoji => e !== undefined);
|
||||||
|
|
||||||
|
if (mostUsedEmojis.length > 0) {
|
||||||
g.push({
|
g.push({
|
||||||
id: RECENT_GROUP_ID,
|
id: MOST_USED_GROUP_ID,
|
||||||
name: 'Recent',
|
name: 'Most Used',
|
||||||
items: recentEmojis,
|
items: mostUsedEmojis,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
imagePacks.forEach((pack) => {
|
imagePacks.forEach((pack) => {
|
||||||
let label = pack.meta.name;
|
let label = pack.meta.name;
|
||||||
@@ -108,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[] = [];
|
||||||
@@ -187,10 +198,10 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<GroupIcon
|
<GroupIcon
|
||||||
active={activeGroupId === RECENT_GROUP_ID}
|
active={activeGroupId === MOST_USED_GROUP_ID}
|
||||||
id={RECENT_GROUP_ID}
|
id={MOST_USED_GROUP_ID}
|
||||||
label="Recent"
|
label="Most Used"
|
||||||
icon={Icons.RecentClock}
|
icon={Icons.Heart}
|
||||||
onClick={handleScrollToGroup}
|
onClick={handleScrollToGroup}
|
||||||
/>
|
/>
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
@@ -378,13 +389,30 @@ export function EmojiBoard({
|
|||||||
addToRecentEmoji = true,
|
addToRecentEmoji = true,
|
||||||
}: EmojiBoardProps) {
|
}: EmojiBoardProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const { trackEmojiUsage, getMostUsedEmoji } = useEmojiUsage();
|
||||||
|
|
||||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||||
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
|
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
|
||||||
|
|
||||||
|
// Get default emoji - use most used or fallback to slight_smile
|
||||||
|
const defaultPreview = useMemo((): PreviewData => {
|
||||||
|
if (!emojiTab) return undefined;
|
||||||
|
|
||||||
|
const mostUsedShortcode = getMostUsedEmoji();
|
||||||
|
if (mostUsedShortcode) {
|
||||||
|
// Find emoji by shortcode
|
||||||
|
const emoji = emojis.find((e) => e.shortcode === mostUsedShortcode);
|
||||||
|
if (emoji) {
|
||||||
|
return { key: emoji.unicode, shortcode: emoji.shortcode };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to default
|
||||||
|
return DefaultEmojiPreview;
|
||||||
|
}, [emojiTab, getMostUsedEmoji]);
|
||||||
|
|
||||||
const previewAtom = useMemo(
|
const previewAtom = useMemo(
|
||||||
() => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined),
|
() => createPreviewDataAtom(defaultPreview),
|
||||||
[emojiTab]
|
[defaultPreview]
|
||||||
);
|
);
|
||||||
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
||||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||||
@@ -439,6 +467,10 @@ export function EmojiBoard({
|
|||||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
|
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
|
||||||
addRecentEmoji(mx, emojiInfo.data);
|
addRecentEmoji(mx, emojiInfo.data);
|
||||||
|
// Track emoji usage for favorites
|
||||||
|
trackEmojiUsage(emojiInfo.shortcode).catch((err) => {
|
||||||
|
console.error('Failed to track emoji usage:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (emojiInfo.type === EmojiType.CustomEmoji) {
|
if (emojiInfo.type === EmojiType.CustomEmoji) {
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
|||||||
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;
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 formattedBody = customHtml.trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
|
||||||
|
|
||||||
let body = plainText;
|
if (body === '') return;
|
||||||
let formattedBody = customHtml;
|
|
||||||
|
|
||||||
// Apply emoticon conversion if enabled
|
// Apply emoticon conversion if enabled
|
||||||
if (autoConvertEmoticons) {
|
if (autoConvertEmoticons) {
|
||||||
|
|||||||
@@ -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,82 @@ 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',
|
||||||
|
width: `min(${toRem(380)}, 72vw)`,
|
||||||
|
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%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,183 @@ 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getLiveTimeline = (room: Room): EventTimeline =>
|
export const getLiveTimeline = (room: Room): EventTimeline =>
|
||||||
room.getUnfilteredTimelineSet().getLiveTimeline();
|
room.getUnfilteredTimelineSet().getLiveTimeline();
|
||||||
|
|
||||||
@@ -1748,6 +1983,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 +2105,106 @@ 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}>
|
||||||
|
<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 +2238,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 +2308,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);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -91,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
|
||||||
@@ -105,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}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
104
src/app/hooks/useEmojiUsage.ts
Normal file
104
src/app/hooks/useEmojiUsage.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useAccountData } from './useAccountData';
|
||||||
|
|
||||||
|
const PAARROT_EMOJI_USAGE_EVENT = 'paarrot.favemojis';
|
||||||
|
const MAX_TRACKED_EMOJIS = 50;
|
||||||
|
|
||||||
|
export type EmojiUsageData = {
|
||||||
|
[shortcode: string]: {
|
||||||
|
count: number;
|
||||||
|
lastUsed: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for tracking emoji usage and storing favorites in Matrix account data
|
||||||
|
* Stores emoji usage stats under paarrot.favemojis in user's account data
|
||||||
|
*/
|
||||||
|
export function useEmojiUsage() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const event = useAccountData(PAARROT_EMOJI_USAGE_EVENT);
|
||||||
|
|
||||||
|
const emojiUsage = useMemo((): EmojiUsageData => {
|
||||||
|
const content = event?.getContent<EmojiUsageData>();
|
||||||
|
return content || {};
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track emoji usage (max 1 per message as per spec)
|
||||||
|
* @param shortcode - Emoji shortcode (e.g., 'thumbsup', 'smile')
|
||||||
|
*/
|
||||||
|
const trackEmojiUsage = useCallback(
|
||||||
|
async (shortcode: string) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const currentUsage = { ...emojiUsage };
|
||||||
|
|
||||||
|
// Update or create emoji entry
|
||||||
|
currentUsage[shortcode] = {
|
||||||
|
count: (currentUsage[shortcode]?.count || 0) + 1,
|
||||||
|
lastUsed: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep only top MAX_TRACKED_EMOJIS by usage count
|
||||||
|
const entries = Object.entries(currentUsage);
|
||||||
|
if (entries.length > MAX_TRACKED_EMOJIS) {
|
||||||
|
entries.sort((a, b) => b[1].count - a[1].count);
|
||||||
|
const topEmojis = entries.slice(0, MAX_TRACKED_EMOJIS);
|
||||||
|
const newUsage: EmojiUsageData = {};
|
||||||
|
topEmojis.forEach(([key, value]) => {
|
||||||
|
newUsage[key] = value;
|
||||||
|
});
|
||||||
|
await mx.setAccountData(PAARROT_EMOJI_USAGE_EVENT, newUsage);
|
||||||
|
} else {
|
||||||
|
await mx.setAccountData(PAARROT_EMOJI_USAGE_EVENT, currentUsage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, emojiUsage]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get most used emoji shortcode
|
||||||
|
* @returns Shortcode of most used emoji, or undefined if none tracked
|
||||||
|
*/
|
||||||
|
const getMostUsedEmoji = useCallback((): string | undefined => {
|
||||||
|
const entries = Object.entries(emojiUsage);
|
||||||
|
if (entries.length === 0) return undefined;
|
||||||
|
|
||||||
|
// Sort by count descending, then by lastUsed descending
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (b[1].count !== a[1].count) {
|
||||||
|
return b[1].count - a[1].count;
|
||||||
|
}
|
||||||
|
return b[1].lastUsed - a[1].lastUsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries[0][0];
|
||||||
|
}, [emojiUsage]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top N most used emojis
|
||||||
|
* @param count - Number of top emojis to return
|
||||||
|
* @returns Array of shortcodes sorted by usage
|
||||||
|
*/
|
||||||
|
const getTopEmojis = useCallback(
|
||||||
|
(count: number = 10): string[] => {
|
||||||
|
const entries = Object.entries(emojiUsage);
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (b[1].count !== a[1].count) {
|
||||||
|
return b[1].count - a[1].count;
|
||||||
|
}
|
||||||
|
return b[1].lastUsed - a[1].lastUsed;
|
||||||
|
});
|
||||||
|
return entries.slice(0, count).map(([shortcode]) => shortcode);
|
||||||
|
},
|
||||||
|
[emojiUsage]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emojiUsage,
|
||||||
|
trackEmojiUsage,
|
||||||
|
getMostUsedEmoji,
|
||||||
|
getTopEmojis,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -344,7 +344,7 @@ async function sendMessage(matrixClient: any, roomId: string, message: string) {
|
|||||||
|
|
||||||
const content = {
|
const content = {
|
||||||
msgtype: 'm.text',
|
msgtype: 'm.text',
|
||||||
body: message,
|
body: message.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await matrixClient.sendMessage(roomId, content);
|
const result = await matrixClient.sendMessage(roomId, content);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user