Compare commits
20 Commits
e5e296ca98
...
carousel-s
| Author | SHA1 | Date | |
|---|---|---|---|
| a3caafaf20 | |||
| b9b0dce40b | |||
| b2fd65c8cb | |||
| 7ef9939d8a | |||
| 43bd6320b8 | |||
| aaf8089ba6 | |||
| 38a43a3a99 | |||
| 4fdedb87c8 | |||
| 5d4d518af8 | |||
| cad4852878 | |||
| e6af56a417 | |||
| ffec244113 | |||
| 52abffb4c9 | |||
| ef0aa2a847 | |||
| 7b69ed6df8 | |||
| 35ccdc0a22 | |||
| 643608f25d | |||
| 17f7a7eaf2 | |||
| 854f365715 | |||
| 114435a56d |
BIN
build_output.txt
Normal file
BIN
build_output.txt
Normal file
Binary file not shown.
12
capacitor.config.json
Normal file
12
capacitor.config.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.paarrot.app",
|
||||||
|
"appName": "Paarrot",
|
||||||
|
"webDir": "dist",
|
||||||
|
"bundledWebRuntime": false,
|
||||||
|
"plugins": {
|
||||||
|
"LocalNotifications": {
|
||||||
|
"smallIcon": "ic_stat_paarrot",
|
||||||
|
"iconColor": "#FF8A00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"defaultHomeserver": 0,
|
"defaultHomeserver": 2,
|
||||||
"homeserverList": [
|
"homeserverList": [
|
||||||
"ruv.wtf",
|
|
||||||
"exau.dev",
|
|
||||||
"converser.eu",
|
"converser.eu",
|
||||||
"envs.net",
|
"envs.net",
|
||||||
"matrix.org",
|
"matrix.org",
|
||||||
@@ -12,6 +10,10 @@
|
|||||||
],
|
],
|
||||||
"allowCustomHomeservers": true,
|
"allowCustomHomeservers": true,
|
||||||
|
|
||||||
|
"calling": {
|
||||||
|
"livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
|
||||||
|
},
|
||||||
|
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": [
|
"spaces": [
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
15
public/res/svg/notification.svg
Normal file
15
public/res/svg/notification.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1.05922,0,0,1.05922,-0.562821,-0.480534)">
|
||||||
|
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||||
|
<path d="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||||
|
<path d="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||||
|
<path d="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -28,6 +28,7 @@ import {
|
|||||||
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl, GiphyEmbed, isGiphyUrl } from './url-preview';
|
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl, GiphyEmbed, isGiphyUrl } from './url-preview';
|
||||||
import { Image, MediaControl, Video } from './media';
|
import { Image, MediaControl, Video } from './media';
|
||||||
import { ImageViewer } from './image-viewer';
|
import { ImageViewer } from './image-viewer';
|
||||||
|
import { VideoViewer } from './video-viewer';
|
||||||
import { PdfViewer } from './Pdf-viewer';
|
import { PdfViewer } from './Pdf-viewer';
|
||||||
import { TextViewer } from './text-viewer';
|
import { TextViewer } from './text-viewer';
|
||||||
import { testMatrixTo } from '../plugins/matrix-to';
|
import { testMatrixTo } from '../plugins/matrix-to';
|
||||||
@@ -158,6 +159,24 @@ export function RenderMessageContent({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check for custom renderer from plugins BEFORE standard type handling
|
||||||
|
// so plugins can intercept any msgtype including custom ones.
|
||||||
|
const customRendererEarly = pluginRegistry.getRenderer('message');
|
||||||
|
if (customRendererEarly) {
|
||||||
|
try {
|
||||||
|
const messageDataEarly = {
|
||||||
|
msgtype: msgType,
|
||||||
|
...getContent(),
|
||||||
|
};
|
||||||
|
const customContentEarly = customRendererEarly(messageDataEarly, () => null);
|
||||||
|
if (customContentEarly) {
|
||||||
|
return customContentEarly as React.ReactElement;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RenderMessageContent] Custom renderer error (early):', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (msgType === MsgType.Text) {
|
if (msgType === MsgType.Text) {
|
||||||
return (
|
return (
|
||||||
<MText
|
<MText
|
||||||
@@ -240,7 +259,6 @@ export function RenderMessageContent({
|
|||||||
content={getContent()}
|
content={getContent()}
|
||||||
renderAsFile={renderFile}
|
renderAsFile={renderFile}
|
||||||
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
|
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
|
||||||
// Check if there's valid thumbnail metadata
|
|
||||||
const hasValidThumbnail =
|
const hasValidThumbnail =
|
||||||
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
|
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
|
||||||
typeof info.thumbnail_info?.mimetype === 'string';
|
typeof info.thumbnail_info?.mimetype === 'string';
|
||||||
@@ -257,7 +275,6 @@ export function RenderMessageContent({
|
|||||||
renderThumbnail={
|
renderThumbnail={
|
||||||
mediaAutoLoad
|
mediaAutoLoad
|
||||||
? () => hasValidThumbnail ? (
|
? () => hasValidThumbnail ? (
|
||||||
// Try to load the provided thumbnail first
|
|
||||||
<ThumbnailContent
|
<ThumbnailContent
|
||||||
info={info}
|
info={info}
|
||||||
renderImage={(src) => (
|
renderImage={(src) => (
|
||||||
@@ -265,7 +282,6 @@ export function RenderMessageContent({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Fallback: Extract first frame from the video itself
|
|
||||||
<VideoFrameThumbnail
|
<VideoFrameThumbnail
|
||||||
mimeType={mimeType}
|
mimeType={mimeType}
|
||||||
url={url}
|
url={url}
|
||||||
@@ -277,6 +293,7 @@ export function RenderMessageContent({
|
|||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
renderViewer={(p) => <VideoViewer {...p} />}
|
||||||
renderVideo={(p) => <Video {...p} />}
|
renderVideo={(p) => <Video {...p} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export type CreateRoomData = {
|
|||||||
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
||||||
const initialState: ICreateRoomStateEvent[] = [];
|
const initialState: ICreateRoomStateEvent[] = [];
|
||||||
|
|
||||||
initialState.push(createRoomPowerLevelsState());
|
// initialState.push(createRoomPowerLevelsState()); // Removed: causes 403 when setting permissions before admin
|
||||||
|
|
||||||
if (data.encryption) {
|
if (data.encryption) {
|
||||||
initialState.push(createRoomEncryptionState());
|
initialState.push(createRoomEncryptionState());
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
|
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
|
||||||
|
import { useRoomListReorder, useListReorderAnimation } from '../../hooks/useRoomListReorder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the DirectMenu component
|
* Props for the DirectMenu component
|
||||||
@@ -215,13 +216,17 @@ export function DirectList({
|
|||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
|
const reorderTrigger = useRoomListReorder(mx, directs);
|
||||||
|
|
||||||
const sortedDirects = useMemo(() => {
|
const sortedDirects = useMemo(() => {
|
||||||
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
||||||
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
||||||
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
|
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
|
||||||
|
|
||||||
|
const animationRef = useListReorderAnimation(sortedDirects);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: sortedDirects.length,
|
||||||
@@ -275,6 +280,7 @@ export function DirectList({
|
|||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
ref={animationRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: virtualizer.getTotalSize(),
|
height: virtualizer.getTotalSize(),
|
||||||
@@ -287,7 +293,12 @@ export function DirectList({
|
|||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={roomId}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
data-room-id={roomId}
|
||||||
|
>
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
|
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
|
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
|
||||||
import * as css from './media.css';
|
import * as css from './media.css';
|
||||||
@@ -6,16 +7,14 @@ import * as videoCss from './videoControls.css';
|
|||||||
|
|
||||||
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
|
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
|
||||||
disableControls?: boolean;
|
disableControls?: boolean;
|
||||||
|
onExpand?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
||||||
({ className, disableControls, ...props }, ref) => {
|
({ className, disableControls, onExpand, ...props }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [showControls, setShowControls] = useState(false);
|
const [showControls, setShowControls] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
|
|
||||||
// Combine refs
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof ref === 'function') {
|
if (typeof ref === 'function') {
|
||||||
ref(videoRef.current);
|
ref(videoRef.current);
|
||||||
@@ -24,74 +23,22 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
}
|
}
|
||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
|
||||||
// Track fullscreen state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFullscreenChange = () => {
|
if (!onExpand) return;
|
||||||
setIsFullscreen(!!document.fullscreenElement);
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const handleFullscreenRequest = (evt: Event) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopImmediatePropagation();
|
||||||
|
onExpand();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
video.addEventListener('webkitbeginfullscreen', handleFullscreenRequest);
|
||||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
return () => {
|
||||||
}, []);
|
video.removeEventListener('webkitbeginfullscreen', handleFullscreenRequest);
|
||||||
|
|
||||||
const handleFullscreen = async () => {
|
|
||||||
try {
|
|
||||||
if (!document.fullscreenElement && containerRef.current) {
|
|
||||||
await containerRef.current.requestFullscreen();
|
|
||||||
} else if (document.fullscreenElement) {
|
|
||||||
await document.exitFullscreen();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling fullscreen:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePopOut = () => {
|
|
||||||
const videoSrc = videoRef.current?.src;
|
|
||||||
const videoTitle = props.title || 'Video';
|
|
||||||
|
|
||||||
if (videoSrc) {
|
|
||||||
const popoutWindow = window.open(
|
|
||||||
'',
|
|
||||||
'_blank',
|
|
||||||
'width=800,height=600,menubar=no,toolbar=no,location=no,status=no'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (popoutWindow) {
|
|
||||||
popoutWindow.document.write(`
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>${videoTitle}</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: #000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
video {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<video controls autoplay src="${videoSrc}">
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
popoutWindow.document.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}, [onExpand]);
|
||||||
|
|
||||||
if (disableControls) {
|
if (disableControls) {
|
||||||
return (
|
return (
|
||||||
@@ -109,7 +56,6 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={containerRef}
|
|
||||||
className={videoCss.VideoContainer}
|
className={videoCss.VideoContainer}
|
||||||
onMouseEnter={() => setShowControls(true)}
|
onMouseEnter={() => setShowControls(true)}
|
||||||
onMouseLeave={() => setShowControls(false)}
|
onMouseLeave={() => setShowControls(false)}
|
||||||
@@ -122,14 +68,16 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
loop={props.autoPlay}
|
loop={props.autoPlay}
|
||||||
muted={props.autoPlay}
|
muted={props.autoPlay}
|
||||||
playsInline
|
playsInline
|
||||||
|
controlsList={onExpand ? 'nofullscreen' : undefined}
|
||||||
|
onDoubleClick={onExpand ? (e) => { e.stopPropagation(); onExpand(); } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showControls && (
|
{onExpand && (
|
||||||
<Box className={videoCss.VideoControlsOverlay} gap="200">
|
<Box className={classNames(videoCss.VideoControlsOverlay, !showControls && videoCss.VideoControlsOverlayHidden)} gap="200">
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip variant="Secondary">
|
<Tooltip variant="Secondary">
|
||||||
<Text size="T200">{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text>
|
<Text size="T200">Expand</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
@@ -141,36 +89,10 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={handleFullscreen}
|
onClick={onExpand}
|
||||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
aria-label="Expand video"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon size="50" src={Icons.Plus} />
|
||||||
size="200"
|
|
||||||
src={isFullscreen ? Icons.Minus : Icons.Plus}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<TooltipProvider
|
|
||||||
tooltip={
|
|
||||||
<Tooltip variant="Secondary">
|
|
||||||
<Text size="T200">Pop Out</Text>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
position="Bottom"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
{(triggerRef) => (
|
|
||||||
<IconButton
|
|
||||||
ref={triggerRef}
|
|
||||||
variant="Secondary"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
onClick={handlePopOut}
|
|
||||||
aria-label="Open in new window"
|
|
||||||
>
|
|
||||||
<Icon size="200" src={Icons.External} />
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const Image = style([
|
|||||||
display: 'block',
|
display: 'block',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
imageRendering: 'auto',
|
imageRendering: 'auto',
|
||||||
backfaceVisibility: 'hidden',
|
backfaceVisibility: 'hidden',
|
||||||
transform: 'translateZ(0)',
|
transform: 'translateZ(0)',
|
||||||
|
|||||||
@@ -24,5 +24,12 @@ export const VideoControlsOverlay = style([
|
|||||||
gap: config.space.S200,
|
gap: config.space.S200,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
|
opacity: 1,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const VideoControlsOverlayHidden = style({
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -197,7 +206,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined}>
|
<Attachment outlined={outlined} transparent>
|
||||||
<AttachmentBox>
|
<AttachmentBox>
|
||||||
{renderImageContent({
|
{renderImageContent({
|
||||||
body: content.body || 'Image',
|
body: content.body || 'Image',
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export const Reply = style({
|
|||||||
|
|
||||||
export const ReplyContent = style({
|
export const ReplyContent = style({
|
||||||
opacity: config.opacity.P300,
|
opacity: config.opacity.P300,
|
||||||
|
overflow: 'clip',
|
||||||
|
overflowY: 'clip',
|
||||||
|
lineHeight: '1.5',
|
||||||
selectors: {
|
selectors: {
|
||||||
[`${Reply}:hover &`]: {
|
[`${Reply}:hover &`]: {
|
||||||
opacity: config.opacity.P500,
|
opacity: config.opacity.P500,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -14,6 +18,7 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { BlurhashCanvas } from 'react-blurhash';
|
import { BlurhashCanvas } from 'react-blurhash';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import {
|
import {
|
||||||
IThumbnailContent,
|
IThumbnailContent,
|
||||||
@@ -33,7 +38,14 @@ import {
|
|||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
|
||||||
|
type RenderViewerProps = {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
src: string;
|
src: string;
|
||||||
@@ -41,6 +53,7 @@ type RenderVideoProps = {
|
|||||||
onError: () => void;
|
onError: () => void;
|
||||||
autoPlay: boolean;
|
autoPlay: boolean;
|
||||||
controls: boolean;
|
controls: boolean;
|
||||||
|
onExpand?: () => void;
|
||||||
};
|
};
|
||||||
type VideoContentProps = {
|
type VideoContentProps = {
|
||||||
body: string;
|
body: string;
|
||||||
@@ -52,6 +65,7 @@ type VideoContentProps = {
|
|||||||
markedAsSpoiler?: boolean;
|
markedAsSpoiler?: boolean;
|
||||||
spoilerReason?: string;
|
spoilerReason?: string;
|
||||||
renderThumbnail?: () => ReactNode;
|
renderThumbnail?: () => ReactNode;
|
||||||
|
renderViewer?: (props: RenderViewerProps) => ReactNode;
|
||||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||||
};
|
};
|
||||||
export const VideoContent = as<'div', VideoContentProps>(
|
export const VideoContent = as<'div', VideoContentProps>(
|
||||||
@@ -67,6 +81,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
markedAsSpoiler,
|
markedAsSpoiler,
|
||||||
spoilerReason,
|
spoilerReason,
|
||||||
renderThumbnail,
|
renderThumbnail,
|
||||||
|
renderViewer,
|
||||||
renderVideo,
|
renderVideo,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
@@ -78,6 +93,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
@@ -113,6 +129,28 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
{renderViewer && srcState.status === AsyncStatus.Success && (
|
||||||
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setViewer(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal className={ModalWide} size="500">
|
||||||
|
{renderViewer({
|
||||||
|
src: srcState.data,
|
||||||
|
alt: body,
|
||||||
|
requestClose: () => setViewer(false),
|
||||||
|
})}
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
{typeof blurHash === 'string' && !load && (
|
{typeof blurHash === 'string' && !load && (
|
||||||
<BlurhashCanvas
|
<BlurhashCanvas
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
@@ -154,6 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
onError: handleError,
|
onError: handleError,
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
controls: true,
|
controls: true,
|
||||||
|
onExpand: renderViewer ? () => setViewer(true) : undefined,
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const RelativeBase = style([
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
|
borderRadius: '7px',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ export const AbsoluteContainer = style([
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -188,8 +188,10 @@ export const BubbleLeftArrow = style({
|
|||||||
|
|
||||||
export const Username = style({
|
export const Username = style({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
overflowY: 'clip',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
|
lineHeight: '1.5',
|
||||||
// Allow text selection while keeping button clickable
|
// Allow text selection while keeping button clickable
|
||||||
userSelect: 'text',
|
userSelect: 'text',
|
||||||
WebkitUserSelect: 'text',
|
WebkitUserSelect: 'text',
|
||||||
@@ -210,6 +212,9 @@ export const UsernameBold = style({
|
|||||||
export const MessageTextBody = recipe({
|
export const MessageTextBody = recipe({
|
||||||
base: {
|
base: {
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
|
overflow: 'clip',
|
||||||
|
overflowY: 'clip',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
preWrap: {
|
preWrap: {
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
|
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
|
||||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
||||||
|
|
||||||
|
console.log('[UserHero]', {
|
||||||
|
userId,
|
||||||
|
avatarMxc,
|
||||||
|
bannerUrl,
|
||||||
|
profileStyle,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
@@ -125,6 +132,13 @@ export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNamePro
|
|||||||
const username = getMxIdLocalPart(userId);
|
const username = getMxIdLocalPart(userId);
|
||||||
const userColor = useOtherUserColor(userId, avatarMxc);
|
const userColor = useOtherUserColor(userId, avatarMxc);
|
||||||
|
|
||||||
|
console.log('[UserHeroName]', {
|
||||||
|
userId,
|
||||||
|
avatarMxc,
|
||||||
|
userColor,
|
||||||
|
fallbackColor: colorMXID(userId),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
<Box alignItems="Baseline" gap="200" wrap="Wrap">
|
<Box alignItems="Baseline" gap="200" wrap="Wrap">
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import { DirectCreateSearchParams } from '../../pages/paths';
|
|||||||
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
|
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
|
||||||
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
|
|
||||||
type UserRoomProfileProps = {
|
type UserRoomProfileProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -64,6 +65,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
|
|
||||||
const presence = useUserPresence(userId);
|
const presence = useUserPresence(userId);
|
||||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
||||||
|
const userColor = useOtherUserColor(userId, avatarMxc);
|
||||||
|
|
||||||
// Build gradient CSS if configured
|
// Build gradient CSS if configured
|
||||||
const gradientStyle = profileStyle.gradient
|
const gradientStyle = profileStyle.gradient
|
||||||
@@ -87,9 +89,25 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
(canKickUser && membership === Membership.Join) ||
|
(canKickUser && membership === Membership.Join) ||
|
||||||
(canBanUser && membership !== Membership.Ban);
|
(canBanUser && membership !== Membership.Ban);
|
||||||
|
|
||||||
const profileColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : colorMXID(userId);
|
// Use userColor for text, or fall back to colorMXID
|
||||||
|
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
|
||||||
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
|
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
|
||||||
const pillBgColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : undefined;
|
|
||||||
|
// Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent)
|
||||||
|
const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00');
|
||||||
|
const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined;
|
||||||
|
|
||||||
|
console.log('[UserRoomProfile]', {
|
||||||
|
userId,
|
||||||
|
avatarMxc,
|
||||||
|
userColor,
|
||||||
|
profileStyle,
|
||||||
|
profileColor,
|
||||||
|
profileTextShadow,
|
||||||
|
hasVisibleBorder,
|
||||||
|
pillBgColor,
|
||||||
|
fallbackColorMXID: getColorMXIDValue(userId),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
|
|||||||
40
src/app/components/video-viewer/VideoViewer.css.ts
Normal file
40
src/app/components/video-viewer/VideoViewer.css.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, color, config } from 'folds';
|
||||||
|
|
||||||
|
export const VideoViewer = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const VideoViewerHeader = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
paddingLeft: config.space.S200,
|
||||||
|
paddingRight: config.space.S200,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
flexShrink: 0,
|
||||||
|
gap: config.space.S200,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const VideoViewerContent = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const VideoViewerVideo = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
},
|
||||||
|
]);
|
||||||
78
src/app/components/video-viewer/VideoViewer.tsx
Normal file
78
src/app/components/video-viewer/VideoViewer.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||||
|
import * as css from './VideoViewer.css';
|
||||||
|
import { downloadMedia } from '../../utils/matrix';
|
||||||
|
import { getCurrentAccessToken } from '../../utils/auth';
|
||||||
|
|
||||||
|
export type VideoViewerProps = {
|
||||||
|
alt: string;
|
||||||
|
src: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VideoViewer = as<'div', VideoViewerProps>(
|
||||||
|
({ className, alt, src, requestClose, ...props }, ref) => { const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
||||||
|
FileSaver.saveAs(fileContent, alt);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[VideoViewer] Failed to download media:', error);
|
||||||
|
try {
|
||||||
|
const response = await fetch(src);
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
FileSaver.saveAs(blob, alt);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
window.open(src, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.VideoViewer, className)}
|
||||||
|
direction="Column"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Header className={css.VideoViewerHeader} size="400">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
|
</IconButton>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{alt}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
onClick={handleDownload}
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="50" src={Icons.Download} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Download</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
className={css.VideoViewerContent}
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
className={css.VideoViewerVideo}
|
||||||
|
src={src}
|
||||||
|
title={alt}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
1
src/app/components/video-viewer/index.ts
Normal file
1
src/app/components/video-viewer/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './VideoViewer';
|
||||||
316
src/app/features/ShareRoomPicker.tsx
Normal file
316
src/app/features/ShareRoomPicker.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
MenuItem,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Scroll,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
|
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
||||||
|
import { allRoomsAtom } from '../state/room-list/roomList';
|
||||||
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
|
import { useDirects, useRooms } from '../state/hooks/roomList';
|
||||||
|
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
|
||||||
|
import { RoomAvatar, RoomIcon } from '../components/room-avatar';
|
||||||
|
import { nameInitials } from '../utils/common';
|
||||||
|
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
||||||
|
import { factoryRoomIdByActivity } from '../utils/sort';
|
||||||
|
import {
|
||||||
|
SearchItemStrGetter,
|
||||||
|
useAsyncSearch,
|
||||||
|
UseAsyncSearchOptions,
|
||||||
|
} from '../hooks/useAsyncSearch';
|
||||||
|
import { VirtualTile } from '../components/virtualizer';
|
||||||
|
import { AndroidSharePayload } from '../utils/androidShare';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
|
||||||
|
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
||||||
|
limit: 200,
|
||||||
|
matchOptions: {
|
||||||
|
contain: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShareRoomPickerProps = {
|
||||||
|
/** The pending share payload to display a preview of. */
|
||||||
|
share: AndroidSharePayload;
|
||||||
|
/** Called when the user picks a room. */
|
||||||
|
onPick: (roomId: string) => void;
|
||||||
|
/** Called when the user dismisses without picking. */
|
||||||
|
onDismiss: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen modal that lets the user pick a room to send
|
||||||
|
* an incoming Android share intent into.
|
||||||
|
*/
|
||||||
|
export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const allRoomsSet = useAllJoinedRoomsSet();
|
||||||
|
const getRoom = useGetRoom(allRoomsSet);
|
||||||
|
const rooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||||
|
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||||
|
|
||||||
|
const allItems = useMemo(
|
||||||
|
() => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
|
||||||
|
[mx, rooms, directs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||||
|
(rId) => getRoom(rId)?.name ?? rId,
|
||||||
|
[getRoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
|
||||||
|
allItems,
|
||||||
|
getRoomNameStr,
|
||||||
|
SEARCH_OPTS
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = searchResult ? searchResult.items : allItems;
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 48,
|
||||||
|
overscan: 8,
|
||||||
|
});
|
||||||
|
const vItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const value = evt.currentTarget.value.trim();
|
||||||
|
if (!value) {
|
||||||
|
resetSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchRoom(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
const roomId = evt.currentTarget.getAttribute('data-room-id');
|
||||||
|
if (roomId) {
|
||||||
|
setConfirmRoomId(roomId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
if (confirmRoomId) {
|
||||||
|
onPick(confirmRoomId);
|
||||||
|
}
|
||||||
|
}, [confirmRoomId, onPick]);
|
||||||
|
|
||||||
|
const handleCancelConfirm = useCallback(() => {
|
||||||
|
setConfirmRoomId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
|
||||||
|
const fileCount = share.files.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Overlay open>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
onDeactivate: onDismiss,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 999,
|
||||||
|
backgroundColor: '#1a1d23',
|
||||||
|
paddingTop: '60px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
size="500"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
|
||||||
|
<Text size="H4">Share to Room</Text>
|
||||||
|
{(previewText || fileCount > 0) && (
|
||||||
|
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
|
{previewText
|
||||||
|
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
||||||
|
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton size="300" radii="300" onClick={onDismiss}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
before={<Icon size="200" src={Icons.Search} />}
|
||||||
|
placeholder="Search rooms…"
|
||||||
|
size="400"
|
||||||
|
variant="Background"
|
||||||
|
outlined
|
||||||
|
autoFocus
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<Box style={{ padding: config.space.S300 }} direction="Column">
|
||||||
|
{vItems.length === 0 && (
|
||||||
|
<Box
|
||||||
|
style={{ padding: `${config.space.S700} 0` }}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
<Text size="H6" align="Center">
|
||||||
|
{searchResult ? 'No Match Found' : 'No Rooms'}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" align="Center" priority="300">
|
||||||
|
{searchResult
|
||||||
|
? `No rooms found for "${searchResult.query}".`
|
||||||
|
: 'You have no rooms to share into.'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
|
||||||
|
{vItems.map((vItem) => {
|
||||||
|
const roomId = items[vItem.index];
|
||||||
|
const room = getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
const isDm = mDirects.has(roomId);
|
||||||
|
|
||||||
|
const avatarSrc = isDm
|
||||||
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
style={{ paddingBottom: config.space.S100 }}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
key={roomId}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
data-room-id={roomId}
|
||||||
|
onClick={handleRoomClick}
|
||||||
|
variant="Surface"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
before={
|
||||||
|
<Avatar size="300" radii={isDm ? '400' : '300'}>
|
||||||
|
{avatarSrc ? (
|
||||||
|
<RoomAvatar
|
||||||
|
roomId={roomId}
|
||||||
|
src={avatarSrc}
|
||||||
|
alt={room.name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text as="span" size="H6">
|
||||||
|
{nameInitials(room.name)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RoomIcon room={room} size="200" filled />
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Text size="B400" truncate>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</FocusTrap>
|
||||||
|
</Overlay>
|
||||||
|
|
||||||
|
{confirmRoomId && (() => {
|
||||||
|
const room = getRoom(confirmRoomId);
|
||||||
|
const roomName = room?.name ?? confirmRoomId;
|
||||||
|
const isDm = mDirects.has(confirmRoomId);
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleCancelConfirm,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal 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>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
||||||
import { Room, EventTimeline } from 'matrix-js-sdk';
|
import { Room, EventTimeline } from 'matrix-js-sdk';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -44,7 +45,7 @@ import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../util
|
|||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom, ScrollToLatestBehavior } from '../../state/settings';
|
||||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import {
|
import {
|
||||||
@@ -294,6 +295,8 @@ export function RoomNavItem({
|
|||||||
}: RoomNavItemProps) {
|
}: RoomNavItemProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||||
@@ -365,6 +368,20 @@ export function RoomNavItem({
|
|||||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRoomClick: MouseEventHandler<HTMLAnchorElement> = (evt) => {
|
||||||
|
if (selected) {
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
const shouldScroll =
|
||||||
|
scrollToLatestBehavior === ScrollToLatestBehavior.Always ||
|
||||||
|
(scrollToLatestBehavior === ScrollToLatestBehavior.OnNewMessage && unread !== undefined);
|
||||||
|
|
||||||
|
if (shouldScroll) {
|
||||||
|
navigate(linkPath, { replace: true, state: { scrollToLatest: Date.now() } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const optionsVisible = hover || !!menuAnchor;
|
const optionsVisible = hover || !!menuAnchor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -380,7 +397,7 @@ export function RoomNavItem({
|
|||||||
{...hoverProps}
|
{...hoverProps}
|
||||||
{...focusWithinProps}
|
{...focusWithinProps}
|
||||||
>
|
>
|
||||||
<NavLink to={linkPath}>
|
<NavLink to={linkPath} onClick={handleRoomClick}>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
||||||
{shouldShowIcon && (
|
{shouldShowIcon && (
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -47,11 +49,12 @@ import {
|
|||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
|
||||||
import {
|
import {
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
CompactPlaceholder,
|
CompactPlaceholder,
|
||||||
@@ -61,6 +64,7 @@ import {
|
|||||||
Time,
|
Time,
|
||||||
MessageNotDecryptedContent,
|
MessageNotDecryptedContent,
|
||||||
RedactedContent,
|
RedactedContent,
|
||||||
|
MImage,
|
||||||
MSticker,
|
MSticker,
|
||||||
ImageContent,
|
ImageContent,
|
||||||
EventContent,
|
EventContent,
|
||||||
@@ -84,7 +88,7 @@ import {
|
|||||||
reactionOrEditEvent,
|
reactionOrEditEvent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
import { MessageLayout, ScrollToLatestBehavior, settingsAtom } from '../../state/settings';
|
||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||||
@@ -104,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';
|
||||||
@@ -145,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
|
||||||
@@ -168,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();
|
||||||
|
|
||||||
@@ -473,6 +709,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
|
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
|
||||||
|
|
||||||
// Track consecutive call member events for collapsing
|
// Track consecutive call member events for collapsing
|
||||||
const pendingCallEventsRef = useRef<CallEventInfo[]>([]);
|
const pendingCallEventsRef = useRef<CallEventInfo[]>([]);
|
||||||
@@ -511,10 +748,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const markAsRead = useMarkAsRead(mx);
|
const markAsRead = useMarkAsRead(mx);
|
||||||
|
const location = useLocation();
|
||||||
|
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
|
const [unreadInfo, setUnreadInfo] = useState(() =>
|
||||||
|
getRoomUnreadInfo(room, scrollToLatestBehavior !== ScrollToLatestBehavior.Always)
|
||||||
|
);
|
||||||
const readUptoEventIdRef = useRef<string>();
|
const readUptoEventIdRef = useRef<string>();
|
||||||
if (unreadInfo) {
|
if (unreadInfo) {
|
||||||
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
|
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
|
||||||
@@ -525,10 +766,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const atBottomRef = useRef(atBottom);
|
const atBottomRef = useRef(atBottom);
|
||||||
atBottomRef.current = atBottom;
|
atBottomRef.current = atBottom;
|
||||||
|
|
||||||
|
const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineContentHeightRef = useRef(0);
|
||||||
const scrollToBottomRef = useRef({
|
const scrollToBottomRef = useRef({
|
||||||
count: 0,
|
count: 0,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [focusItem, setFocusItem] = useState<
|
const [focusItem, setFocusItem] = useState<
|
||||||
@@ -628,6 +877,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
}, [alive, room])
|
}, [alive, room])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -635,25 +885,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
room,
|
room,
|
||||||
useCallback(
|
useCallback(
|
||||||
(mEvt: MatrixEvent) => {
|
(mEvt: MatrixEvent) => {
|
||||||
// if user is at bottom of timeline
|
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||||
// keep paginating timeline and conditionally mark as read
|
const shouldStickToBottom = atBottomRef.current || isOwnMessage;
|
||||||
// otherwise we update timeline without paginating
|
|
||||||
// so timeline can be updated with evt like: edits, reactions etc
|
|
||||||
if (atBottomRef.current) {
|
|
||||||
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
|
|
||||||
// Check if the document is in focus (user is actively viewing the app),
|
|
||||||
// and either there are no unread messages or the latest message is from the current user.
|
|
||||||
// If either condition is met, trigger the markAsRead function to send a read receipt.
|
|
||||||
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!document.hasFocus() && !unreadInfo) {
|
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToBottomRef.current.count += 1;
|
|
||||||
scrollToBottomRef.current.smooth = true;
|
|
||||||
|
|
||||||
|
// Always update timeline with new message
|
||||||
setTimeline((ct) => ({
|
setTimeline((ct) => ({
|
||||||
...ct,
|
...ct,
|
||||||
range: {
|
range: {
|
||||||
@@ -661,12 +896,39 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
end: ct.range.end + 1,
|
end: ct.range.end + 1,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
|
||||||
|
// Handle own messages and focus
|
||||||
|
if (isOwnMessage) {
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
|
if (document.hasFocus() && (!unreadInfo)) {
|
||||||
|
const roomId = mEvt.getRoomId();
|
||||||
|
if (roomId) {
|
||||||
|
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||||
}
|
}
|
||||||
setTimeline((ct) => ({ ...ct }));
|
}
|
||||||
if (!unreadInfo) {
|
} else if (!document.hasFocus() && !unreadInfo) {
|
||||||
|
// Show unread notification for other users' messages when unfocused
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
setUnreadInfo(getRoomUnreadInfo(room));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve the pre-update bottom state so a newly added message keeps the view pinned.
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = !isOwnMessage;
|
||||||
|
scrollToBottomRef.current.force = shouldStickToBottom;
|
||||||
|
|
||||||
|
// Show unread message preview for incoming messages when not at bottom
|
||||||
|
if (!isOwnMessage && !atBottomRef.current) {
|
||||||
|
const senderName =
|
||||||
|
getMemberDisplayName(room, mEvt.getSender() ?? '') ??
|
||||||
|
getMxIdLocalPart(mEvt.getSender() ?? '') ??
|
||||||
|
'Unknown';
|
||||||
|
const content = mEvt.getContent();
|
||||||
|
const messageText = content.body ?? content.msgtype ?? 'New message';
|
||||||
|
setLatestUnreadMessage({
|
||||||
|
sender: senderName,
|
||||||
|
content: messageText.length > 100 ? `${messageText.substring(0, 100)}...` : messageText,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mx, room, unreadInfo, hideActivity, markAsRead]
|
[mx, room, unreadInfo, hideActivity, markAsRead]
|
||||||
)
|
)
|
||||||
@@ -727,13 +989,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (!editorBaseEntry || !scrollElement) return;
|
if (!editorBaseEntry || !scrollElement) return;
|
||||||
|
|
||||||
if (atBottomRef.current) {
|
if (atBottomRef.current) {
|
||||||
scrollToBottom(scrollElement);
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [getScrollElement, roomInputRef]),
|
}, [getScrollElement, roomInputRef]),
|
||||||
useCallback(() => roomInputRef.current, [roomInputRef])
|
useCallback(() => roomInputRef.current, [roomInputRef])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useResizeObserver(
|
||||||
|
useCallback((entries) => {
|
||||||
|
const timelineContent = timelineContentRef.current;
|
||||||
|
const scrollElement = getScrollElement();
|
||||||
|
if (!timelineContent || !scrollElement) return;
|
||||||
|
|
||||||
|
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
|
||||||
|
if (!timelineEntry) return;
|
||||||
|
|
||||||
|
const nextHeight = timelineEntry.contentRect.height;
|
||||||
|
const previousHeight = timelineContentHeightRef.current;
|
||||||
|
timelineContentHeightRef.current = nextHeight;
|
||||||
|
|
||||||
|
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
|
}, [getScrollElement]),
|
||||||
|
useCallback(() => timelineContentRef.current, [])
|
||||||
|
);
|
||||||
|
|
||||||
const tryAutoMarkAsRead = useCallback(() => {
|
const tryAutoMarkAsRead = useCallback(() => {
|
||||||
const readUptoEventId = readUptoEventIdRef.current;
|
const readUptoEventId = readUptoEventIdRef.current;
|
||||||
if (!readUptoEventId) {
|
if (!readUptoEventId) {
|
||||||
@@ -749,7 +1033,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
|
|
||||||
const debounceSetAtBottom = useDebounce(
|
const debounceSetAtBottom = useDebounce(
|
||||||
useCallback((entry: IntersectionObserverEntry) => {
|
useCallback((entry: IntersectionObserverEntry) => {
|
||||||
if (!entry.isIntersecting) setAtBottom(false);
|
if (!entry.isIntersecting) {
|
||||||
|
setAtBottom(false);
|
||||||
|
}
|
||||||
}, []),
|
}, []),
|
||||||
{ wait: 1000 }
|
{ wait: 1000 }
|
||||||
);
|
);
|
||||||
@@ -762,6 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
if (targetEntry) debounceSetAtBottom(targetEntry);
|
||||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
if (document.hasFocus()) {
|
if (document.hasFocus()) {
|
||||||
tryAutoMarkAsRead();
|
tryAutoMarkAsRead();
|
||||||
}
|
}
|
||||||
@@ -831,11 +1118,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [eventId, loadEventTimeline]);
|
}, [eventId, loadEventTimeline]);
|
||||||
|
|
||||||
|
// Scroll to latest when clicking on already-selected room
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollToLatest) {
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
|
setUnreadInfo(undefined);
|
||||||
|
setTimeline(getInitialTimeline(room));
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
|
}
|
||||||
|
}, [scrollToLatest, room]);
|
||||||
|
|
||||||
// Scroll to bottom on initial timeline load
|
// Scroll to bottom on initial timeline load
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl) {
|
if (scrollEl) {
|
||||||
scrollToBottom(scrollEl);
|
autoScrollToBottom(scrollEl, false, true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -882,8 +1181,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (scrollToBottomCount > 0) {
|
if (scrollToBottomCount > 0) {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl)
|
if (scrollEl) {
|
||||||
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
autoScrollToBottom(
|
||||||
|
scrollEl,
|
||||||
|
scrollToBottomRef.current.smooth,
|
||||||
|
scrollToBottomRef.current.force
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [scrollToBottomCount]);
|
}, [scrollToBottomCount]);
|
||||||
|
|
||||||
@@ -936,9 +1240,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
const handleJumpToUnread = () => {
|
||||||
@@ -1677,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 = [];
|
||||||
@@ -1798,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;
|
||||||
@@ -1831,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',
|
||||||
@@ -1842,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);
|
||||||
@@ -1949,6 +2416,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)}
|
)}
|
||||||
<Scroll ref={scrollRef} visibility="Hover">
|
<Scroll ref={scrollRef} visibility="Hover">
|
||||||
<Box
|
<Box
|
||||||
|
ref={timelineContentRef}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
justifyContent="End"
|
justifyContent="End"
|
||||||
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
||||||
@@ -2036,6 +2504,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</Scroll>
|
</Scroll>
|
||||||
{!atBottom && (
|
{!atBottom && (
|
||||||
<TimelineFloat position="Bottom">
|
<TimelineFloat position="Bottom">
|
||||||
|
<Box direction="Column" gap="200" alignItems="Center">
|
||||||
|
{latestUnreadMessage && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
maxWidth: toRem(400),
|
||||||
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T300" priority="300" style={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
<b>{latestUnreadMessage.sender}:</b> {latestUnreadMessage.content}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Chip
|
<Chip
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
@@ -2045,6 +2533,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
>
|
>
|
||||||
<Text size="L400">Jump to Latest</Text>
|
<Text size="L400">Jump to Latest</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
|
</Box>
|
||||||
</TimelineFloat>
|
</TimelineFloat>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -24,4 +24,7 @@ export const RoomViewTyping = style([
|
|||||||
]);
|
]);
|
||||||
export const TypingText = style({
|
export const TypingText = style({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
overflow: 'clip',
|
||||||
|
overflowY: 'clip',
|
||||||
|
lineHeight: '1.5',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ import {
|
|||||||
} from '../../utils/matrix';
|
} from '../../utils/matrix';
|
||||||
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
|
||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
import { scrollToBottom } from '../../utils/dom';
|
import { autoScrollToBottom, scrollElementIntoView } from '../../utils/dom';
|
||||||
|
|
||||||
type ThreadViewProps = {
|
type ThreadViewProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -406,7 +406,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
// Scroll to bottom on mount and when new events arrive.
|
// Scroll to bottom on mount and when new events arrive.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) scrollToBottom(el);
|
if (el) autoScrollToBottom(el);
|
||||||
}, [events.length]);
|
}, [events.length]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -481,7 +481,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
const el = scrollRef.current?.querySelector(
|
const el = scrollRef.current?.querySelector(
|
||||||
`[data-message-id="${targetId}"]`
|
`[data-message-id="${targetId}"]`
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
if (el) scrollElementIntoView(el, { behavior: 'smooth', block: 'center' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReactionToggle = useCallback(
|
const handleReactionToggle = useCallback(
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
|
|||||||
import { useUserPresence } from '../../../hooks/useUserPresence';
|
import { useUserPresence } from '../../../hooks/useUserPresence';
|
||||||
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
||||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||||
import colorMXID from '../../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
||||||
import {
|
import {
|
||||||
extractMetadataFromImage,
|
extractMetadataFromImage,
|
||||||
embedMetadataInImage,
|
embedMetadataInImage,
|
||||||
@@ -574,7 +574,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
: profileStyle.avatarBorderColor;
|
: profileStyle.avatarBorderColor;
|
||||||
|
|
||||||
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
|
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
|
||||||
const previewProfileColor = userColor || colorMXID(userId);
|
const previewProfileColor = userColor || getColorMXIDValue(userId);
|
||||||
|
|
||||||
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
||||||
|
|
||||||
|
|||||||
@@ -454,7 +454,11 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
||||||
const [micLevel, setMicLevel] = useState(0);
|
const [micLevel, setMicLevel] = useState(0);
|
||||||
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
||||||
|
const [micMonitoring, setMicMonitoring] = useState(false);
|
||||||
const micAnimFrameRef = useRef<number | null>(null);
|
const micAnimFrameRef = useRef<number | null>(null);
|
||||||
|
const micMonitorContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const micMonitorStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
const micMonitorGainRef = useRef<GainNode | null>(null);
|
||||||
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -627,6 +631,101 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
}
|
}
|
||||||
}, [speakerTestState]);
|
}, [speakerTestState]);
|
||||||
|
|
||||||
|
const handleMicMonitoringToggle = useCallback(async () => {
|
||||||
|
if (micMonitoring) {
|
||||||
|
// Stop monitoring
|
||||||
|
if (micAnimFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(micAnimFrameRef.current);
|
||||||
|
micAnimFrameRef.current = null;
|
||||||
|
}
|
||||||
|
if (micMonitorStreamRef.current) {
|
||||||
|
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||||
|
micMonitorStreamRef.current = null;
|
||||||
|
}
|
||||||
|
if (micMonitorContextRef.current) {
|
||||||
|
micMonitorContextRef.current.close();
|
||||||
|
micMonitorContextRef.current = null;
|
||||||
|
}
|
||||||
|
micMonitorGainRef.current = null;
|
||||||
|
setMicLevel(0);
|
||||||
|
setMicMonitoring(false);
|
||||||
|
} else {
|
||||||
|
// Start monitoring
|
||||||
|
try {
|
||||||
|
const constraints: MediaStreamConstraints = {
|
||||||
|
audio: settings.microphoneId
|
||||||
|
? {
|
||||||
|
deviceId: { exact: settings.microphoneId },
|
||||||
|
noiseSuppression: settings.noiseSuppression,
|
||||||
|
echoCancellation: settings.echoCancellation,
|
||||||
|
autoGainControl: settings.autoGainControl,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
noiseSuppression: settings.noiseSuppression,
|
||||||
|
echoCancellation: settings.echoCancellation,
|
||||||
|
autoGainControl: settings.autoGainControl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
const microphone = audioContext.createMediaStreamSource(stream);
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
// Connect microphone to analyser for level monitoring
|
||||||
|
microphone.connect(analyser);
|
||||||
|
|
||||||
|
// Connect microphone to speakers for playback
|
||||||
|
microphone.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
gainNode.gain.value = 1.0; // Full volume playback
|
||||||
|
|
||||||
|
micMonitorContextRef.current = audioContext;
|
||||||
|
micMonitorStreamRef.current = stream;
|
||||||
|
micMonitorGainRef.current = gainNode;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
||||||
|
setMicLevel(Math.min(100, Math.round((avg / 255) * 100 * 3)));
|
||||||
|
micAnimFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
micAnimFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
setMicMonitoring(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Microphone monitoring failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
||||||
|
|
||||||
|
// Cleanup monitoring on unmount or when settings change
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (micAnimFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(micAnimFrameRef.current);
|
||||||
|
}
|
||||||
|
if (micMonitorStreamRef.current) {
|
||||||
|
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
if (micMonitorContextRef.current) {
|
||||||
|
micMonitorContextRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restart monitoring when mic settings change
|
||||||
|
useEffect(() => {
|
||||||
|
if (micMonitoring) {
|
||||||
|
handleMicMonitoringToggle(); // Stop
|
||||||
|
setTimeout(() => handleMicMonitoringToggle(), 100); // Restart
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
@@ -686,21 +785,35 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
onSelect={handleMicrophoneSelect}
|
onSelect={handleMicrophoneSelect}
|
||||||
/>
|
/>
|
||||||
<Box direction="Column" gap="100" alignItems="End">
|
<Box direction="Column" gap="100" alignItems="End">
|
||||||
|
<Box gap="200" alignItems="Center">
|
||||||
<Button
|
<Button
|
||||||
size="300"
|
size="300"
|
||||||
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
|
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={handleTestMicrophone}
|
onClick={handleTestMicrophone}
|
||||||
disabled={micTestState === 'testing'}
|
disabled={micTestState === 'testing' || micMonitoring}
|
||||||
>
|
>
|
||||||
<Text size="T300">
|
<Text size="T300">
|
||||||
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
|
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
{micTestState === 'testing' && (
|
<Button
|
||||||
<Box style={{ width: toRem(80), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
|
size="300"
|
||||||
<Box style={{ width: `${micLevel}%`, height: '100%', background: color.Primary.Main, transition: 'width 0.05s ease' }} />
|
variant={micMonitoring ? 'Primary' : 'Secondary'}
|
||||||
|
fill={micMonitoring ? 'Solid' : 'Soft'}
|
||||||
|
radii="300"
|
||||||
|
onClick={handleMicMonitoringToggle}
|
||||||
|
disabled={micTestState === 'testing'}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{micMonitoring ? 'Stop' : 'Monitor'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{(micTestState === 'testing' || micMonitoring) && (
|
||||||
|
<Box style={{ width: toRem(160), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
|
||||||
|
<Box style={{ width: `${micLevel}%`, height: '100%', background: micMonitoring ? color.Success.Main : color.Primary.Main, transition: 'width 0.05s ease' }} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { DateFormat, EmojiStyle, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
import { DateFormat, EmojiStyle, MessageLayout, MessageSpacing, ScrollToLatestBehavior, settingsAtom } from '../../../state/settings';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { KeySymbol } from '../../../utils/key-symbol';
|
import { KeySymbol } from '../../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../../utils/user-agent';
|
import { isMacOS } from '../../../utils/user-agent';
|
||||||
@@ -984,6 +984,83 @@ function SelectMessageSpacing() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SelectScrollToLatestBehavior() {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const [scrollToLatestBehavior, setScrollToLatestBehavior] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'scrollToLatestBehavior'
|
||||||
|
);
|
||||||
|
|
||||||
|
const behaviors = [
|
||||||
|
{ value: ScrollToLatestBehavior.Always, label: 'Always' },
|
||||||
|
{ value: ScrollToLatestBehavior.OnNewMessage, label: 'On New Message' },
|
||||||
|
{ value: ScrollToLatestBehavior.Never, label: 'Never' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (behavior: ScrollToLatestBehavior) => {
|
||||||
|
setScrollToLatestBehavior(behavior);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentLabel = behaviors.find((b) => b.value === scrollToLatestBehavior)?.label ?? 'Always';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleMenu}
|
||||||
|
>
|
||||||
|
<Text size="T300">{currentLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{behaviors.map((item) => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.value}
|
||||||
|
size="300"
|
||||||
|
variant={scrollToLatestBehavior === item.value ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleSelect(item.value)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{item.label}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Messages() {
|
function Messages() {
|
||||||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
@@ -1011,6 +1088,12 @@ function Messages() {
|
|||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Scroll to Latest on Room Reselect"
|
||||||
|
after={<SelectScrollToLatestBehavior />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Legacy Username Color"
|
title="Legacy Username Color"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
||||||
import { IPusherRequest } from 'matrix-js-sdk';
|
import { IPusherRequest } from 'matrix-js-sdk';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
@@ -10,6 +10,13 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
|
|||||||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
|
||||||
|
import {
|
||||||
|
isBackgroundSyncSupported,
|
||||||
|
getBackgroundSyncStatus,
|
||||||
|
requestResetPushRegistration,
|
||||||
|
triggerBackgroundSyncPing,
|
||||||
|
} from '../../../utils/backgroundSync';
|
||||||
|
|
||||||
function EmailNotification() {
|
function EmailNotification() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -86,14 +93,15 @@ function EmailNotification() {
|
|||||||
|
|
||||||
export function SystemNotification() {
|
export function SystemNotification() {
|
||||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||||
|
const capacitorNative = isCapacitorNative();
|
||||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
'isNotificationSounds'
|
'isNotificationSounds'
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestNotificationPermission = () => {
|
const requestNotificationPermission = async () => {
|
||||||
window.Notification.requestPermission();
|
await requestSystemNotificationPermission();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -125,7 +133,7 @@ export function SystemNotification() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Switch
|
<Switch
|
||||||
disabled={notifPermission !== 'granted'}
|
disabled={!capacitorNative && notifPermission !== 'granted'}
|
||||||
value={showNotifications}
|
value={showNotifications}
|
||||||
onChange={setShowNotifications}
|
onChange={setShowNotifications}
|
||||||
/>
|
/>
|
||||||
@@ -145,6 +153,7 @@ export function SystemNotification() {
|
|||||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -156,3 +165,126 @@ export function SystemNotification() {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PushStatus = {
|
||||||
|
registered: boolean;
|
||||||
|
distributor: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||||
|
function AndroidPushNotifications() {
|
||||||
|
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const s = await getBackgroundSyncStatus();
|
||||||
|
setStatus(
|
||||||
|
s
|
||||||
|
? {
|
||||||
|
registered: s.registered,
|
||||||
|
distributor: s.distributor || '',
|
||||||
|
endpoint: s.endpoint || '',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const [resetState, reset] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
await requestResetPushRegistration();
|
||||||
|
await refresh();
|
||||||
|
}, [refresh])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [pingState, ping] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
await triggerBackgroundSyncPing('manual-test');
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const isBusy =
|
||||||
|
loading ||
|
||||||
|
resetState.status === AsyncStatus.Loading ||
|
||||||
|
pingState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Background Notifications"
|
||||||
|
description={
|
||||||
|
loading ? (
|
||||||
|
'Loading status…'
|
||||||
|
) : status === undefined ? (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Failed to read push status.
|
||||||
|
</Text>
|
||||||
|
) : status.registered ? (
|
||||||
|
<>
|
||||||
|
<Text as="span" size="T200">
|
||||||
|
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||||
|
Not registered. Tap "Reset" to choose a distributor app.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Change Distributor"
|
||||||
|
description="Re-open the UnifiedPush distributor selection dialog."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void reset()}
|
||||||
|
>
|
||||||
|
{resetState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
) : (
|
||||||
|
<Text size="B300">Reset</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Test Notification"
|
||||||
|
description="Trigger a test push ping to verify the pipeline works."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void ping()}
|
||||||
|
>
|
||||||
|
{pingState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
) : (
|
||||||
|
<Text size="B300">Send Test</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ import {
|
|||||||
} from '@paarrot/plugin-manager';
|
} from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
const PLUGIN_INDEX_URL =
|
const PLUGIN_INDEX_URL =
|
||||||
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${ (Math.random()*256).toString()}`;
|
import.meta.env.VITE_PLUGIN_INDEX_URL ||
|
||||||
|
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`;
|
||||||
const PLUGIN_BASE_URL =
|
const PLUGIN_BASE_URL =
|
||||||
|
import.meta.env.VITE_PLUGIN_BASE_URL ||
|
||||||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
|
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
|
||||||
|
|
||||||
function injectThemeStyle(styleId: string, css: string): void {
|
function injectThemeStyle(styleId: string, css: string): void {
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const useAuthenticatedMediaUrl = (
|
|||||||
// Check if this is an authenticated media URL
|
// Check if this is an authenticated media URL
|
||||||
const isAuthenticatedMediaUrl =
|
const isAuthenticatedMediaUrl =
|
||||||
src.includes('/_matrix/client/v1/media/download') ||
|
src.includes('/_matrix/client/v1/media/download') ||
|
||||||
src.includes('/_matrix/client/v1/media/thumbnail');
|
src.includes('/_matrix/client/v1/media/thumbnail') ||
|
||||||
|
(src.includes('/_matrix/media/') &&
|
||||||
|
(src.includes('/download/') || src.includes('/thumbnail/')));
|
||||||
|
|
||||||
if (!isAuthenticatedMediaUrl) {
|
if (!isAuthenticatedMediaUrl) {
|
||||||
setBlobUrl(src);
|
setBlobUrl(src);
|
||||||
@@ -99,7 +101,9 @@ export const useAuthenticatedMediaFetch = () => {
|
|||||||
async (src: string): Promise<string> => {
|
async (src: string): Promise<string> => {
|
||||||
const isAuthenticatedMediaUrl =
|
const isAuthenticatedMediaUrl =
|
||||||
src.includes('/_matrix/client/v1/media/download') ||
|
src.includes('/_matrix/client/v1/media/download') ||
|
||||||
src.includes('/_matrix/client/v1/media/thumbnail');
|
src.includes('/_matrix/client/v1/media/thumbnail') ||
|
||||||
|
(src.includes('/_matrix/media/') &&
|
||||||
|
(src.includes('/download/') || src.includes('/thumbnail/')));
|
||||||
|
|
||||||
if (!isAuthenticatedMediaUrl) {
|
if (!isAuthenticatedMediaUrl) {
|
||||||
return src;
|
return src;
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
110
src/app/hooks/useRoomListReorder.ts
Normal file
110
src/app/hooks/useRoomListReorder.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { MatrixClient, MatrixEvent, Room, RoomEvent, IRoomTimelineData } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that tracks timeline events across all rooms to trigger re-sorting of room lists.
|
||||||
|
* Returns a counter that increments whenever a message is sent or received in any room.
|
||||||
|
* This can be used as a dependency in useMemo to trigger re-sorting of room lists.
|
||||||
|
*
|
||||||
|
* @param mx - The Matrix client instance
|
||||||
|
* @param roomIds - Optional array of room IDs to track. If not provided, tracks all rooms.
|
||||||
|
* @returns A number that increments on each timeline event
|
||||||
|
*/
|
||||||
|
export const useRoomListReorder = (mx: MatrixClient, roomIds?: string[]): number => {
|
||||||
|
const [updateCounter, setUpdateCounter] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimelineEvent = (
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
room: Room | undefined,
|
||||||
|
toStartOfTimeline: boolean | undefined,
|
||||||
|
removed: boolean,
|
||||||
|
data: IRoomTimelineData
|
||||||
|
) => {
|
||||||
|
if (!room || !data.liveEvent) return;
|
||||||
|
if (toStartOfTimeline) return;
|
||||||
|
|
||||||
|
if (roomIds && !roomIds.includes(room.roomId)) return;
|
||||||
|
|
||||||
|
setUpdateCounter((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
};
|
||||||
|
}, [mx, roomIds]);
|
||||||
|
|
||||||
|
return updateCounter;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that implements FLIP (First, Last, Invert, Play) animation for list reordering.
|
||||||
|
* Tracks element positions before and after reorder, then animates the difference.
|
||||||
|
*
|
||||||
|
* @param items - Array of item IDs in their current order
|
||||||
|
* @returns Ref callback to attach to the list container
|
||||||
|
*/
|
||||||
|
export const useListReorderAnimation = <T extends string>(
|
||||||
|
items: T[]
|
||||||
|
): React.RefCallback<HTMLElement> => {
|
||||||
|
const positionsRef = useRef<Map<T, number>>(new Map());
|
||||||
|
const containerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const currentPositions = new Map<T, number>();
|
||||||
|
const elements = new Map<T, HTMLElement>();
|
||||||
|
|
||||||
|
items.forEach((itemId) => {
|
||||||
|
const element = containerRef.current!.querySelector(`[data-room-id="${itemId}"]`) as HTMLElement;
|
||||||
|
if (element) {
|
||||||
|
currentPositions.set(itemId, element.offsetTop);
|
||||||
|
elements.set(itemId, element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (positionsRef.current.size === 0) {
|
||||||
|
positionsRef.current = currentPositions;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animations: Array<{ element: HTMLElement; deltaY: number }> = [];
|
||||||
|
|
||||||
|
items.forEach((itemId) => {
|
||||||
|
const element = elements.get(itemId);
|
||||||
|
const oldPos = positionsRef.current.get(itemId);
|
||||||
|
const newPos = currentPositions.get(itemId);
|
||||||
|
|
||||||
|
if (element && oldPos !== undefined && newPos !== undefined) {
|
||||||
|
const deltaY = oldPos - newPos;
|
||||||
|
|
||||||
|
if (Math.abs(deltaY) > 1) {
|
||||||
|
animations.push({ element, deltaY });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (animations.length > 0) {
|
||||||
|
animations.forEach(({ element, deltaY }) => {
|
||||||
|
element.style.transform = `translateY(${deltaY}px)`;
|
||||||
|
element.style.transition = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
animations.forEach(({ element }) => {
|
||||||
|
element.style.transition = 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
|
element.style.transform = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
positionsRef.current = currentPositions;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
return (container: HTMLElement | null) => {
|
||||||
|
containerRef.current = container;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -73,6 +73,7 @@ export function useUserBanner(): [
|
|||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
const profile = await mx.getProfileInfo(userId);
|
||||||
const avatarUrl = profile.avatar_url;
|
const avatarUrl = profile.avatar_url;
|
||||||
|
console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl);
|
||||||
|
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) {
|
||||||
setBanner(undefined);
|
setBanner(undefined);
|
||||||
@@ -90,6 +91,7 @@ export function useUserBanner(): [
|
|||||||
// Always use current session's token to avoid stale tokens during account switches
|
// Always use current session's token to avoid stale tokens during account switches
|
||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||||
|
console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner);
|
||||||
setBanner(metadata.banner);
|
setBanner(metadata.banner);
|
||||||
} catch {
|
} catch {
|
||||||
setBanner(undefined);
|
setBanner(undefined);
|
||||||
@@ -104,7 +106,8 @@ export function useUserBanner(): [
|
|||||||
if (!userId) return undefined;
|
if (!userId) return undefined;
|
||||||
|
|
||||||
const user = mx.getUser(userId);
|
const user = mx.getUser(userId);
|
||||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
|
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => {
|
||||||
|
console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl);
|
||||||
loadBanner();
|
loadBanner();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,12 +146,16 @@ export function useUserBanner(): [
|
|||||||
|
|
||||||
// Get existing metadata to preserve
|
// Get existing metadata to preserve
|
||||||
const existingMetadata = extractMetadataFromImage(avatarData);
|
const existingMetadata = extractMetadataFromImage(avatarData);
|
||||||
|
console.log('[updateBanner] Existing metadata:', existingMetadata);
|
||||||
|
|
||||||
// Modify image metadata with new banner, preserving color
|
// Modify image metadata with new banner, preserving color
|
||||||
const newMetadata: ImageMetadata = {
|
const newMetadata: ImageMetadata = {
|
||||||
color: existingMetadata.color,
|
color: existingMetadata.color,
|
||||||
banner: newBanner,
|
banner: newBanner,
|
||||||
|
avatarBorderColor: existingMetadata.avatarBorderColor,
|
||||||
|
gradient: existingMetadata.gradient,
|
||||||
};
|
};
|
||||||
|
console.log('[updateBanner] New metadata to embed:', newMetadata);
|
||||||
|
|
||||||
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
||||||
|
|
||||||
@@ -158,10 +165,13 @@ export function useUserBanner(): [
|
|||||||
|
|
||||||
// Verify the banner was embedded correctly before uploading
|
// Verify the banner was embedded correctly before uploading
|
||||||
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
||||||
|
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
|
||||||
|
|
||||||
if (newBanner && verifyMetadata.banner !== newBanner) {
|
if (newBanner && verifyMetadata.banner !== newBanner) {
|
||||||
|
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
|
||||||
throw new Error('Banner verification failed');
|
throw new Error('Banner verification failed');
|
||||||
}
|
}
|
||||||
|
console.log('[updateBanner] Banner verification passed');
|
||||||
|
|
||||||
// Upload modified avatar with correct MIME type
|
// Upload modified avatar with correct MIME type
|
||||||
const mimeType = getMimeType(format);
|
const mimeType = getMimeType(format);
|
||||||
@@ -172,21 +182,26 @@ export function useUserBanner(): [
|
|||||||
name: `avatar.${extension}`,
|
name: `avatar.${extension}`,
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
});
|
});
|
||||||
|
console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri);
|
||||||
|
|
||||||
// Update profile with new avatar
|
// Update profile with new avatar
|
||||||
try {
|
try {
|
||||||
|
console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri);
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
mx.setAvatarUrl(uploadResponse.content_uri),
|
mx.setAvatarUrl(uploadResponse.content_uri),
|
||||||
new Promise((_, reject) => {
|
new Promise((_, reject) => {
|
||||||
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
|
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
console.log('[updateBanner] setAvatarUrl completed');
|
||||||
|
|
||||||
// Manually sync user object to ensure event listeners are triggered
|
// Manually sync user object to ensure event listeners are triggered
|
||||||
const user = mx.getUser(userId);
|
const user = mx.getUser(userId);
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
||||||
|
console.log('[updateBanner] Manually syncing user avatar URL');
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
user.setAvatarUrl(uploadResponse.content_uri);
|
||||||
}
|
}
|
||||||
|
console.log('[updateBanner] Banner update complete');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
@@ -217,11 +232,14 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
|
|||||||
let blobUrl: string | undefined;
|
let blobUrl: string | undefined;
|
||||||
|
|
||||||
if (!avatarMxc) {
|
if (!avatarMxc) {
|
||||||
|
console.log('[useOtherUserBanner] No avatarMxc provided for', userId);
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadBanner = async () => {
|
const loadBanner = async () => {
|
||||||
|
console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc);
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
const cacheKey = `${userId}:${avatarMxc}`;
|
||||||
const cached = userBannerCache.get(cacheKey);
|
const cached = userBannerCache.get(cacheKey);
|
||||||
@@ -230,11 +248,15 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
|
|||||||
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
// Use cached banner MXC
|
// Use cached banner MXC
|
||||||
|
console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc);
|
||||||
bannerMxc = cached.bannerMxc;
|
bannerMxc = cached.bannerMxc;
|
||||||
} else {
|
} else {
|
||||||
// Fetch banner MXC from avatar metadata
|
// Fetch banner MXC from avatar metadata
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||||
|
console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl);
|
||||||
|
|
||||||
if (!httpUrl) {
|
if (!httpUrl) {
|
||||||
|
console.log('[useOtherUserBanner] Failed to get HTTP URL');
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -243,6 +265,8 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
|
|||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||||
|
|
||||||
|
console.log('[useOtherUserBanner] Extracted metadata:', metadata);
|
||||||
|
|
||||||
bannerMxc = metadata.banner;
|
bannerMxc = metadata.banner;
|
||||||
|
|
||||||
// Cache the banner MXC (not the blob URL)
|
// Cache the banner MXC (not the blob URL)
|
||||||
@@ -250,17 +274,23 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!bannerMxc) {
|
if (!bannerMxc) {
|
||||||
|
console.log('[useOtherUserBanner] No banner MXC found');
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc);
|
||||||
|
|
||||||
// Fetch the banner image data with authentication
|
// Fetch the banner image data with authentication
|
||||||
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
||||||
if (!bannerHttpUrl) {
|
if (!bannerHttpUrl) {
|
||||||
|
console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL');
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl);
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
// Always use current session's token to avoid stale tokens during account switches
|
||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const headers: HeadersInit = {};
|
const headers: HeadersInit = {};
|
||||||
|
|||||||
@@ -260,10 +260,19 @@ const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|||||||
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
|
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [color, setColor] = useState<string | undefined>();
|
|
||||||
|
// Check cache synchronously BEFORE initializing state to avoid flash
|
||||||
|
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
|
||||||
|
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
|
||||||
|
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS
|
||||||
|
? cachedEntry.color
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const [color, setColor] = useState<string | undefined>(cachedColor);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!avatarMxc) {
|
if (!avatarMxc) {
|
||||||
|
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
|
||||||
setColor(undefined);
|
setColor(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -272,13 +281,16 @@ export function useOtherUserColor(userId: string, avatarMxc: string | undefined)
|
|||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
const cacheKey = `${userId}:${avatarMxc}`;
|
||||||
const cached = userColorCache.get(cacheKey);
|
const cached = userColorCache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
|
console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color);
|
||||||
setColor(cached.color);
|
setColor(cached.color);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadColor = async () => {
|
const loadColor = async () => {
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||||
|
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
|
||||||
if (!httpUrl) {
|
if (!httpUrl) {
|
||||||
|
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc);
|
||||||
setColor(undefined);
|
setColor(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -287,6 +299,8 @@ export function useOtherUserColor(userId: string, avatarMxc: string | undefined)
|
|||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
||||||
|
|
||||||
|
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor);
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
||||||
|
|
||||||
|
|||||||
@@ -212,22 +212,29 @@ export function useOtherUserProfileStyle(userId: string, avatarMxc: string | und
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!avatarMxc) {
|
if (!avatarMxc) {
|
||||||
|
console.log('[useOtherUserProfileStyle] No avatarMxc provided for', userId);
|
||||||
setStyle({});
|
setStyle({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadStyle = async () => {
|
const loadStyle = async () => {
|
||||||
|
console.log('[useOtherUserProfileStyle] Loading style for', userId, 'avatarMxc:', avatarMxc);
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
const cacheKey = `${userId}:${avatarMxc}`;
|
||||||
const cached = userStyleCache.get(cacheKey);
|
const cached = userStyleCache.get(cacheKey);
|
||||||
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
|
console.log('[useOtherUserProfileStyle] Using cached style:', cached.style);
|
||||||
setStyle(cached.style);
|
setStyle(cached.style);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||||
|
console.log('[useOtherUserProfileStyle] Fetching metadata from:', httpUrl);
|
||||||
|
|
||||||
if (!httpUrl) {
|
if (!httpUrl) {
|
||||||
|
console.log('[useOtherUserProfileStyle] Failed to get HTTP URL');
|
||||||
setStyle({});
|
setStyle({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,11 +243,15 @@ export function useOtherUserProfileStyle(userId: string, avatarMxc: string | und
|
|||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||||
|
|
||||||
|
console.log('[useOtherUserProfileStyle] Extracted metadata:', metadata);
|
||||||
|
|
||||||
const styleData: ProfileStyleData = {
|
const styleData: ProfileStyleData = {
|
||||||
avatarBorderColor: metadata.avatarBorderColor,
|
avatarBorderColor: metadata.avatarBorderColor,
|
||||||
gradient: metadata.gradient,
|
gradient: metadata.gradient,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('[useOtherUserProfileStyle] Final styleData:', styleData);
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
|
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
|
||||||
setStyle(styleData);
|
setStyle(styleData);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getScrollInfo,
|
getScrollInfo,
|
||||||
isInScrollView,
|
isInScrollView,
|
||||||
isIntersectingScrollView,
|
isIntersectingScrollView,
|
||||||
|
scrollToPosition,
|
||||||
} from '../utils/dom';
|
} from '../utils/dom';
|
||||||
|
|
||||||
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
|
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
|
||||||
@@ -218,7 +219,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
|
scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollElement.scrollTo({
|
scrollToPosition(scrollElement, {
|
||||||
top: scrollTo - (opts?.offset ?? 0),
|
top: scrollTo - (opts?.offset ?? 0),
|
||||||
behavior: opts?.behavior,
|
behavior: opts?.behavior,
|
||||||
});
|
});
|
||||||
@@ -253,10 +254,12 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
|
|
||||||
if (!itemElement) {
|
if (!itemElement) {
|
||||||
const scrollElement = getScrollElement();
|
const scrollElement = getScrollElement();
|
||||||
scrollElement?.scrollTo({
|
if (scrollElement) {
|
||||||
|
scrollToPosition(scrollElement, {
|
||||||
top: opts?.offset ?? 0,
|
top: opts?.offset ?? 0,
|
||||||
behavior: opts?.behavior,
|
behavior: opts?.behavior,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return scrollToElement(itemElement, opts);
|
return scrollToElement(itemElement, opts);
|
||||||
@@ -364,7 +367,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
const offsetAddition = offsetTop - oldOffsetTop;
|
const offsetAddition = offsetTop - oldOffsetTop;
|
||||||
const restoreTop = oldScrollTop + offsetAddition;
|
const restoreTop = oldScrollTop + offsetAddition;
|
||||||
|
|
||||||
scrollEl.scrollTo({
|
scrollToPosition(scrollEl, {
|
||||||
top: restoreTop,
|
top: restoreTop,
|
||||||
behavior: 'instant',
|
behavior: 'instant',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
>
|
>
|
||||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||||
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
||||||
|
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
||||||
|
<Route path={_INVITES_PATH} element={<Invites />} />
|
||||||
<Route
|
<Route
|
||||||
path={_ROOM_PATH}
|
path={_ROOM_PATH}
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
|
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
@@ -28,8 +29,35 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { isTauri, isElectron, sendNotification, setupNotificationTapListener } from '../../utils/tauri';
|
import { plainToEditorInput, toPlainText } from '../../components/editor';
|
||||||
|
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
||||||
|
import {
|
||||||
|
isTauri,
|
||||||
|
isElectron,
|
||||||
|
isCapacitorNative,
|
||||||
|
sendNotification,
|
||||||
|
setupNotificationTapListener,
|
||||||
|
} from '../../utils/tauri';
|
||||||
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||||
|
import {
|
||||||
|
startBackgroundSync,
|
||||||
|
stopBackgroundSync,
|
||||||
|
setAppForegroundState,
|
||||||
|
} from '../../utils/backgroundSync';
|
||||||
|
import {
|
||||||
|
TUploadItem,
|
||||||
|
roomIdToMsgDraftAtomFamily,
|
||||||
|
roomIdToUploadItemsAtomFamily,
|
||||||
|
} from '../../state/room/roomInputDrafts';
|
||||||
|
import { encryptFile } from '../../utils/matrix';
|
||||||
|
import {
|
||||||
|
AndroidSharePayload,
|
||||||
|
clearPendingAndroidShare,
|
||||||
|
getPendingAndroidShare,
|
||||||
|
isAndroidShareSupported,
|
||||||
|
listenForAndroidShares,
|
||||||
|
materializeSharedFile,
|
||||||
|
} from '../../utils/androidShare';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected emoji style font to the document.
|
* Applies the selected emoji style font to the document.
|
||||||
@@ -118,7 +146,7 @@ function InviteNotifications() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTauri() && !isElectron()) {
|
if ((isTauri() && !isElectron()) || isCapacitorNative()) {
|
||||||
sendNotification({
|
sendNotification({
|
||||||
title: 'Invitation',
|
title: 'Invitation',
|
||||||
body,
|
body,
|
||||||
@@ -257,7 +285,7 @@ function MessageNotifications() {
|
|||||||
console.warn('[Notifications] flashFrame not available, isElectron:', isElectron());
|
console.warn('[Notifications] flashFrame not available, isElectron:', isElectron());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTauri() && !isElectron()) {
|
if ((isTauri() && !isElectron()) || isCapacitorNative()) {
|
||||||
const roomPath = isDm
|
const roomPath = isDm
|
||||||
? getDirectRoomPath(roomId, eventId)
|
? getDirectRoomPath(roomId, eventId)
|
||||||
: getHomeRoomPath(roomId, eventId);
|
: getHomeRoomPath(roomId, eventId);
|
||||||
@@ -350,7 +378,10 @@ function MessageNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showNotifications && ((isTauri() && !isElectron()) || notificationPermission('granted'))) {
|
if (
|
||||||
|
showNotifications &&
|
||||||
|
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||||
|
) {
|
||||||
const avatarMxc =
|
const avatarMxc =
|
||||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
||||||
const content = mEvent.getContent();
|
const content = mEvent.getContent();
|
||||||
@@ -404,6 +435,36 @@ function MessageNotifications() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures native Android push-ping background sync on login, keeps it
|
||||||
|
* informed of foreground state so it doesn't double-fire notifications,
|
||||||
|
* and clears it cleanly on unmount (logout).
|
||||||
|
* Only active on Android Capacitor builds.
|
||||||
|
*/
|
||||||
|
function BackgroundSyncSetup() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
// Try to make a simple fetch to verify component is rendering
|
||||||
|
try {
|
||||||
|
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
|
||||||
|
startBackgroundSync(mx);
|
||||||
|
|
||||||
|
const onVisibility = () => setAppForegroundState(!document.hidden);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
setAppForegroundState(!document.hidden);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
stopBackgroundSync();
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the Paarrot API for Electron integration
|
* Initializes the Paarrot API for Electron integration
|
||||||
* Registers the navigate function and sets up IPC handlers
|
* Registers the navigate function and sets up IPC handlers
|
||||||
@@ -454,6 +515,167 @@ function TaskbarFlashStopper() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AndroidShareIntentHandler() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
|
||||||
|
const applyPendingShare = useCallback(
|
||||||
|
async (share: AndroidSharePayload, roomId: string) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return false;
|
||||||
|
|
||||||
|
// Send text message if present
|
||||||
|
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
||||||
|
(value): value is string => !!value
|
||||||
|
);
|
||||||
|
if (nextParts.length > 0) {
|
||||||
|
const textContent = {
|
||||||
|
msgtype: 'm.text' as const,
|
||||||
|
body: nextParts.join('\n'),
|
||||||
|
};
|
||||||
|
await mx.sendMessage(roomId, textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload and send files if present
|
||||||
|
if (share.files.length > 0) {
|
||||||
|
for (const sharedFile of share.files) {
|
||||||
|
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
||||||
|
|
||||||
|
let fileToUpload = originalFile;
|
||||||
|
let encInfo: any = undefined;
|
||||||
|
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
const encrypted = await encryptFile(originalFile);
|
||||||
|
fileToUpload = encrypted.file;
|
||||||
|
encInfo = encrypted.encInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
const uploadResult = await mx.uploadContent(fileToUpload);
|
||||||
|
const mxc = uploadResult?.content_uri;
|
||||||
|
if (!mxc) continue;
|
||||||
|
|
||||||
|
// Determine message type and send
|
||||||
|
const fileType = originalFile.type;
|
||||||
|
let msgtype = 'm.file' as const;
|
||||||
|
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
|
||||||
|
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
|
||||||
|
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
|
||||||
|
|
||||||
|
const fileContent: any = {
|
||||||
|
msgtype,
|
||||||
|
body: originalFile.name,
|
||||||
|
filename: originalFile.name,
|
||||||
|
info: {
|
||||||
|
mimetype: originalFile.type,
|
||||||
|
size: originalFile.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (encInfo) {
|
||||||
|
fileContent.file = {
|
||||||
|
...encInfo,
|
||||||
|
url: mxc,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fileContent.url = mxc;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mx.sendMessage(roomId, fileContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearPendingAndroidShare();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePickRoom = useCallback(
|
||||||
|
(roomId: string) => {
|
||||||
|
if (!pendingShare) return;
|
||||||
|
|
||||||
|
applyPendingShare(pendingShare, roomId)
|
||||||
|
.then((applied) => {
|
||||||
|
if (applied) {
|
||||||
|
setPendingShare(null);
|
||||||
|
|
||||||
|
// Navigate to the selected room
|
||||||
|
const isDirect = mDirects.has(roomId);
|
||||||
|
if (isDirect) {
|
||||||
|
navigate(getDirectRoomPath(roomId));
|
||||||
|
} else {
|
||||||
|
const parents = roomToParents.get(roomId);
|
||||||
|
const parent = parents && parents.length > 0 ? parents[0] : undefined;
|
||||||
|
if (parent) {
|
||||||
|
navigate(getSpaceRoomPath(parent, roomId));
|
||||||
|
} else {
|
||||||
|
navigate(getHomeRoomPath(roomId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
||||||
|
setPendingShare(null);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
clearPendingAndroidShare().catch(() => {});
|
||||||
|
setPendingShare(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAndroidShareSupported()) return;
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
let listenerHandle: { remove: () => Promise<void> } | undefined;
|
||||||
|
|
||||||
|
getPendingAndroidShare()
|
||||||
|
.then((share) => {
|
||||||
|
if (mounted && share) {
|
||||||
|
setPendingShare(share);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to get pending share:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
listenForAndroidShares((share) => {
|
||||||
|
if (mounted) {
|
||||||
|
setPendingShare(share);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((handle) => {
|
||||||
|
listenerHandle = handle;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to listen for shares:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
void listenerHandle?.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!pendingShare) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShareRoomPicker
|
||||||
|
share={pendingShare}
|
||||||
|
onPick={handlePickRoom}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ClientNonUIFeaturesProps = {
|
type ClientNonUIFeaturesProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -466,8 +688,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<BackgroundSyncSetup />
|
||||||
<PaarrotAPIInitializer />
|
<PaarrotAPIInitializer />
|
||||||
<TaskbarFlashStopper />
|
<TaskbarFlashStopper />
|
||||||
|
<AndroidShareIntentHandler />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
DirectTab,
|
DirectTab,
|
||||||
HomeTab,
|
HomeTab,
|
||||||
SpaceTabs,
|
SpaceTabs,
|
||||||
InboxTab,
|
|
||||||
ExploreTab,
|
ExploreTab,
|
||||||
SettingsTab,
|
SettingsTab,
|
||||||
UnverifiedTab,
|
UnverifiedTab,
|
||||||
@@ -52,7 +51,6 @@ export function SidebarNav() {
|
|||||||
<PluginSidebarSlot location="sidebar-actions" />
|
<PluginSidebarSlot location="sidebar-actions" />
|
||||||
<SearchTab />
|
<SearchTab />
|
||||||
<UnverifiedTab />
|
<UnverifiedTab />
|
||||||
<InboxTab />
|
|
||||||
<SettingsTab />
|
<SettingsTab />
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ import {
|
|||||||
NavEmptyLayout,
|
NavEmptyLayout,
|
||||||
NavItem,
|
NavItem,
|
||||||
NavItemContent,
|
NavItemContent,
|
||||||
|
NavLink,
|
||||||
} from '../../../components/nav';
|
} from '../../../components/nav';
|
||||||
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
|
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath } from '../../pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { VirtualTile } from '../../../components/virtualizer';
|
import { VirtualTile } from '../../../components/virtualizer';
|
||||||
@@ -47,12 +48,15 @@ import { stopPropagation } from '../../../utils/keyboard';
|
|||||||
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
|
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
|
||||||
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
|
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { useRoomListReorder, useListReorderAnimation } from '../../../hooks/useRoomListReorder';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
||||||
|
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||||
|
import { UnreadBadge } from '../../../components/unread-badge';
|
||||||
|
|
||||||
type DirectMenuProps = {
|
type DirectMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -170,7 +174,64 @@ function DirectEmpty() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation item for room invites
|
||||||
|
*/
|
||||||
|
function InvitesNavItem() {
|
||||||
|
const allInvites = useAtomValue(allInvitesAtom);
|
||||||
|
const inviteCount = allInvites.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavItem
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
highlight={inviteCount > 0}
|
||||||
|
>
|
||||||
|
<NavLink to={getDirectInvitesPath()}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<Icon src={Icons.Mail} size="100" />
|
||||||
|
</Avatar>
|
||||||
|
<Box as="span" grow="Yes">
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
Invites
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavLink>
|
||||||
|
</NavItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation item for notifications
|
||||||
|
*/
|
||||||
|
function NotificationsNavItem() {
|
||||||
|
return (
|
||||||
|
<NavItem variant="Background" radii="400">
|
||||||
|
<NavLink to={getDirectNotificationsPath()}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<Icon src={Icons.MessageUnread} size="100" />
|
||||||
|
</Avatar>
|
||||||
|
<Box as="span" grow="Yes">
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
Notifications
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavLink>
|
||||||
|
</NavItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
|
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
|
||||||
|
|
||||||
export function Direct() {
|
export function Direct() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('direct');
|
useNavToActivePathMapper('direct');
|
||||||
@@ -186,13 +247,17 @@ export function Direct() {
|
|||||||
const noRoomToDisplay = directs.length === 0;
|
const noRoomToDisplay = directs.length === 0;
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
|
const reorderTrigger = useRoomListReorder(mx, directs);
|
||||||
|
|
||||||
const sortedDirects = useMemo(() => {
|
const sortedDirects = useMemo(() => {
|
||||||
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
||||||
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
||||||
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
|
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
|
||||||
|
|
||||||
|
const animationRef = useListReorderAnimation(sortedDirects);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: sortedDirects.length,
|
||||||
@@ -232,6 +297,10 @@ export function Direct() {
|
|||||||
</NavItem>
|
</NavItem>
|
||||||
<PluginNavSlot location="direct-messages" />
|
<PluginNavSlot location="direct-messages" />
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
<NavCategory>
|
||||||
|
<NotificationsNavItem />
|
||||||
|
<InvitesNavItem />
|
||||||
|
</NavCategory>
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
@@ -243,6 +312,7 @@ export function Direct() {
|
|||||||
</RoomNavCategoryButton>
|
</RoomNavCategoryButton>
|
||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
<div
|
<div
|
||||||
|
ref={animationRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: virtualizer.getTotalSize(),
|
height: virtualizer.getTotalSize(),
|
||||||
@@ -257,8 +327,9 @@ export function Direct() {
|
|||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile
|
||||||
virtualItem={vItem}
|
virtualItem={vItem}
|
||||||
key={vItem.index}
|
key={roomId}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
|
data-room-id={roomId}
|
||||||
>
|
>
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
DIRECT_CREATE_PATH,
|
DIRECT_CREATE_PATH,
|
||||||
DIRECT_PATH,
|
DIRECT_PATH,
|
||||||
DIRECT_ROOM_PATH,
|
DIRECT_ROOM_PATH,
|
||||||
|
DIRECT_NOTIFICATIONS_PATH,
|
||||||
|
DIRECT_INVITES_PATH,
|
||||||
EXPLORE_FEATURED_PATH,
|
EXPLORE_FEATURED_PATH,
|
||||||
EXPLORE_PATH,
|
EXPLORE_PATH,
|
||||||
EXPLORE_SERVER_PATH,
|
EXPLORE_SERVER_PATH,
|
||||||
@@ -110,6 +112,8 @@ export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): stri
|
|||||||
|
|
||||||
return generatePath(DIRECT_ROOM_PATH, params);
|
return generatePath(DIRECT_ROOM_PATH, params);
|
||||||
};
|
};
|
||||||
|
export const getDirectNotificationsPath = (): string => DIRECT_NOTIFICATIONS_PATH;
|
||||||
|
export const getDirectInvitesPath = (): string => DIRECT_INVITES_PATH;
|
||||||
|
|
||||||
export const getSpacePath = (spaceIdOrAlias: string): string => {
|
export const getSpacePath = (spaceIdOrAlias: string): string => {
|
||||||
const params = {
|
const params = {
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export type InboxNotificationsPathSearchParams = {
|
|||||||
export const INBOX_NOTIFICATIONS_PATH = `/inbox/${_NOTIFICATIONS_PATH}`;
|
export const INBOX_NOTIFICATIONS_PATH = `/inbox/${_NOTIFICATIONS_PATH}`;
|
||||||
export const INBOX_INVITES_PATH = `/inbox/${_INVITES_PATH}`;
|
export const INBOX_INVITES_PATH = `/inbox/${_INVITES_PATH}`;
|
||||||
|
|
||||||
|
export const DIRECT_NOTIFICATIONS_PATH = `/direct/${_NOTIFICATIONS_PATH}`;
|
||||||
|
export const DIRECT_INVITES_PATH = `/direct/${_INVITES_PATH}`;
|
||||||
|
|
||||||
export const SPACE_SETTINGS_PATH = '/space-settings/';
|
export const SPACE_SETTINGS_PATH = '/space-settings/';
|
||||||
|
|
||||||
export const ROOM_SETTINGS_PATH = '/room-settings/';
|
export const ROOM_SETTINGS_PATH = '/room-settings/';
|
||||||
|
|||||||
@@ -289,7 +289,15 @@ export function CodeBlock({
|
|||||||
fill="None"
|
fill="None"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
before={copied && <Icon size="50" src={Icons.Check} />}
|
before={
|
||||||
|
copied ? (
|
||||||
|
<Icon size="50" src={Icons.Check} />
|
||||||
|
) : (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M3 5V12.73C2.4 12.38 2 11.74 2 11V5C2 2.79 3.79 1 6 1H9C9.74 1 10.38 1.4 10.73 2H6C4.35 2 3 3.35 3 5ZM11 15H6C4.897 15 4 14.103 4 13V5C4 3.897 4.897 3 6 3H11C12.103 3 13 3.897 13 5V13C13 14.103 12.103 15 11 15ZM12 5C12 4.448 11.552 4 11 4H6C5.448 4 5 4.448 5 5V13C5 13.552 5.448 14 6 14H11C11.552 14 12 13.552 12 13V5Z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
|
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -327,6 +335,41 @@ export function CodeBlock({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InlineCode({
|
||||||
|
children,
|
||||||
|
opts,
|
||||||
|
props,
|
||||||
|
}: {
|
||||||
|
children: ChildNode[];
|
||||||
|
opts: HTMLReactParserOptions;
|
||||||
|
props: any;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useTimeoutToggle();
|
||||||
|
|
||||||
|
const handleCopy = (e: MouseEvent<HTMLSpanElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(extractTextFromChildren(children));
|
||||||
|
setCopied();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={css.InlineCodeWrapper}>
|
||||||
|
<span className={css.InlineCodeCopyButton} onClick={handleCopy}>
|
||||||
|
{copied ? (
|
||||||
|
<Icon size="50" src={Icons.Check} />
|
||||||
|
) : (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M3 5V12.73C2.4 12.38 2 11.74 2 11V5C2 2.79 3.79 1 6 1H9C9.74 1 10.38 1.4 10.73 2H6C4.35 2 3 3.35 3 5ZM11 15H6C4.897 15 4 14.103 4 13V5C4 3.897 4.897 3 6 3H11C12.103 3 13 3.897 13 5V13C13 14.103 12.103 15 11 15ZM12 5C12 4.448 11.552 4 11 4H6C5.448 4 5 4.448 5 5V13C5 13.552 5.448 14 6 14H11C11.552 14 12 13.552 12 13V5Z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Text as="code" size="T300" className={css.Code} {...props}>
|
||||||
|
{domToReact(children, opts)}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const getReactCustomHtmlParser = (
|
export const getReactCustomHtmlParser = (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
roomId: string | undefined,
|
roomId: string | undefined,
|
||||||
@@ -450,11 +493,7 @@ export const getReactCustomHtmlParser = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return (
|
return <InlineCode opts={opts} props={props}>{children}</InlineCode>;
|
||||||
<Text as="code" size="T300" className={css.Code} {...props}>
|
|
||||||
{domToReact(children, opts)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export enum EmojiStyle {
|
|||||||
Twemoji = 'twemoji',
|
Twemoji = 'twemoji',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ScrollToLatestBehavior {
|
||||||
|
Always = 'always',
|
||||||
|
Never = 'never',
|
||||||
|
OnNewMessage = 'onNewMessage',
|
||||||
|
}
|
||||||
|
|
||||||
export type CallPanelDockPosition = 'right' | 'sidebar';
|
export type CallPanelDockPosition = 'right' | 'sidebar';
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
@@ -56,6 +62,8 @@ export interface Settings {
|
|||||||
developerTools: boolean;
|
developerTools: boolean;
|
||||||
|
|
||||||
autoJoinSpaceRooms: boolean;
|
autoJoinSpaceRooms: boolean;
|
||||||
|
|
||||||
|
scrollToLatestBehavior: ScrollToLatestBehavior;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -97,6 +105,8 @@ const defaultSettings: Settings = {
|
|||||||
developerTools: false,
|
developerTools: false,
|
||||||
|
|
||||||
autoJoinSpaceRooms: true,
|
autoJoinSpaceRooms: true,
|
||||||
|
|
||||||
|
scrollToLatestBehavior: ScrollToLatestBehavior.Always,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = () => {
|
export const getSettings = () => {
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ export const MarginSpaced = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Paragraph = style([DefaultReset]);
|
export const Paragraph = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const Heading = style([
|
export const Heading = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
MarginSpaced,
|
MarginSpaced,
|
||||||
{
|
{
|
||||||
marginTop: config.space.S400,
|
marginTop: config.space.S400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:first-child': {
|
'&:first-child': {
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
@@ -38,6 +46,8 @@ export const BlockQuote = style([
|
|||||||
paddingLeft: config.space.S200,
|
paddingLeft: config.space.S200,
|
||||||
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
|
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -51,15 +61,58 @@ const CodeFont = style({
|
|||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const InlineCodeWrapper = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-block',
|
||||||
|
});
|
||||||
|
|
||||||
export const Code = style([
|
export const Code = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
BaseCode,
|
BaseCode,
|
||||||
CodeFont,
|
CodeFont,
|
||||||
{
|
{
|
||||||
padding: `0 ${config.space.S100}`,
|
padding: `0 ${config.space.S100}`,
|
||||||
|
display: 'inline',
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const InlineCodeCopyButton = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
transform: 'translateY(-100%)',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderBottom: 'none',
|
||||||
|
borderTopLeftRadius: config.radii.R300,
|
||||||
|
borderTopRightRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: toRem(11),
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
fontWeight: config.fontWeight.W500,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
zIndex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
selectors: {
|
||||||
|
[`${InlineCodeWrapper}:hover &`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
background: color.SurfaceVariant.ContainerHover,
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
background: color.SurfaceVariant.ContainerActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const Spoiler = recipe({
|
export const Spoiler = recipe({
|
||||||
base: [
|
base: [
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
@@ -67,6 +120,8 @@ export const Spoiler = recipe({
|
|||||||
padding: `0 ${config.space.S100}`,
|
padding: `0 ${config.space.S100}`,
|
||||||
backgroundColor: color.SurfaceVariant.ContainerActive,
|
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[aria-pressed=true]': {
|
'&[aria-pressed=true]': {
|
||||||
color: 'transparent',
|
color: 'transparent',
|
||||||
@@ -91,6 +146,7 @@ export const CodeBlock = style([
|
|||||||
fontStyle: 'normal',
|
fontStyle: 'normal',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
export const CodeBlockHeader = style([
|
export const CodeBlockHeader = style([
|
||||||
@@ -126,6 +182,8 @@ export const List = style([
|
|||||||
{
|
{
|
||||||
padding: `0 ${config.space.S100}`,
|
padding: `0 ${config.space.S100}`,
|
||||||
paddingLeft: config.space.S600,
|
paddingLeft: config.space.S600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -153,6 +211,8 @@ export const Mention = recipe({
|
|||||||
padding: `0 ${toRem(2)}`,
|
padding: `0 ${toRem(2)}`,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
fontWeight: config.fontWeight.W500,
|
fontWeight: config.fontWeight.W500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
variants: {
|
variants: {
|
||||||
@@ -178,6 +238,8 @@ export const Command = recipe({
|
|||||||
padding: `0 ${toRem(2)}`,
|
padding: `0 ${toRem(2)}`,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
fontWeight: config.fontWeight.W500,
|
fontWeight: config.fontWeight.W500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
variants: {
|
variants: {
|
||||||
@@ -246,5 +308,7 @@ export const highlightText = style([
|
|||||||
{
|
{
|
||||||
backgroundColor: 'yellow',
|
backgroundColor: 'yellow',
|
||||||
color: 'black',
|
color: 'black',
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
73
src/app/utils/androidShare.ts
Normal file
73
src/app/utils/androidShare.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||||
|
|
||||||
|
/** Metadata for a file received from an Android share intent. */
|
||||||
|
export type AndroidSharedFile = {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Payload persisted by the native Android share handler. */
|
||||||
|
export type AndroidSharePayload = {
|
||||||
|
text?: string;
|
||||||
|
subject?: string;
|
||||||
|
files: AndroidSharedFile[];
|
||||||
|
receivedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AndroidShareHandlerPlugin {
|
||||||
|
/** Returns the last pending share payload, if one exists. */
|
||||||
|
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. */
|
||||||
|
export const isAndroidShareSupported = (): boolean =>
|
||||||
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
|
/** Fetches the current pending native share payload. */
|
||||||
|
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
|
||||||
|
if (!isAndroidShareSupported()) return null;
|
||||||
|
const result = await AndroidShareHandler.getPendingShare();
|
||||||
|
return result.share;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Clears the native pending share payload. */
|
||||||
|
export const clearPendingAndroidShare = async (): Promise<void> => {
|
||||||
|
if (!isAndroidShareSupported()) return;
|
||||||
|
await AndroidShareHandler.clearPendingShare();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subscribes to new incoming native share payloads. */
|
||||||
|
export const listenForAndroidShares = async (
|
||||||
|
listener: (payload: AndroidSharePayload) => void
|
||||||
|
): Promise<PluginListenerHandle | undefined> => {
|
||||||
|
if (!isAndroidShareSupported()) return undefined;
|
||||||
|
return AndroidShareHandler.addListener('shareReceived', listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Converts a cached native shared file into a browser File for upload. */
|
||||||
|
export const materializeSharedFile = async (
|
||||||
|
sharedFile: AndroidSharedFile,
|
||||||
|
receivedAt: number
|
||||||
|
): Promise<File> => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
518
src/app/utils/backgroundSync.ts
Normal file
518
src/app/utils/backgroundSync.ts
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||||
|
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
type UnifiedPushStatus = {
|
||||||
|
running: boolean;
|
||||||
|
endpoint: string;
|
||||||
|
instance: string;
|
||||||
|
registered: boolean;
|
||||||
|
distributor: string;
|
||||||
|
distributors: string[] | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnifiedPushEndpointEvent = {
|
||||||
|
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. */
|
||||||
|
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
|
||||||
|
await unifiedPushManager.start(mx);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually triggers a one-shot native fetch.
|
||||||
|
* Useful for diagnostics and bridge testing.
|
||||||
|
*/
|
||||||
|
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. */
|
||||||
|
export const stopBackgroundSync = async (): Promise<void> => {
|
||||||
|
await unifiedPushManager.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the native service whether the app UI is in the foreground.
|
||||||
|
* 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> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.setAppForeground({ foreground });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] setAppForeground failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-opens the UnifiedPush distributor selection dialog.
|
||||||
|
* 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 }> => {
|
||||||
|
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. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -172,6 +172,32 @@ export type ScrollInfo = {
|
|||||||
viewHeight: number;
|
viewHeight: number;
|
||||||
scrollable: boolean;
|
scrollable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ScrollBehavior = 'auto' | 'instant' | 'smooth';
|
||||||
|
|
||||||
|
export type ScrollPosition = {
|
||||||
|
top?: number;
|
||||||
|
left?: number;
|
||||||
|
behavior?: ScrollBehavior;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized wrapper for element scrolling.
|
||||||
|
*/
|
||||||
|
export const scrollToPosition = (scrollEl: HTMLElement, position: ScrollPosition): void => {
|
||||||
|
scrollEl.scrollTo(position);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized wrapper for scrolling a target into the current viewport.
|
||||||
|
*/
|
||||||
|
export const scrollElementIntoView = (
|
||||||
|
element: HTMLElement,
|
||||||
|
options?: ScrollIntoViewOptions
|
||||||
|
): void => {
|
||||||
|
element.scrollIntoView(options);
|
||||||
|
};
|
||||||
|
|
||||||
export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
|
export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
|
||||||
offsetTop: Math.round(target.offsetTop),
|
offsetTop: Math.round(target.offsetTop),
|
||||||
top: Math.round(target.scrollTop),
|
top: Math.round(target.scrollTop),
|
||||||
@@ -180,17 +206,44 @@ export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
|
|||||||
scrollable: target.scrollHeight > target.offsetHeight,
|
scrollable: target.scrollHeight > target.offsetHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => {
|
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: ScrollBehavior) => {
|
||||||
scrollEl.scrollTo({
|
scrollToPosition(scrollEl, {
|
||||||
top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
|
top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
|
||||||
behavior,
|
behavior,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if scroll element is at or near the bottom (within threshold).
|
||||||
|
* Useful for "sticky to bottom" behavior in chat-like interfaces.
|
||||||
|
* @param scrollEl - The scrollable element
|
||||||
|
* @param threshold - Distance from bottom in pixels (default: 50px)
|
||||||
|
* @returns true if scrolled to bottom within threshold
|
||||||
|
*/
|
||||||
|
export const isScrolledToBottom = (scrollEl: HTMLElement, threshold = 50): boolean => {
|
||||||
|
const { scrollTop, offsetHeight, scrollHeight } = scrollEl;
|
||||||
|
const scrollPosition = scrollTop + offsetHeight;
|
||||||
|
return scrollHeight - scrollPosition <= threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared auto-scroll behavior for chat-like timelines that should stay pinned to bottom.
|
||||||
|
* Only scrolls if already near the bottom unless a call site explicitly forces it.
|
||||||
|
*/
|
||||||
|
export const autoScrollToBottom = (
|
||||||
|
scrollEl: HTMLElement,
|
||||||
|
smooth = false,
|
||||||
|
force = false
|
||||||
|
): void => {
|
||||||
|
if (force || isScrolledToBottom(scrollEl)) {
|
||||||
|
scrollToBottom(scrollEl, smooth ? 'smooth' : 'instant');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const copyToClipboard = (text: string) => {
|
export const copyToClipboard = (text: string) => {
|
||||||
// Try Electron clipboard first (if available)
|
// Try Electron clipboard first (if available)
|
||||||
if ('electron' in window && (window as any).electron?.clipboard?.writeText) {
|
if ('electron' in window && window.electron?.clipboard?.writeText) {
|
||||||
(window as any).electron.clipboard.writeText(text);
|
window.electron.clipboard.writeText(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ export function detectImageFormat(data: ArrayBuffer | Uint8Array): ImageFormat {
|
|||||||
export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
||||||
const format = detectImageFormat(imageData);
|
const format = detectImageFormat(imageData);
|
||||||
|
|
||||||
|
console.log('[extractColorFromImage] Detected format:', format);
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'png':
|
case 'png':
|
||||||
return extractColorFromPNG(imageData);
|
return extractColorFromPNG(imageData);
|
||||||
@@ -211,14 +213,19 @@ export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): str
|
|||||||
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
|
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
|
||||||
const format = detectImageFormat(imageData);
|
const format = detectImageFormat(imageData);
|
||||||
|
|
||||||
|
console.log('[extractMetadataFromImage] Detected format:', format);
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'png':
|
case 'png':
|
||||||
return extractMetadataFromPNG(imageData);
|
const pngMetadata = extractMetadataFromPNG(imageData);
|
||||||
|
console.log('[extractMetadataFromImage] PNG metadata:', pngMetadata);
|
||||||
|
return pngMetadata;
|
||||||
// For other formats, extract color only for now
|
// For other formats, extract color only for now
|
||||||
case 'webp':
|
case 'webp':
|
||||||
case 'jpeg':
|
case 'jpeg':
|
||||||
case 'gif': {
|
case 'gif': {
|
||||||
const color = extractColorFromImage(imageData);
|
const color = extractColorFromImage(imageData);
|
||||||
|
console.log('[extractMetadataFromImage] Non-PNG color:', color);
|
||||||
return color ? { color } : {};
|
return color ? { color } : {};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -344,6 +351,10 @@ export function getExtension(format: ImageFormat): string {
|
|||||||
const avatarMetadataCache = new LRUCache<string, { metadata: ImageMetadata; timestamp: number }>(100);
|
const avatarMetadataCache = new LRUCache<string, { metadata: ImageMetadata; timestamp: number }>(100);
|
||||||
const METADATA_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
const METADATA_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
|
// In-flight fetch tracking to prevent duplicate parallel requests
|
||||||
|
const inflightColorFetches = new Map<string, Promise<string | undefined>>();
|
||||||
|
const inflightMetadataFetches = new Map<string, Promise<ImageMetadata>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and extract color from an image URL (supports all formats)
|
* Fetch and extract color from an image URL (supports all formats)
|
||||||
* @param url - HTTP URL to fetch the image from
|
* @param url - HTTP URL to fetch the image from
|
||||||
@@ -354,29 +365,51 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
|
|||||||
// Check if full metadata is cached
|
// Check if full metadata is cached
|
||||||
const cached = avatarMetadataCache.get(url);
|
const cached = avatarMetadataCache.get(url);
|
||||||
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
||||||
|
console.log('[fetchAndExtractColor] Using cached color for', url, ':', cached.metadata.color);
|
||||||
return cached.metadata.color;
|
return cached.metadata.color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if fetch already in progress
|
||||||
|
const inflightKey = `${url}:${accessToken || ''}`;
|
||||||
|
const existingFetch = inflightColorFetches.get(inflightKey);
|
||||||
|
if (existingFetch) {
|
||||||
|
console.log('[fetchAndExtractColor] Reusing in-flight fetch for', url);
|
||||||
|
return existingFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[fetchAndExtractColor] Fetching and extracting color from', url);
|
||||||
|
|
||||||
|
const fetchPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[fetchAndExtractColor] Fetch response status:', response.ok, response.status);
|
||||||
|
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
const data = await response.arrayBuffer();
|
const data = await response.arrayBuffer();
|
||||||
const color = extractColorFromImage(data);
|
console.log('[fetchAndExtractColor] Downloaded', data.byteLength, 'bytes');
|
||||||
|
|
||||||
// Cache the color result as part of metadata
|
const color = extractColorFromImage(data);
|
||||||
avatarMetadataCache.set(url, {
|
console.log('[fetchAndExtractColor] Extracted color:', color);
|
||||||
metadata: { color },
|
|
||||||
timestamp: Date.now()
|
// Don't cache here - fetchAndExtractMetadata will cache full metadata
|
||||||
});
|
// This avoids cache collision where color-only fetch overwrites full metadata
|
||||||
|
|
||||||
return color;
|
return color;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error('[fetchAndExtractColor] Error fetching/extracting color:', error);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
inflightColorFetches.delete(inflightKey);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflightColorFetches.set(inflightKey, fetchPromise);
|
||||||
|
return fetchPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -389,18 +422,36 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
|
|||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = avatarMetadataCache.get(url);
|
const cached = avatarMetadataCache.get(url);
|
||||||
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
||||||
|
console.log('[fetchAndExtractMetadata] Using cached metadata for', url, ':', cached.metadata);
|
||||||
return cached.metadata;
|
return cached.metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if fetch already in progress
|
||||||
|
const inflightKey = `${url}:${accessToken || ''}`;
|
||||||
|
const existingFetch = inflightMetadataFetches.get(inflightKey);
|
||||||
|
if (existingFetch) {
|
||||||
|
console.log('[fetchAndExtractMetadata] Reusing in-flight fetch for', url);
|
||||||
|
return existingFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[fetchAndExtractMetadata] Fetching metadata from', url);
|
||||||
|
|
||||||
|
const fetchPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[fetchAndExtractMetadata] Fetch response status:', response.ok, response.status);
|
||||||
|
|
||||||
if (!response.ok) return {};
|
if (!response.ok) return {};
|
||||||
|
|
||||||
const data = await response.arrayBuffer();
|
const data = await response.arrayBuffer();
|
||||||
|
console.log('[fetchAndExtractMetadata] Downloaded', data.byteLength, 'bytes');
|
||||||
|
|
||||||
const metadata = extractMetadataFromImage(data);
|
const metadata = extractMetadataFromImage(data);
|
||||||
|
console.log('[fetchAndExtractMetadata] Extracted metadata:', metadata);
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
avatarMetadataCache.set(url, {
|
avatarMetadataCache.set(url, {
|
||||||
@@ -411,5 +462,11 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
|
|||||||
return metadata;
|
return metadata;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
|
} finally {
|
||||||
|
inflightMetadataFetches.delete(inflightKey);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflightMetadataFetches.set(inflightKey, fetchPromise);
|
||||||
|
return fetchPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
|
|||||||
|
|
||||||
export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
|
export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
|
||||||
|
|
||||||
const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])([^\s:]+):(\S+)$/);
|
const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])([^\s:]+):(\S+)$/);
|
||||||
|
|
||||||
const validMxId = (id: string): boolean => !!matchMxId(id);
|
const validMxId = (id: string): boolean => !!matchMxId(id);
|
||||||
|
|
||||||
@@ -301,7 +301,9 @@ export const mxcUrlToHttp = (
|
|||||||
*/
|
*/
|
||||||
export const isAuthenticatedMediaUrl = (url: string): boolean =>
|
export const isAuthenticatedMediaUrl = (url: string): boolean =>
|
||||||
url.includes('/_matrix/client/v1/media/download') ||
|
url.includes('/_matrix/client/v1/media/download') ||
|
||||||
url.includes('/_matrix/client/v1/media/thumbnail');
|
url.includes('/_matrix/client/v1/media/thumbnail') ||
|
||||||
|
url.includes('/_matrix/media/') &&
|
||||||
|
(url.includes('/download/') || url.includes('/thumbnail/'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads media with optional authentication.
|
* Downloads media with optional authentication.
|
||||||
|
|||||||
@@ -153,21 +153,30 @@ export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string
|
|||||||
// Verify PNG signature
|
// Verify PNG signature
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
if (data[i] !== PNG_SIGNATURE[i]) {
|
if (data[i] !== PNG_SIGNATURE[i]) {
|
||||||
|
console.warn('[extractColorFromPNG] Not a valid PNG');
|
||||||
return undefined; // Not a PNG
|
return undefined; // Not a PNG
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = parseChunks(data);
|
const chunks = parseChunks(data);
|
||||||
|
console.log('[extractColorFromPNG] Found', chunks.length, 'chunks');
|
||||||
|
|
||||||
|
let textChunksFound = 0;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.type === 'tEXt') {
|
if (chunk.type === 'tEXt') {
|
||||||
|
textChunksFound++;
|
||||||
const text = parseTextChunk(chunk.data);
|
const text = parseTextChunk(chunk.data);
|
||||||
if (text && text.key === PAARROT_COLOR_KEY) {
|
if (text) {
|
||||||
|
console.log('[extractColorFromPNG] Found tEXt chunk:', text.key, '=', text.value);
|
||||||
|
if (text.key === PAARROT_COLOR_KEY) {
|
||||||
|
console.log('[extractColorFromPNG] Found paarrot:color =', text.value);
|
||||||
return text.value;
|
return text.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[extractColorFromPNG] No paarrot:color found (found', textChunksFound, 'tEXt chunks)');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,16 +223,21 @@ export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): Ima
|
|||||||
// Verify PNG signature
|
// Verify PNG signature
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
if (data[i] !== PNG_SIGNATURE[i]) {
|
if (data[i] !== PNG_SIGNATURE[i]) {
|
||||||
|
console.warn('[extractMetadataFromPNG] Not a valid PNG');
|
||||||
return metadata; // Not a PNG
|
return metadata; // Not a PNG
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = parseChunks(data);
|
const chunks = parseChunks(data);
|
||||||
|
console.log('[extractMetadataFromPNG] Found', chunks.length, 'chunks');
|
||||||
|
|
||||||
|
let textChunksFound = 0;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.type === 'tEXt') {
|
if (chunk.type === 'tEXt') {
|
||||||
|
textChunksFound++;
|
||||||
const text = parseTextChunk(chunk.data);
|
const text = parseTextChunk(chunk.data);
|
||||||
if (text) {
|
if (text) {
|
||||||
|
console.log('[extractMetadataFromPNG] Found tEXt chunk:', text.key, '=', text.value);
|
||||||
if (text.key === PAARROT_COLOR_KEY) {
|
if (text.key === PAARROT_COLOR_KEY) {
|
||||||
metadata.color = text.value;
|
metadata.color = text.value;
|
||||||
} else if (text.key === PAARROT_BANNER_KEY) {
|
} else if (text.key === PAARROT_BANNER_KEY) {
|
||||||
@@ -241,6 +255,8 @@ export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): Ima
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[extractMetadataFromPNG] Final metadata extracted:', metadata, 'found', textChunksFound, 'tEXt chunks');
|
||||||
|
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ export const isYouTubeStreamingAvailable = (): boolean => {
|
|||||||
/** Callback for notification tap actions */
|
/** Callback for notification tap actions */
|
||||||
let notificationTapCallback: ((path: string) => void) | null = null;
|
let notificationTapCallback: ((path: string) => void) | null = null;
|
||||||
|
|
||||||
|
const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot';
|
||||||
|
const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bring the Tauri window to the front and focus it
|
* Bring the Tauri window to the front and focus it
|
||||||
*/
|
*/
|
||||||
@@ -190,6 +193,21 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
|
|||||||
console.warn('Failed to set up Tauri notification tap listener:', err);
|
console.warn('Failed to set up Tauri notification tap listener:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCapacitorNative()) {
|
||||||
|
try {
|
||||||
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||||
|
await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => {
|
||||||
|
await focusWindow();
|
||||||
|
const path = event?.notification?.extra?.path;
|
||||||
|
if (path && typeof path === 'string' && notificationTapCallback) {
|
||||||
|
notificationTapCallback(path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to set up Capacitor notification tap listener:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,6 +222,15 @@ export const isTauri = (): boolean =>
|
|||||||
export const isElectron = (): boolean =>
|
export const isElectron = (): boolean =>
|
||||||
typeof window !== 'undefined' && 'electron' in window;
|
typeof window !== 'undefined' && 'electron' in window;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're running inside a Capacitor native app
|
||||||
|
*/
|
||||||
|
export const isCapacitorNative = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const cap = (window as any).Capacitor;
|
||||||
|
return Boolean(cap?.isNativePlatform?.() || cap?.getPlatform?.() === 'android' || cap?.getPlatform?.() === 'ios');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we're running on a mobile platform (Android/iOS)
|
* Check if we're running on a mobile platform (Android/iOS)
|
||||||
*/
|
*/
|
||||||
@@ -305,6 +332,78 @@ const ensureNotificationChannel = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureCapacitorNotificationChannel = async (): Promise<void> => {
|
||||||
|
if (notificationChannelCreated || !isAndroid()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||||
|
await LocalNotifications.createChannel({
|
||||||
|
id: 'messages',
|
||||||
|
name: 'Messages',
|
||||||
|
description: 'Message notifications from Matrix',
|
||||||
|
importance: 5,
|
||||||
|
sound: 'default',
|
||||||
|
visibility: 1,
|
||||||
|
vibration: true,
|
||||||
|
lights: true,
|
||||||
|
});
|
||||||
|
notificationChannelCreated = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to create Capacitor notification channel:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request notification permission across Electron, Tauri, Capacitor, and browser
|
||||||
|
*/
|
||||||
|
export const requestSystemNotificationPermission = async (): Promise<boolean> => {
|
||||||
|
if (isElectron() || (isTauri() && !isElectron())) {
|
||||||
|
if (!('Notification' in window)) return false;
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
return permission === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCapacitorNative()) {
|
||||||
|
try {
|
||||||
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||||
|
let perm = await LocalNotifications.checkPermissions();
|
||||||
|
if (perm.display !== 'granted') {
|
||||||
|
perm = await LocalNotifications.requestPermissions();
|
||||||
|
}
|
||||||
|
return perm.display === 'granted';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Capacitor notification permission request failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('Notification' in window)) return false;
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
return permission === 'granted';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemNotificationPermissionState = async (): Promise<PermissionState> => {
|
||||||
|
if (isCapacitorNative()) {
|
||||||
|
try {
|
||||||
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||||
|
const perm = await LocalNotifications.checkPermissions();
|
||||||
|
return perm.display === 'granted' ? 'granted' : 'prompt';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to check Capacitor notification permission:', err);
|
||||||
|
return 'denied';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('Notification' in window) {
|
||||||
|
if (window.Notification.permission === 'default') {
|
||||||
|
return 'prompt';
|
||||||
|
}
|
||||||
|
return window.Notification.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'denied';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a notification using Tauri's notification plugin
|
* Send a notification using Tauri's notification plugin
|
||||||
* Falls back to browser Notification API if not in Tauri
|
* Falls back to browser Notification API if not in Tauri
|
||||||
@@ -370,6 +469,38 @@ export const sendNotification = async (options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCapacitorNative()) {
|
||||||
|
try {
|
||||||
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||||
|
let perm = await LocalNotifications.checkPermissions();
|
||||||
|
if (perm.display !== 'granted') {
|
||||||
|
perm = await LocalNotifications.requestPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (perm.display === 'granted') {
|
||||||
|
await ensureCapacitorNotificationChannel();
|
||||||
|
|
||||||
|
const id = Math.floor(Date.now() % 2147483647);
|
||||||
|
await LocalNotifications.schedule({
|
||||||
|
notifications: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
channelId: isAndroid() ? 'messages' : undefined,
|
||||||
|
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
|
||||||
|
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
|
||||||
|
extra: path ? { path } : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Capacitor notification failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback to browser notification
|
// Fallback to browser notification
|
||||||
sendBrowserNotification({ title, body, icon, onClick });
|
sendBrowserNotification({ title, body, icon, onClick });
|
||||||
};
|
};
|
||||||
|
|||||||
3
src/ext.d.ts
vendored
3
src/ext.d.ts
vendored
@@ -53,6 +53,9 @@ interface PluginAPI {
|
|||||||
interface ElectronAPI {
|
interface ElectronAPI {
|
||||||
platform?: string;
|
platform?: string;
|
||||||
arch?: string;
|
arch?: string;
|
||||||
|
clipboard?: {
|
||||||
|
writeText: (text: string) => void;
|
||||||
|
};
|
||||||
plugins?: PluginAPI;
|
plugins?: PluginAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ body {
|
|||||||
font-variant-ligatures: no-contextual;
|
font-variant-ligatures: no-contextual;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
|
||||||
|
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme-specific body backgrounds for safe area padding */
|
/* Theme-specific body backgrounds for safe area padding */
|
||||||
body.light-theme {
|
body.light-theme {
|
||||||
background-color: #F0F0F0;
|
background-color: #F0F0F0;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -1,5 +1,69 @@
|
|||||||
// https://github.com/cloudrac3r/cadencegq/blob/master/pug/mxid.pug
|
// https://github.com/cloudrac3r/cadencegq/blob/master/pug/mxid.pug
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HSL to hex color
|
||||||
|
* @param {number} h - Hue (0-360)
|
||||||
|
* @param {number} s - Saturation (0-100)
|
||||||
|
* @param {number} l - Lightness (0-100)
|
||||||
|
* @returns {string} Hex color string
|
||||||
|
*/
|
||||||
|
function hslToHex(h, s, l) {
|
||||||
|
const sNorm = s / 100;
|
||||||
|
const lNorm = l / 100;
|
||||||
|
|
||||||
|
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
||||||
|
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
||||||
|
const m = lNorm - c / 2;
|
||||||
|
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
|
||||||
|
if (h >= 0 && h < 60) {
|
||||||
|
r = c; g = x; b = 0;
|
||||||
|
} else if (h >= 60 && h < 120) {
|
||||||
|
r = x; g = c; b = 0;
|
||||||
|
} else if (h >= 120 && h < 180) {
|
||||||
|
r = 0; g = c; b = x;
|
||||||
|
} else if (h >= 180 && h < 240) {
|
||||||
|
r = 0; g = x; b = c;
|
||||||
|
} else if (h >= 240 && h < 300) {
|
||||||
|
r = x; g = 0; b = c;
|
||||||
|
} else if (h >= 300 && h < 360) {
|
||||||
|
r = c; g = 0; b = x;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rHex = Math.round((r + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
const gHex = Math.round((g + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
const bHex = Math.round((b + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
|
||||||
|
return `#${rHex}${gHex}${bHex}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light theme colors: hsl(h, 100%, l)
|
||||||
|
const LIGHT_COLORS = [
|
||||||
|
{ h: 208, s: 100, l: 45 }, // --mx-uc-1
|
||||||
|
{ h: 302, s: 100, l: 30 }, // --mx-uc-2
|
||||||
|
{ h: 163, s: 100, l: 30 }, // --mx-uc-3
|
||||||
|
{ h: 343, s: 100, l: 45 }, // --mx-uc-4
|
||||||
|
{ h: 24, s: 100, l: 45 }, // --mx-uc-5
|
||||||
|
{ h: 181, s: 100, l: 30 }, // --mx-uc-6
|
||||||
|
{ h: 242, s: 100, l: 45 }, // --mx-uc-7
|
||||||
|
{ h: 94, s: 100, l: 35 }, // --mx-uc-8
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dark theme colors: hsl(h, 100%, l)
|
||||||
|
const DARK_COLORS = [
|
||||||
|
{ h: 208, s: 100, l: 75 }, // --mx-uc-1
|
||||||
|
{ h: 301, s: 100, l: 80 }, // --mx-uc-2
|
||||||
|
{ h: 163, s: 100, l: 70 }, // --mx-uc-3
|
||||||
|
{ h: 343, s: 100, l: 75 }, // --mx-uc-4
|
||||||
|
{ h: 24, s: 100, l: 70 }, // --mx-uc-5
|
||||||
|
{ h: 181, s: 100, l: 60 }, // --mx-uc-6
|
||||||
|
{ h: 243, s: 100, l: 80 }, // --mx-uc-7
|
||||||
|
{ h: 94, s: 100, l: 80 }, // --mx-uc-8
|
||||||
|
];
|
||||||
|
|
||||||
function hashCode(str) {
|
function hashCode(str) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
let i;
|
let i;
|
||||||
@@ -22,6 +86,33 @@ export function cssColorMXID(userId) {
|
|||||||
return `--mx-uc-${colorNumber + 1}`;
|
return `--mx-uc-${colorNumber + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the actual hex color value for a user ID
|
||||||
|
* @param {string} userId - Matrix user ID
|
||||||
|
* @param {boolean} isDark - Whether to use dark theme colors (default: auto-detect)
|
||||||
|
* @returns {string} Hex color string
|
||||||
|
*/
|
||||||
|
export function getColorMXIDValue(userId, isDark) {
|
||||||
|
const colorNumber = hashCode(userId) % 8;
|
||||||
|
|
||||||
|
// Auto-detect theme if not specified
|
||||||
|
let useDark = isDark;
|
||||||
|
if (typeof isDark === 'undefined') {
|
||||||
|
const bodyClass = document.body.className;
|
||||||
|
useDark = bodyClass.includes('dark-theme') ||
|
||||||
|
bodyClass.includes('butter-theme') ||
|
||||||
|
bodyClass.includes('discord-theme') ||
|
||||||
|
bodyClass.includes('discord-darker-theme') ||
|
||||||
|
bodyClass.includes('twilight-theme') ||
|
||||||
|
bodyClass.includes('mocha-theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorSet = useDark ? DARK_COLORS : LIGHT_COLORS;
|
||||||
|
const color = colorSet[colorNumber];
|
||||||
|
|
||||||
|
return hslToHex(color.h, color.s, color.l);
|
||||||
|
}
|
||||||
|
|
||||||
export default function colorMXID(userId) {
|
export default function colorMXID(userId) {
|
||||||
return `var(${cssColorMXID(userId)})`;
|
return `var(${cssColorMXID(userId)})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
"rootDir": "./src",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["ES2016", "DOM"]
|
"lib": ["ES2016", "DOM"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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