Compare commits
20 Commits
b4258278d8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 99a294791e | |||
| f62200ecd1 | |||
| b52926f7d8 | |||
| d338e1c35e | |||
| 9589680a81 | |||
| 27f1357fc0 | |||
| 9509a9705e | |||
| 61d41900cc | |||
| e7777f42d8 | |||
| ae70eae0fc | |||
| c286501be8 | |||
| 154f4dfdb0 | |||
| c7067021ba | |||
| 687f139719 | |||
| b711646b58 | |||
| bd6d753f47 | |||
| 3de3058ca9 | |||
| 02d96a9758 | |||
| 5374c10c61 | |||
| 9c7f71896c |
3
.npmrc
3
.npmrc
@@ -1,2 +1,3 @@
|
|||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
||||||
save-exact=true
|
save-exact=true
|
||||||
|
allow-git=all
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ tauri.ts / electron main
|
|||||||
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
|
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
|
||||||
- Pusher registration races on fast login/logout
|
- Pusher registration races on fast login/logout
|
||||||
- Android small icon: `ic_stat_paarrot` must exist in Android resources
|
- Android small icon: `ic_stat_paarrot` must exist in Android resources
|
||||||
|
- Background wake (`MatrixSyncService`) must filter by push rules / unread_notifications; a push for one room used to notify for every new message in the sync batch
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
|
|
||||||
@@ -91,3 +92,4 @@ tauri.ts / electron main
|
|||||||
|
|
||||||
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
|
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
|
||||||
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
|
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
|
||||||
|
- Android notify filter: mute / mentions / default-room behavior lives in `MatrixSyncService.resolveRoomNotifyMode`
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
|
||||||
"@fontsource-variable/inter": "5.2.8",
|
"@fontsource-variable/inter": "5.2.8",
|
||||||
|
"@fontsource/caveat": "5.3.0",
|
||||||
"@fontsource/inter": "5.2.8",
|
"@fontsource/inter": "5.2.8",
|
||||||
"@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.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
@@ -2368,6 +2369,15 @@
|
|||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/caveat": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-eHTfzQxFSW9ABERRLNruHoNJmKed2J7pqTUc5lp4a4POMgXGKpxmWj3d9Hz10kmAg6iuTTLe6UagbA4bnsmfQw==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fontsource/inter": {
|
"node_modules/@fontsource/inter": {
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite --open false",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "yarn check:eslint && yarn check:prettier",
|
"lint": "yarn check:eslint && yarn check:prettier",
|
||||||
"check:eslint": "eslint src/*",
|
"check:eslint": "eslint src/*",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
|
||||||
"@fontsource-variable/inter": "5.2.8",
|
"@fontsource-variable/inter": "5.2.8",
|
||||||
|
"@fontsource/caveat": "5.3.0",
|
||||||
"@fontsource/inter": "5.2.8",
|
"@fontsource/inter": "5.2.8",
|
||||||
"@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.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
@@ -122,5 +123,9 @@
|
|||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vite-plugin-static-copy": "4.1.1",
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-top-level-await": "1.6.0"
|
"vite-plugin-top-level-await": "1.6.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"@swc/core@1.15.43": true,
|
||||||
|
"esbuild@0.28.1": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,53 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
const decodeSegment = (segment: string): string => {
|
||||||
|
let decoded = segment;
|
||||||
|
try {
|
||||||
|
let next = decodeURIComponent(decoded);
|
||||||
|
while (next !== decoded) {
|
||||||
|
decoded = next;
|
||||||
|
next = decodeURIComponent(decoded);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep partially decoded value
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper for Outlet that adds route-based animation
|
* Room routes are `:roomIdOrAlias/:eventId?/`. Jumping to an event (or clearing it)
|
||||||
* Forces remount on route change by using location as key
|
* changes the pathname but not the room — keep the outlet mounted so we don't replay
|
||||||
|
* the route enter animation or remount drawers like Shared Media.
|
||||||
|
*
|
||||||
|
* Path segments are decoded so `!room:server` and `%21room%3Aserver` share a key.
|
||||||
|
*/
|
||||||
|
const getOutletTransitionKey = (pathname: string): string => {
|
||||||
|
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
|
||||||
|
if (segments.length === 0) return pathname;
|
||||||
|
|
||||||
|
// Matrix event IDs start with `$`
|
||||||
|
if (segments[segments.length - 1].startsWith('$')) {
|
||||||
|
segments.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${segments.join('/')}/`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for Outlet that adds route-based animation.
|
||||||
|
* Remounts (and animates) when leaving a room / switching rooms, not on same-room event hops.
|
||||||
*/
|
*/
|
||||||
export function AnimatedOutlet() {
|
export function AnimatedOutlet() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const transitionKey = useMemo(
|
||||||
|
() => getOutletTransitionKey(location.pathname),
|
||||||
|
[location.pathname]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={location.pathname}
|
key={transitionKey}
|
||||||
data-route-transition="true"
|
data-route-transition="true"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,90 +1,11 @@
|
|||||||
import { ReactNode, useCallback } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
|
import { useBackRoute } from '../hooks/useBackRoute';
|
||||||
import {
|
|
||||||
decodeRouteParam,
|
|
||||||
getDirectPath,
|
|
||||||
getExplorePath,
|
|
||||||
getHomePath,
|
|
||||||
getInboxPath,
|
|
||||||
getSpacePath,
|
|
||||||
} from '../pages/pathUtils';
|
|
||||||
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
|
|
||||||
|
|
||||||
type BackRouteHandlerProps = {
|
type BackRouteHandlerProps = {
|
||||||
children: (onBack: () => void) => ReactNode;
|
children: (onBack: () => void) => ReactNode;
|
||||||
};
|
};
|
||||||
export function BackRouteHandler({ children }: BackRouteHandlerProps) {
|
export function BackRouteHandler({ children }: BackRouteHandlerProps) {
|
||||||
const navigate = useNavigate();
|
const { goBack } = useBackRoute();
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
|
||||||
if (
|
|
||||||
matchPath(
|
|
||||||
{
|
|
||||||
path: HOME_PATH,
|
|
||||||
caseSensitive: true,
|
|
||||||
end: false,
|
|
||||||
},
|
|
||||||
location.pathname
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
navigate(getHomePath());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
matchPath(
|
|
||||||
{
|
|
||||||
path: DIRECT_PATH,
|
|
||||||
caseSensitive: true,
|
|
||||||
end: false,
|
|
||||||
},
|
|
||||||
location.pathname
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
navigate(getDirectPath());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const spaceMatch = matchPath(
|
|
||||||
{
|
|
||||||
path: SPACE_PATH,
|
|
||||||
caseSensitive: true,
|
|
||||||
end: false,
|
|
||||||
},
|
|
||||||
location.pathname
|
|
||||||
);
|
|
||||||
if (spaceMatch?.params.spaceIdOrAlias) {
|
|
||||||
const spaceIdOrAlias = decodeRouteParam(spaceMatch.params.spaceIdOrAlias);
|
|
||||||
if (spaceIdOrAlias) {
|
|
||||||
navigate(getSpacePath(spaceIdOrAlias));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
matchPath(
|
|
||||||
{
|
|
||||||
path: EXPLORE_PATH,
|
|
||||||
caseSensitive: true,
|
|
||||||
end: false,
|
|
||||||
},
|
|
||||||
location.pathname
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
navigate(getExplorePath());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
matchPath(
|
|
||||||
{
|
|
||||||
path: INBOX_PATH,
|
|
||||||
caseSensitive: true,
|
|
||||||
end: false,
|
|
||||||
},
|
|
||||||
location.pathname
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
navigate(getInboxPath());
|
|
||||||
}
|
|
||||||
}, [navigate, location]);
|
|
||||||
|
|
||||||
return children(goBack);
|
return children(goBack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ export const createRoomEncryptionState = () => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial power levels for MatrixRTC call membership (MSC3401).
|
||||||
|
* Do NOT put this in createRoom initial_state — homeservers reject incomplete
|
||||||
|
* m.room.power_levels with 403. Apply after create via sendStateEvent instead,
|
||||||
|
* or rely on TrustedPrivateChat (both users PL 100) for DMs.
|
||||||
|
*/
|
||||||
export const createRoomPowerLevelsState = () => ({
|
export const createRoomPowerLevelsState = () => ({
|
||||||
type: StateEvent.RoomPowerLevels,
|
type: StateEvent.RoomPowerLevels,
|
||||||
state_key: '',
|
state_key: '',
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const EditorOptions = style([
|
|||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ export const EditorTextarea = style([
|
|||||||
{
|
{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
padding: `${toRem(13)} ${toRem(1)}`,
|
padding: `${toRem(10)} ${toRem(1)}`,
|
||||||
selectors: {
|
selectors: {
|
||||||
[`${EditorTextareaScroll}:first-child &`]: {
|
[`${EditorTextareaScroll}:first-child &`]: {
|
||||||
paddingLeft: toRem(13),
|
paddingLeft: toRem(13),
|
||||||
@@ -54,7 +56,7 @@ export const EditorPlaceholderTextVisual = style([
|
|||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
paddingTop: toRem(13),
|
paddingTop: toRem(10),
|
||||||
paddingLeft: toRem(1),
|
paddingLeft: toRem(1),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ 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, Element as SlateElement, Text as SlateText } from 'slate';
|
||||||
import {
|
import {
|
||||||
Slate,
|
Slate,
|
||||||
Editable,
|
Editable,
|
||||||
@@ -172,48 +170,6 @@ 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}>
|
||||||
@@ -243,7 +199,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||||
{top}
|
{top}
|
||||||
<Box
|
<Box
|
||||||
alignItems="Start"
|
alignItems={fillHeight ? 'Start' : 'Center'}
|
||||||
grow={fillHeight ? 'Yes' : undefined}
|
grow={fillHeight ? 'Yes' : undefined}
|
||||||
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
|
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
|
||||||
>
|
>
|
||||||
@@ -256,10 +212,9 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
className={css.EditorTextareaScroll}
|
className={css.EditorTextareaScroll}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
style={scrollStyle}
|
style={scrollStyle}
|
||||||
size="300"
|
size="0"
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
hideTrack
|
hideTrack
|
||||||
ref={editableRef}
|
|
||||||
>
|
>
|
||||||
<Editable
|
<Editable
|
||||||
data-editable-name={editableName}
|
data-editable-name={editableName}
|
||||||
|
|||||||
@@ -82,13 +82,21 @@ function RenderEmoticonElement({
|
|||||||
const selected = useSelected();
|
const selected = useSelected();
|
||||||
const focused = useFocused();
|
const focused = useFocused();
|
||||||
|
|
||||||
|
// Void inline: attributes + contentEditable={false} on the same root, children
|
||||||
|
// as a sibling of the visual (not nested inside another non-editable span).
|
||||||
|
// Nesting {children} under an inner contentEditable={false} breaks Slate's
|
||||||
|
// DOM↔node map and crashes ReactEditor.focus after insert.
|
||||||
return (
|
return (
|
||||||
<span className={css.EmoticonBase} {...attributes}>
|
<span
|
||||||
|
{...attributes}
|
||||||
|
contentEditable={false}
|
||||||
|
className={css.EmoticonBase}
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={css.Emoticon({
|
className={css.Emoticon({
|
||||||
focus: selected && focused,
|
focus: selected && focused,
|
||||||
})}
|
})}
|
||||||
contentEditable={false}
|
|
||||||
>
|
>
|
||||||
{element.key.startsWith('mxc://') ? (
|
{element.key.startsWith('mxc://') ? (
|
||||||
<img
|
<img
|
||||||
@@ -99,8 +107,8 @@ function RenderEmoticonElement({
|
|||||||
) : (
|
) : (
|
||||||
element.key
|
element.key
|
||||||
)}
|
)}
|
||||||
{children}
|
|
||||||
</span>
|
</span>
|
||||||
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
|
|||||||
radii="300"
|
radii="300"
|
||||||
disabled={disableInline}
|
disabled={disableInline}
|
||||||
>
|
>
|
||||||
<Icon size="200" src={icon} />
|
<Icon size="100" src={icon} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -95,7 +95,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={icon} />
|
<Icon size="100" src={icon} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -152,7 +152,7 @@ export function HeadingBlockButton() {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.Heading1} />
|
<Icon size="100" src={Icons.Heading1} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -167,7 +167,7 @@ export function HeadingBlockButton() {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.Heading2} />
|
<Icon size="100" src={Icons.Heading2} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -182,7 +182,7 @@ export function HeadingBlockButton() {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.Heading3} />
|
<Icon size="100" src={Icons.Heading3} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -199,8 +199,8 @@ export function HeadingBlockButton() {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
|
<Icon size="100" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
|
||||||
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
<Icon size="100" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</PopOut>
|
</PopOut>
|
||||||
);
|
);
|
||||||
@@ -336,7 +336,7 @@ export function Toolbar() {
|
|||||||
radii="300"
|
radii="300"
|
||||||
disabled={disableInline || !!isAnyMarkActive(editor)}
|
disabled={disableInline || !!isAnyMarkActive(editor)}
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.Markdown} filled={isMarkdown} />
|
<Icon size="100" src={Icons.Markdown} filled={isMarkdown} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
|
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
import { BlockType, MarkType } from './types';
|
import { BlockType, MarkType } from './types';
|
||||||
import {
|
import {
|
||||||
CommandElement,
|
CommandElement,
|
||||||
@@ -206,6 +207,29 @@ export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
|||||||
if (withSpace) editor.insertText(' ');
|
if (withSpace) editor.insertText(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus the editor after React has committed DOM for recent transforms.
|
||||||
|
* Calling ReactEditor.focus immediately after insertNode/insertText (e.g. emoji +
|
||||||
|
* trailing space) races Slate's DOM map and throws:
|
||||||
|
* "Cannot resolve a DOM node from Slate node: {"text":" "}"
|
||||||
|
*/
|
||||||
|
export const safeFocusEditor = (editor: Editor) => {
|
||||||
|
const tryFocus = () => {
|
||||||
|
try {
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Defer past the current React commit; retry once if the DOM map isn't ready yet.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (tryFocus()) return;
|
||||||
|
setTimeout(tryFocus, 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface PointUntilCharOptions {
|
interface PointUntilCharOptions {
|
||||||
match: (char: string) => boolean;
|
match: (char: string) => boolean;
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
|
|||||||
? { width: pixelSize, height: pixelSize, ...style }
|
? { width: pixelSize, height: pixelSize, ...style }
|
||||||
: style
|
: style
|
||||||
}
|
}
|
||||||
strokeWidth={2}
|
// Lucide icons are stroke-based; solid fill turns faces (e.g. Smile) into blobs.
|
||||||
fill={fill ?? (filled ? 'currentColor' : 'none')}
|
// Emphasize selection with a heavier stroke instead.
|
||||||
|
strokeWidth={filled ? 2.75 : 2}
|
||||||
|
fill={fill ?? 'none'}
|
||||||
aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true}
|
aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { IconSize } from './types';
|
import type { IconSize } from './types';
|
||||||
|
|
||||||
|
/** App chrome icons; ~4% above the original folds scale after +30% then -20%. */
|
||||||
export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = {
|
export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = {
|
||||||
'50': 10,
|
'50': 10,
|
||||||
'100': 12,
|
'100': 13,
|
||||||
'200': 16,
|
'200': 17,
|
||||||
'300': 20,
|
'300': 21,
|
||||||
'400': 24,
|
'400': 25,
|
||||||
'500': 28,
|
'500': 29,
|
||||||
'600': 32,
|
'600': 34,
|
||||||
Inherit: undefined,
|
Inherit: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { config } from 'folds';
|
|||||||
|
|
||||||
export const Icon = recipe({
|
export const Icon = recipe({
|
||||||
base: {
|
base: {
|
||||||
display: 'inline-block',
|
display: 'block',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
verticalAlign: 'middle',
|
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ import {
|
|||||||
MATRIX_SPOILER_PROPERTY_NAME,
|
MATRIX_SPOILER_PROPERTY_NAME,
|
||||||
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
||||||
} from '../../../types/matrix/common';
|
} from '../../../types/matrix/common';
|
||||||
|
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
|
||||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||||
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||||
|
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
|
||||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||||
|
|
||||||
@@ -199,6 +201,20 @@ type MImageProps = {
|
|||||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||||
outlined?: boolean;
|
outlined?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveMediaBoxSize = (
|
||||||
|
mxcUrl: string,
|
||||||
|
w?: number,
|
||||||
|
h?: number
|
||||||
|
): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h);
|
||||||
|
|
||||||
|
/** Extra space after media so following text stays on the 24px ruled grid — applied outside the photo chrome */
|
||||||
|
const STATIONERY_LINE_PX = 24;
|
||||||
|
const snapStationeryGridPad = (height: number): number => {
|
||||||
|
const rem = height % STATIONERY_LINE_PX;
|
||||||
|
return rem === 0 ? 0 : STATIONERY_LINE_PX - rem;
|
||||||
|
};
|
||||||
|
|
||||||
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||||
const imgInfo = content?.info;
|
const imgInfo = content?.info;
|
||||||
const mxcUrl = content.file?.url ?? content.url;
|
const mxcUrl = content.file?.url ?? content.url;
|
||||||
@@ -206,9 +222,24 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
return <BrokenContent />;
|
return <BrokenContent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h);
|
||||||
|
const gridPad = snapStationeryGridPad(height);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined} transparent>
|
<StationeryMedia
|
||||||
<AttachmentBox>
|
outlined={outlined}
|
||||||
|
transparent
|
||||||
|
tiltSeed={mxcUrl}
|
||||||
|
gridPad={gridPad}
|
||||||
|
>
|
||||||
|
<AttachmentBox
|
||||||
|
style={{
|
||||||
|
width: toRem(width),
|
||||||
|
height: toRem(height),
|
||||||
|
['--media-h' as string]: `${height}px`,
|
||||||
|
}}
|
||||||
|
data-paarrot-media-height={height}
|
||||||
|
>
|
||||||
{renderImageContent({
|
{renderImageContent({
|
||||||
body: content.body || 'Image',
|
body: content.body || 'Image',
|
||||||
info: imgInfo,
|
info: imgInfo,
|
||||||
@@ -219,7 +250,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||||
})}
|
})}
|
||||||
</AttachmentBox>
|
</AttachmentBox>
|
||||||
</Attachment>
|
</StationeryMedia>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,9 +282,20 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = content.filename ?? content.body ?? 'Video';
|
const filename = content.filename ?? content.body ?? 'Video';
|
||||||
|
const { width, height } = resolveMediaBoxSize(
|
||||||
|
mxcUrl,
|
||||||
|
videoInfo.w ?? videoInfo.thumbnail_info?.w,
|
||||||
|
videoInfo.h ?? videoInfo.thumbnail_info?.h
|
||||||
|
);
|
||||||
|
const gridPad = snapStationeryGridPad(height);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined} transparent>
|
<StationeryMedia
|
||||||
|
outlined={outlined}
|
||||||
|
transparent
|
||||||
|
tiltSeed={mxcUrl}
|
||||||
|
gridPad={gridPad}
|
||||||
|
>
|
||||||
<AttachmentHeader>
|
<AttachmentHeader>
|
||||||
<FileHeader
|
<FileHeader
|
||||||
body={filename}
|
body={filename}
|
||||||
@@ -268,7 +310,13 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</AttachmentHeader>
|
</AttachmentHeader>
|
||||||
<AttachmentBox>
|
<AttachmentBox
|
||||||
|
style={{
|
||||||
|
width: toRem(width),
|
||||||
|
height: toRem(height),
|
||||||
|
}}
|
||||||
|
data-paarrot-media-height={height}
|
||||||
|
>
|
||||||
{renderVideoContent({
|
{renderVideoContent({
|
||||||
body: content.body || 'Video',
|
body: content.body || 'Video',
|
||||||
info: videoInfo,
|
info: videoInfo,
|
||||||
@@ -279,7 +327,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||||
})}
|
})}
|
||||||
</AttachmentBox>
|
</AttachmentBox>
|
||||||
</Attachment>
|
</StationeryMedia>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,12 +466,16 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
|
|||||||
return <MessageBrokenContent />;
|
return <MessageBrokenContent />;
|
||||||
}
|
}
|
||||||
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
|
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
|
||||||
|
const tapeAlt = hashStationerySeed(mxcUrl) % 2 === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AttachmentBox
|
<AttachmentBox
|
||||||
|
data-stationery-media=""
|
||||||
|
data-tape-alt={tapeAlt ? '' : undefined}
|
||||||
style={{
|
style={{
|
||||||
height: toRem(height < 48 ? 48 : height),
|
height: toRem(height < 48 ? 48 : height),
|
||||||
width: toRem(152),
|
width: toRem(152),
|
||||||
|
['--media-rot' as string]: stationeryMediaRot(mxcUrl),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderImageContent({
|
{renderImageContent({
|
||||||
|
|||||||
@@ -64,6 +64,33 @@ export const ReactionText = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const ReactionStack = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
gridTemplateColumns: '1fr',
|
||||||
|
gridTemplateRows: '1fr',
|
||||||
|
lineHeight: toRem(20),
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ReactionSticker = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: toRem(150),
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
lineHeight: toRem(20),
|
||||||
|
gridArea: '1 / 1',
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const ReactionImg = style([
|
export const ReactionImg = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import * as css from './Reaction.css';
|
|||||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
|
const MAX_STICKER_STACK = 4;
|
||||||
|
|
||||||
export const Reaction = as<
|
export const Reaction = as<
|
||||||
'button',
|
'button',
|
||||||
@@ -15,35 +18,101 @@ export const Reaction = as<
|
|||||||
reaction: string;
|
reaction: string;
|
||||||
useAuthentication?: boolean;
|
useAuthentication?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
|
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
|
||||||
<Box
|
const theme = useTheme();
|
||||||
as="button"
|
const stationery = isStationeryTheme(theme);
|
||||||
className={classNames(css.Reaction, className)}
|
const isCustomEmoji = reaction.startsWith('mxc://');
|
||||||
alignItems="Center"
|
const customSrc = isCustomEmoji
|
||||||
shrink="No"
|
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
||||||
gap="200"
|
: undefined;
|
||||||
{...props}
|
|
||||||
ref={ref}
|
const emoji = isCustomEmoji ? (
|
||||||
>
|
<img className={css.ReactionImg} src={customSrc} alt={reaction} />
|
||||||
<Text className={css.ReactionText} as="span" size="T400">
|
) : (
|
||||||
{reaction.startsWith('mxc://') ? (
|
<Text as="span" size="Inherit" truncate>
|
||||||
<img
|
{reaction}
|
||||||
className={css.ReactionImg}
|
</Text>
|
||||||
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
);
|
||||||
}
|
|
||||||
alt={reaction}
|
// Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
|
||||||
/>
|
if (!stationery) {
|
||||||
) : (
|
return (
|
||||||
<Text as="span" size="Inherit" truncate>
|
<Box
|
||||||
{reaction}
|
as="button"
|
||||||
|
className={classNames(css.Reaction, className)}
|
||||||
|
alignItems="Center"
|
||||||
|
shrink="No"
|
||||||
|
gap="200"
|
||||||
|
data-reaction=""
|
||||||
|
aria-label={`${reaction}, ${count}`}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Text className={css.ReactionText} as="span" size="T400">
|
||||||
|
{emoji}
|
||||||
|
</Text>
|
||||||
|
<Text as="span" size="T300" data-reaction-count="">
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
|
||||||
|
const showCount = count > 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
className={classNames(css.Reaction, className)}
|
||||||
|
alignItems="Center"
|
||||||
|
shrink="No"
|
||||||
|
gap="200"
|
||||||
|
data-reaction=""
|
||||||
|
data-reaction-stack={stackCount}
|
||||||
|
aria-label={`${reaction}, ${count}`}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<span className={css.ReactionStack} data-reaction-stack-layer="">
|
||||||
|
{Array.from({ length: stackCount }, (_, i) => {
|
||||||
|
const layer = stackCount - 1 - i;
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={layer}
|
||||||
|
className={css.ReactionSticker}
|
||||||
|
as="span"
|
||||||
|
size="T400"
|
||||||
|
data-reaction-sticker=""
|
||||||
|
style={{
|
||||||
|
['--sticker-i' as string]: layer,
|
||||||
|
zIndex: i + 1,
|
||||||
|
}}
|
||||||
|
aria-hidden={i < stackCount - 1 ? true : undefined}
|
||||||
|
>
|
||||||
|
{isCustomEmoji ? (
|
||||||
|
<img
|
||||||
|
className={css.ReactionImg}
|
||||||
|
src={customSrc}
|
||||||
|
alt={i === stackCount - 1 ? reaction : ''}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
{reaction}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{showCount && (
|
||||||
|
<Text as="span" size="T300" data-reaction-count="">
|
||||||
|
{count}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Box>
|
||||||
<Text as="span" size="T300">
|
);
|
||||||
{count}
|
});
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
));
|
|
||||||
|
|
||||||
type ReactionTooltipMsgProps = {
|
type ReactionTooltipMsgProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|||||||
@@ -14,29 +14,54 @@ import { useRoomEvent } from '../../hooks/useRoomEvent';
|
|||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
import { STATIONERY_NAME_INK } from '../../utils/paperSafeInk';
|
||||||
|
|
||||||
type ReplyLayoutProps = {
|
type ReplyLayoutProps = {
|
||||||
userColor?: string;
|
userColor?: string;
|
||||||
username?: ReactNode;
|
username?: ReactNode;
|
||||||
};
|
};
|
||||||
export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
||||||
({ username, userColor, className, children, ...props }, ref) => (
|
({ username, userColor, className, children, ...props }, ref) => {
|
||||||
<Box
|
const theme = useTheme();
|
||||||
className={classNames(css.Reply, className)}
|
const isStationery = isStationeryTheme(theme);
|
||||||
alignItems="Center"
|
|
||||||
gap="100"
|
return (
|
||||||
{...props}
|
<Box
|
||||||
ref={ref}
|
className={classNames(css.Reply, className)}
|
||||||
>
|
alignItems="Center"
|
||||||
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
|
gap="100"
|
||||||
<Icon size="100" src={Icons.ReplyArrow} />
|
{...props}
|
||||||
{username}
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={
|
||||||
|
isStationery
|
||||||
|
? {
|
||||||
|
color: STATIONERY_NAME_INK,
|
||||||
|
maxWidth: toRem(200),
|
||||||
|
...(userColor
|
||||||
|
? {
|
||||||
|
['--username-accent' as string]: userColor,
|
||||||
|
textDecorationColor: userColor,
|
||||||
|
}
|
||||||
|
: null),
|
||||||
|
}
|
||||||
|
: { color: userColor, maxWidth: toRem(200) }
|
||||||
|
}
|
||||||
|
data-username-accent={isStationery && userColor ? '' : undefined}
|
||||||
|
alignItems="Center"
|
||||||
|
shrink="No"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.ReplyArrow} />
|
||||||
|
{username}
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" className={css.ReplyContent}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" className={css.ReplyContent}>
|
);
|
||||||
{children}
|
}
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||||
|
|||||||
293
src/app/components/message/StationeryMedia.tsx
Normal file
293
src/app/components/message/StationeryMedia.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
|
CSSProperties,
|
||||||
|
MouseEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { Attachment } from './attachment';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
|
type Corner = 'tl' | 'tr' | 'bl' | 'br';
|
||||||
|
|
||||||
|
type StationeryMediaProps = ComponentProps<typeof Attachment> & {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Stable id (e.g. mxc URL) so tilt/tape don't reshuffle on timeline re-renders */
|
||||||
|
tiltSeed?: string;
|
||||||
|
/** Invisible bottom spacer (px) so following text stays on the ruled grid */
|
||||||
|
gridPad?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CORNERS: { id: Corner; x: 0 | 1; y: 0 | 1 }[] = [
|
||||||
|
{ id: 'tl', x: 0, y: 0 },
|
||||||
|
{ id: 'tr', x: 1, y: 0 },
|
||||||
|
{ id: 'bl', x: 0, y: 1 },
|
||||||
|
{ id: 'br', x: 1, y: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEDIA_ROTS = [-0.9, 0.7, -0.5, 1.0, -1.1, 0.8, -0.4, 0.9] as const;
|
||||||
|
|
||||||
|
export function hashStationerySeed(seed: string): number {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < seed.length; i += 1) {
|
||||||
|
h ^= seed.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stationeryMediaRot(seed: string): string {
|
||||||
|
return `${MEDIA_ROTS[hashStationerySeed(seed) % MEDIA_ROTS.length]}deg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold sits halfway between corner and cursor; flap hinges on that fold.
|
||||||
|
* `keep` = remaining photo; `flap` = lifted triangle.
|
||||||
|
*/
|
||||||
|
function cornerPeelPaths(
|
||||||
|
id: Corner,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
ux: number,
|
||||||
|
uy: number,
|
||||||
|
size: number
|
||||||
|
): {
|
||||||
|
keep: string;
|
||||||
|
flap: string;
|
||||||
|
origin: string;
|
||||||
|
axisX: number;
|
||||||
|
axisY: number;
|
||||||
|
hingeSign: number;
|
||||||
|
} {
|
||||||
|
const eps = 0.0001;
|
||||||
|
// Fold line through the midpoint on corner→cursor, ⊥ to that ray
|
||||||
|
const px = cx + ux * size;
|
||||||
|
const py = cy + uy * size;
|
||||||
|
|
||||||
|
let ax = 0;
|
||||||
|
let ay = 0;
|
||||||
|
let bx = 0;
|
||||||
|
let by = 0;
|
||||||
|
|
||||||
|
if (id === 'tr') {
|
||||||
|
ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w);
|
||||||
|
ay = 0;
|
||||||
|
bx = w;
|
||||||
|
by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h);
|
||||||
|
} else if (id === 'tl') {
|
||||||
|
ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w);
|
||||||
|
ay = 0;
|
||||||
|
bx = 0;
|
||||||
|
by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h);
|
||||||
|
} else if (id === 'br') {
|
||||||
|
ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w);
|
||||||
|
ay = h;
|
||||||
|
bx = w;
|
||||||
|
by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h);
|
||||||
|
} else {
|
||||||
|
ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w);
|
||||||
|
ay = h;
|
||||||
|
bx = 0;
|
||||||
|
by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
let keep: string;
|
||||||
|
if (id === 'tr') {
|
||||||
|
keep = `polygon(0 0, ${ax}px 0, ${w}px ${by}px, ${w}px ${h}px, 0 ${h}px)`;
|
||||||
|
} else if (id === 'tl') {
|
||||||
|
keep = `polygon(${ax}px 0, ${w}px 0, ${w}px ${h}px, 0 ${h}px, 0 ${by}px)`;
|
||||||
|
} else if (id === 'br') {
|
||||||
|
keep = `polygon(0 0, ${w}px 0, ${w}px ${by}px, ${ax}px ${h}px, 0 ${h}px)`;
|
||||||
|
} else {
|
||||||
|
keep = `polygon(0 0, ${w}px 0, ${w}px ${h}px, ${ax}px ${h}px, 0 ${by}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flap = `polygon(${cx}px ${cy}px, ${ax}px ${ay}px, ${bx}px ${by}px)`;
|
||||||
|
|
||||||
|
// Hinge on the fold (midpoint), axis along the fold edge
|
||||||
|
const midX = (ax + bx) / 2;
|
||||||
|
const midY = (ay + by) / 2;
|
||||||
|
const fdx = bx - ax;
|
||||||
|
const fdy = by - ay;
|
||||||
|
const flen = Math.hypot(fdx, fdy) || 1;
|
||||||
|
const axisX = fdx / flen;
|
||||||
|
const axisY = fdy / flen;
|
||||||
|
// Sign so the corner swings up over the fold (right-hand rule)
|
||||||
|
const cross = fdx * (cy - midY) - fdy * (cx - midX);
|
||||||
|
const hingeSign = cross >= 0 ? 1 : -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
keep,
|
||||||
|
flap,
|
||||||
|
origin: `${midX}px ${midY}px`,
|
||||||
|
axisX,
|
||||||
|
axisY,
|
||||||
|
hingeSign,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stationery-theme image chrome: clear corner tape + a mouse-driven peel
|
||||||
|
* that shows a duplicated slice of the photo lifting toward the cursor.
|
||||||
|
*/
|
||||||
|
export function StationeryMedia({
|
||||||
|
children,
|
||||||
|
tiltSeed = 'stationery',
|
||||||
|
gridPad = 0,
|
||||||
|
style,
|
||||||
|
...attachmentProps
|
||||||
|
}: StationeryMediaProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isStationery = isStationeryTheme(theme);
|
||||||
|
const peelRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const tiltStyle = useMemo((): CSSProperties => {
|
||||||
|
return {
|
||||||
|
...(style as CSSProperties | undefined),
|
||||||
|
// Margin sits outside the paper chrome — snaps the grid without a white bar
|
||||||
|
...(isStationery && gridPad > 0 ? { marginBottom: `${gridPad}px` } : null),
|
||||||
|
['--media-rot' as string]: stationeryMediaRot(tiltSeed),
|
||||||
|
};
|
||||||
|
}, [tiltSeed, style, isStationery, gridPad]);
|
||||||
|
|
||||||
|
const tapeAlt = useMemo(() => hashStationerySeed(tiltSeed) % 2 === 1, [tiltSeed]);
|
||||||
|
// Default tape covers TL; alt covers TR — peel only the free top corner
|
||||||
|
const peelCorner = useMemo(
|
||||||
|
() => (tapeAlt ? CORNERS.find((c) => c.id === 'tl')! : CORNERS.find((c) => c.id === 'tr')!),
|
||||||
|
[tapeAlt]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearPeel = useCallback((host: HTMLElement) => {
|
||||||
|
const peel = peelRef.current;
|
||||||
|
const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null;
|
||||||
|
if (peel) {
|
||||||
|
// Hide flap first (no opacity tween) so restoring the photo can't double-draw
|
||||||
|
peel.style.transition = 'none';
|
||||||
|
peel.style.opacity = '0';
|
||||||
|
peel.style.setProperty('--peel-hinge', '0deg');
|
||||||
|
peel.style.clipPath = '';
|
||||||
|
}
|
||||||
|
if (target) target.style.clipPath = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncPeel = useCallback(
|
||||||
|
(host: HTMLElement, clientX: number, clientY: number) => {
|
||||||
|
const peel = peelRef.current;
|
||||||
|
if (!peel) return;
|
||||||
|
|
||||||
|
const rect = host.getBoundingClientRect();
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
|
||||||
|
const best = peelCorner;
|
||||||
|
const cx = best.x * w;
|
||||||
|
const cy = best.y * h;
|
||||||
|
const dist = Math.hypot(cx - x, cy - y);
|
||||||
|
const maxReach = Math.min(w, h) * 0.55;
|
||||||
|
|
||||||
|
// Fold halfway between corner and cursor; fade when leaving the corner zone
|
||||||
|
let size = 0;
|
||||||
|
let amount = 0;
|
||||||
|
if (dist > 8 && dist <= maxReach) {
|
||||||
|
size = dist * 0.5;
|
||||||
|
amount = Math.min(1, size / (Math.min(w, h) * 0.22));
|
||||||
|
} else if (dist > maxReach && dist < maxReach * 1.35) {
|
||||||
|
const fade = 1 - (dist - maxReach) / (maxReach * 0.35);
|
||||||
|
size = maxReach * 0.5 * Math.max(0, fade);
|
||||||
|
amount = Math.max(0, fade);
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = host.querySelector('img');
|
||||||
|
const url = img?.currentSrc || img?.src || '';
|
||||||
|
|
||||||
|
// Unit vector from corner → cursor (triangle aims this way)
|
||||||
|
let ux = x - cx;
|
||||||
|
let uy = y - cy;
|
||||||
|
if (best.id === 'tr') {
|
||||||
|
ux = Math.min(-0.05, ux);
|
||||||
|
uy = Math.max(0.05, uy);
|
||||||
|
} else {
|
||||||
|
ux = Math.max(0.05, ux);
|
||||||
|
uy = Math.max(0.05, uy);
|
||||||
|
}
|
||||||
|
const len = Math.hypot(ux, uy) || 1;
|
||||||
|
ux /= len;
|
||||||
|
uy /= len;
|
||||||
|
|
||||||
|
peel.dataset.corner = best.id;
|
||||||
|
if (url) {
|
||||||
|
peel.style.setProperty('--peel-img', `url(${JSON.stringify(url)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null;
|
||||||
|
if (target && amount > 0.06 && size > 6) {
|
||||||
|
const { keep, flap, origin, axisX, axisY, hingeSign } = cornerPeelPaths(
|
||||||
|
best.id,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
ux,
|
||||||
|
uy,
|
||||||
|
size
|
||||||
|
);
|
||||||
|
target.style.clipPath = keep;
|
||||||
|
peel.style.clipPath = flap;
|
||||||
|
peel.style.transformOrigin = origin;
|
||||||
|
peel.style.setProperty('--peel-axis-x', String(axisX));
|
||||||
|
peel.style.setProperty('--peel-axis-y', String(axisY));
|
||||||
|
peel.style.setProperty('--peel-hinge', `${hingeSign * amount * 52}deg`);
|
||||||
|
peel.style.setProperty('--peel-tip-x', `${cx}px`);
|
||||||
|
peel.style.setProperty('--peel-tip-y', `${cy}px`);
|
||||||
|
peel.style.setProperty('--peel-grad-r', `${Math.max(size * 1.15, 12)}px`);
|
||||||
|
peel.style.opacity = '1';
|
||||||
|
} else {
|
||||||
|
clearPeel(host);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[peelCorner, clearPeel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMove: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnter: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
clearPeel(evt.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isStationery) {
|
||||||
|
return (
|
||||||
|
<Attachment style={style} {...attachmentProps}>
|
||||||
|
{children}
|
||||||
|
</Attachment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Attachment
|
||||||
|
{...attachmentProps}
|
||||||
|
style={tiltStyle}
|
||||||
|
data-stationery-media=""
|
||||||
|
data-tape-alt={tapeAlt ? '' : undefined}
|
||||||
|
onMouseEnter={handleEnter}
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
onMouseLeave={handleLeave}
|
||||||
|
>
|
||||||
|
<span ref={peelRef} data-stationery-peel="" aria-hidden="true" />
|
||||||
|
{children}
|
||||||
|
</Attachment>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
|||||||
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||||
import { Icon, Icons } from '../../icons';
|
import { Icon, Icons } from '../../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { BlurhashCanvas } from 'react-blurhash';
|
import { Blurhash } from 'react-blurhash';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
||||||
@@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
|
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches media with authentication headers and returns a blob URL.
|
* Fetches media with authentication headers and returns a blob URL.
|
||||||
@@ -68,7 +69,7 @@ type RenderImageProps = {
|
|||||||
alt: string;
|
alt: string;
|
||||||
title: string;
|
title: string;
|
||||||
src: string;
|
src: string;
|
||||||
onLoad: () => void;
|
onLoad: (event?: React.SyntheticEvent<HTMLImageElement>) => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
tabIndex: number;
|
tabIndex: number;
|
||||||
@@ -105,12 +106,23 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
const eventBlurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
const cachedBlurHash = getMediaBlurHash(url);
|
||||||
|
const blurHash = eventBlurHash ?? cachedBlurHash;
|
||||||
|
const cachedDims = getMediaDimensions(url);
|
||||||
|
const blurResolutionX = 32;
|
||||||
|
const ratioW = info?.w || cachedDims?.w;
|
||||||
|
const ratioH = info?.h || cachedDims?.h;
|
||||||
|
const blurResolutionY =
|
||||||
|
ratioW && ratioH && ratioW > 0
|
||||||
|
? Math.max(1, Math.round(blurResolutionX * (ratioH / ratioW)))
|
||||||
|
: 32;
|
||||||
|
|
||||||
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 [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
const [showPlaceholder, setShowPlaceholder] = useState(true);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -139,6 +151,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setLoad(false);
|
setLoad(false);
|
||||||
setError(true);
|
setError(true);
|
||||||
|
setShowPlaceholder(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
@@ -150,8 +163,34 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
if (autoPlay) loadSrc();
|
if (autoPlay) loadSrc();
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, loadSrc]);
|
||||||
|
|
||||||
|
// Keep blurhash under the fade, then remove it once the image is fully opaque.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!load) {
|
||||||
|
setShowPlaceholder(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
{showPlaceholder &&
|
||||||
|
(typeof blurHash === 'string' ? (
|
||||||
|
<div className={css.BlurhashPlaceholder}>
|
||||||
|
<Blurhash
|
||||||
|
hash={blurHash}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
resolutionX={blurResolutionX}
|
||||||
|
resolutionY={blurResolutionY}
|
||||||
|
punch={1.2}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={css.MediaSkeleton} />
|
||||||
|
))}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
@@ -177,15 +216,6 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
{typeof blurHash === 'string' && !load && (
|
|
||||||
<BlurhashCanvas
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
hash={blurHash}
|
|
||||||
punch={1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Button
|
<Button
|
||||||
@@ -201,12 +231,26 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
<Box
|
||||||
|
className={classNames(
|
||||||
|
css.AbsoluteContainer,
|
||||||
|
css.MediaFadeIn,
|
||||||
|
load && css.MediaFadeInLoaded,
|
||||||
|
blurred && css.Blur
|
||||||
|
)}
|
||||||
|
>
|
||||||
{renderImage({
|
{renderImage({
|
||||||
alt: body,
|
alt: body,
|
||||||
title: body,
|
title: body,
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
onLoad: handleLoad,
|
onLoad: (e?: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
const img = e?.currentTarget;
|
||||||
|
if (img?.naturalWidth && img?.naturalHeight) {
|
||||||
|
setMediaDimensions(url, img.naturalWidth, img.naturalHeight);
|
||||||
|
}
|
||||||
|
if (img) rememberMediaBlurHash(url, img);
|
||||||
|
handleLoad();
|
||||||
|
},
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
onClick: () => setViewer(true),
|
onClick: () => setViewer(true),
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
@@ -248,7 +292,8 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
)}
|
)}
|
||||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||||
!load &&
|
!load &&
|
||||||
!blurred && (
|
!blurred &&
|
||||||
|
typeof blurHash !== 'string' && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" />
|
<Spinner variant="Secondary" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
|||||||
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||||
import { Icon, Icons } from '../../icons';
|
import { Icon, Icons } from '../../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { BlurhashCanvas } from 'react-blurhash';
|
import { Blurhash } from 'react-blurhash';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -34,7 +35,7 @@ type RenderViewerProps = {
|
|||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
src: string;
|
src: string;
|
||||||
onLoadedMetadata: () => void;
|
onLoadedMetadata: (event?: React.SyntheticEvent<HTMLVideoElement>) => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
autoPlay: boolean;
|
autoPlay: boolean;
|
||||||
controls: boolean;
|
controls: boolean;
|
||||||
@@ -74,12 +75,23 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
const eventBlurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
const cachedBlurHash = getMediaBlurHash(url);
|
||||||
|
const blurHash = eventBlurHash ?? cachedBlurHash;
|
||||||
|
const cachedDims = getMediaDimensions(url);
|
||||||
|
const blurResolutionX = 32;
|
||||||
|
const blurW = info.w ?? info.thumbnail_info?.w ?? cachedDims?.w;
|
||||||
|
const blurH = info.h ?? info.thumbnail_info?.h ?? cachedDims?.h;
|
||||||
|
const blurResolutionY =
|
||||||
|
blurW && blurH && blurW > 0
|
||||||
|
? Math.max(1, Math.round(blurResolutionX * (blurH / blurW)))
|
||||||
|
: 32;
|
||||||
|
|
||||||
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 [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
const [showPlaceholder, setShowPlaceholder] = useState(true);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -95,12 +107,17 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = (video?: HTMLVideoElement) => {
|
||||||
|
if (video?.videoWidth && video?.videoHeight) {
|
||||||
|
setMediaDimensions(url, video.videoWidth, video.videoHeight);
|
||||||
|
}
|
||||||
|
if (video) rememberMediaBlurHash(url, video);
|
||||||
setLoad(true);
|
setLoad(true);
|
||||||
};
|
};
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setLoad(false);
|
setLoad(false);
|
||||||
setError(true);
|
setError(true);
|
||||||
|
setShowPlaceholder(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
@@ -112,8 +129,33 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
if (autoPlay) loadSrc();
|
if (autoPlay) loadSrc();
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, loadSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!load) {
|
||||||
|
setShowPlaceholder(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
{showPlaceholder &&
|
||||||
|
(typeof blurHash === 'string' ? (
|
||||||
|
<div className={css.BlurhashPlaceholder}>
|
||||||
|
<Blurhash
|
||||||
|
hash={blurHash}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
resolutionX={blurResolutionX}
|
||||||
|
resolutionY={blurResolutionY}
|
||||||
|
punch={1.2}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={css.MediaSkeleton} />
|
||||||
|
))}
|
||||||
{renderViewer && srcState.status === AsyncStatus.Success && (
|
{renderViewer && srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
@@ -136,15 +178,6 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
{typeof blurHash === 'string' && !load && (
|
|
||||||
<BlurhashCanvas
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
hash={blurHash}
|
|
||||||
punch={1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{renderThumbnail && !load && (
|
{renderThumbnail && !load && (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
|
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
|
||||||
@@ -169,11 +202,20 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
<Box
|
||||||
|
className={classNames(
|
||||||
|
css.AbsoluteContainer,
|
||||||
|
css.MediaFadeIn,
|
||||||
|
load && css.MediaFadeInLoaded,
|
||||||
|
blurred && css.Blur
|
||||||
|
)}
|
||||||
|
>
|
||||||
{renderVideo({
|
{renderVideo({
|
||||||
title: body,
|
title: body,
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
onLoadedMetadata: handleLoad,
|
onLoadedMetadata: (e?: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
handleLoad(e?.currentTarget);
|
||||||
|
},
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
controls: true,
|
controls: true,
|
||||||
@@ -213,7 +255,8 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
)}
|
)}
|
||||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||||
!load &&
|
!load &&
|
||||||
!blurred && (
|
!blurred &&
|
||||||
|
typeof blurHash !== 'string' && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" />
|
<Spinner variant="Secondary" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, config } from 'folds';
|
import { DefaultReset, color, config } from 'folds';
|
||||||
|
|
||||||
export const RelativeBase = style([
|
export const RelativeBase = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
borderRadius: '7px',
|
borderRadius: '7px',
|
||||||
@@ -16,9 +18,13 @@ export const RelativeBase = style([
|
|||||||
export const AbsoluteContainer = style([
|
export const AbsoluteContainer = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
borderRadius: 'inherit',
|
borderRadius: 'inherit',
|
||||||
@@ -34,6 +40,7 @@ export const AbsoluteFooter = style([
|
|||||||
bottom: config.space.S100,
|
bottom: config.space.S100,
|
||||||
left: config.space.S100,
|
left: config.space.S100,
|
||||||
right: config.space.S100,
|
right: config.space.S100,
|
||||||
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -43,3 +50,67 @@ export const Blur = style([
|
|||||||
filter: 'blur(44px)',
|
filter: 'blur(44px)',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const shimmer = keyframes({
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '200% 0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
backgroundPosition: '-200% 0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Soft fallback when no blurhash is available. */
|
||||||
|
export const MediaSkeleton = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundImage: `linear-gradient(
|
||||||
|
90deg,
|
||||||
|
${color.SurfaceVariant.Container} 0%,
|
||||||
|
${color.SurfaceVariant.ContainerHover} 40%,
|
||||||
|
${color.SurfaceVariant.Container} 80%
|
||||||
|
)`,
|
||||||
|
backgroundSize: '200% 100%',
|
||||||
|
animation: `${shimmer} 1.4s ease-in-out infinite`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blurhashFadeIn = keyframes({
|
||||||
|
from: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Soft blurhash layer that fills the reserved media box. */
|
||||||
|
export const BlurhashPlaceholder = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
zIndex: 0,
|
||||||
|
// Soften the decode so it reads as color blobs; slight scale hides blur edges.
|
||||||
|
filter: 'blur(14px)',
|
||||||
|
transform: 'scale(1.06)',
|
||||||
|
opacity: 0,
|
||||||
|
animation: `${blurhashFadeIn} 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Media starts invisible and fades in once loaded over the placeholder. */
|
||||||
|
export const MediaFadeIn = style({
|
||||||
|
zIndex: 1,
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaFadeInLoaded = style({
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { CSSProperties, useMemo } from 'react';
|
||||||
import { Text, as } from 'folds';
|
import { Text, as } from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as css from './layout.css';
|
import * as css from './layout.css';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../../hooks/useTheme';
|
||||||
|
import { STATIONERY_NAME_INK } from '../../../utils/paperSafeInk';
|
||||||
|
|
||||||
export const MessageBase = as<'div', css.MessageBaseVariants>(
|
export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||||
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
|
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
|
||||||
@@ -10,6 +12,7 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
|
|||||||
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
|
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
data-message-collapsed={collapse ? '' : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -17,12 +20,45 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
|
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
|
||||||
<span className={classNames(css.AvatarBase, className)} {...props} ref={ref} />
|
<span
|
||||||
|
className={classNames(css.AvatarBase, className)}
|
||||||
|
data-message-avatar=""
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
|
export const Username = as<'span'>(({ as: AsUsername = 'span', className, style, ...props }, ref) => {
|
||||||
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
|
const theme = useTheme();
|
||||||
));
|
const isStationery = isStationeryTheme(theme);
|
||||||
|
const userColor =
|
||||||
|
typeof (style as CSSProperties | undefined)?.color === 'string'
|
||||||
|
? ((style as CSSProperties).color as string)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const safeStyle = useMemo((): CSSProperties | undefined => {
|
||||||
|
if (!isStationery) return style as CSSProperties | undefined;
|
||||||
|
return {
|
||||||
|
...(style as CSSProperties | undefined),
|
||||||
|
// Name in ink; keep their color as an accent underline
|
||||||
|
color: STATIONERY_NAME_INK,
|
||||||
|
...(userColor
|
||||||
|
? { ['--username-accent' as string]: userColor, textDecorationColor: userColor }
|
||||||
|
: null),
|
||||||
|
};
|
||||||
|
}, [style, isStationery, userColor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsUsername
|
||||||
|
className={classNames(css.Username, className)}
|
||||||
|
data-message-username=""
|
||||||
|
data-username-accent={isStationery && userColor ? '' : undefined}
|
||||||
|
style={safeStyle}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
|
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
|
||||||
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
|
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
|
||||||
@@ -32,10 +68,12 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
|
|||||||
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
|
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
|
||||||
<Text
|
<Text
|
||||||
as={asComp}
|
as={asComp}
|
||||||
size="T400"
|
size={jumboEmoji ? 'Inherit' : 'T400'}
|
||||||
priority={notice ? '300' : '400'}
|
priority={notice ? '300' : '400'}
|
||||||
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
||||||
data-allow-text-selection="true"
|
data-allow-text-selection="true"
|
||||||
|
data-message-body=""
|
||||||
|
data-jumbo-emoji={jumboEmoji ? '' : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
||||||
|
import * as htmlCss from '../../../styles/CustomHtml.css';
|
||||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
import { DefaultReset, color, config, toRem } from 'folds';
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ export const MessageBase = recipe({
|
|||||||
collapse: {
|
collapse: {
|
||||||
true: {
|
true: {
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
|
paddingTop: config.space.S0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
autoCollapse: {
|
autoCollapse: {
|
||||||
@@ -202,6 +204,14 @@ export const Username = style({
|
|||||||
'button&:hover, button&:focus-visible': {
|
'button&:hover, button&:focus-visible': {
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
},
|
},
|
||||||
|
// Stationery uses a colored border-bottom accent instead
|
||||||
|
'html.stationery &, body.stationery &': {
|
||||||
|
overflow: 'visible',
|
||||||
|
overflowY: 'visible',
|
||||||
|
},
|
||||||
|
'body.stationery button&:hover, body.stationery button&:focus-visible': {
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,6 +219,8 @@ export const UsernameBold = style({
|
|||||||
fontWeight: 550,
|
fontWeight: 550,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const jumboEmojiSize = toRem(70);
|
||||||
|
|
||||||
export const MessageTextBody = recipe({
|
export const MessageTextBody = recipe({
|
||||||
base: {
|
base: {
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
@@ -226,8 +238,11 @@ export const MessageTextBody = recipe({
|
|||||||
},
|
},
|
||||||
jumboEmoji: {
|
jumboEmoji: {
|
||||||
true: {
|
true: {
|
||||||
fontSize: '1.504em',
|
fontSize: jumboEmojiSize,
|
||||||
lineHeight: '1.4962em',
|
lineHeight: 1,
|
||||||
|
overflow: 'visible',
|
||||||
|
overflowY: 'visible',
|
||||||
|
paddingBottom: config.space.S200,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emote: {
|
emote: {
|
||||||
@@ -240,3 +255,29 @@ export const MessageTextBody = recipe({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
|
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
|
||||||
|
|
||||||
|
const jumboEmojiClass = MessageTextBody.classNames.variants.jumboEmoji.true;
|
||||||
|
|
||||||
|
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
|
||||||
|
height: '1em',
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
|
||||||
|
fontSize: '1em',
|
||||||
|
height: '1em',
|
||||||
|
minWidth: '1em',
|
||||||
|
lineHeight: 1,
|
||||||
|
position: 'static',
|
||||||
|
top: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonImg}`, {
|
||||||
|
height: '1em',
|
||||||
|
width: '1em',
|
||||||
|
maxHeight: 'none',
|
||||||
|
objectFit: 'contain',
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,5 +7,10 @@ type NavCategoryProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
|
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
|
||||||
<div className={classNames(css.NavCategory, className)} {...props} ref={ref} />
|
<div
|
||||||
|
className={classNames(css.NavCategory, className)}
|
||||||
|
data-nav-category=""
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const NavItem = as<
|
|||||||
<AsNavItem
|
<AsNavItem
|
||||||
className={classNames(css.NavItem({ variant, radii }), className)}
|
className={classNames(css.NavItem({ variant, radii }), className)}
|
||||||
data-highlight={highlight}
|
data-highlight={highlight}
|
||||||
|
data-nav-item=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { ContainerColor } from '../../styles/ContainerColor.css';
|
|||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||||
|
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
type PageRootProps = {
|
type PageRootProps = {
|
||||||
nav: ReactNode;
|
nav: ReactNode;
|
||||||
@@ -13,6 +15,7 @@ type PageRootProps = {
|
|||||||
|
|
||||||
export function PageRoot({ nav, children }: PageRootProps) {
|
export function PageRoot({ nav, children }: PageRootProps) {
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
const showCompactMaster = useShowCompactMasterView();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||||
@@ -20,7 +23,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
|||||||
{screenSize !== ScreenSize.Mobile && (
|
{screenSize !== ScreenSize.Mobile && (
|
||||||
<Line variant="Background" size="300" direction="Vertical" />
|
<Line variant="Background" size="300" direction="Vertical" />
|
||||||
)}
|
)}
|
||||||
{children}
|
{!showCompactMaster && children}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,6 +44,7 @@ export function PageNav({
|
|||||||
grow={isMobile ? 'Yes' : undefined}
|
grow={isMobile ? 'Yes' : undefined}
|
||||||
className={classNames(css.PageNav({ size }), className)}
|
className={classNames(css.PageNav({ size }), className)}
|
||||||
shrink={isMobile ? 'Yes' : 'No'}
|
shrink={isMobile ? 'Yes' : 'No'}
|
||||||
|
data-page-nav=""
|
||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
{children}
|
{children}
|
||||||
@@ -56,6 +60,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
|||||||
className={classNames(css.PageNavHeader({ outlined }), className)}
|
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
size="600"
|
size="600"
|
||||||
|
data-folder-tab="nav"
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -71,13 +76,16 @@ export function PageNavContent({
|
|||||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const hideScrollbar = isStationeryTheme(theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Scroll
|
<Scroll
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
direction="Vertical"
|
direction="Vertical"
|
||||||
size="300"
|
size={hideScrollbar ? '0' : '300'}
|
||||||
hideTrack
|
hideTrack
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
{...scrollProps}
|
{...scrollProps}
|
||||||
@@ -93,6 +101,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
|
|||||||
grow="Yes"
|
grow="Yes"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||||
|
data-page=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -104,6 +113,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
|
|||||||
as="header"
|
as="header"
|
||||||
size="600"
|
size="600"
|
||||||
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||||
|
data-folder-tab-bar=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,35 +1,76 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { CSSProperties, ReactElement, cloneElement, isValidElement } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as css from './styles.css';
|
import { config } from 'folds';
|
||||||
import { Presence } from '../../hooks/useUserPresence';
|
import { Presence } from '../../hooks/useUserPresence';
|
||||||
|
|
||||||
type PresenceAvatarProps = {
|
type PresenceAvatarProps = {
|
||||||
/** The presence state to display */
|
|
||||||
presence?: Presence;
|
presence?: Presence;
|
||||||
/** The avatar element to wrap */
|
children: ReactElement;
|
||||||
children: ReactNode;
|
|
||||||
/** Additional className */
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PRESENCE_COLOR: Record<Presence, string> = {
|
||||||
|
[Presence.Online]: '#38842b',
|
||||||
|
[Presence.Unavailable]: '#959e30',
|
||||||
|
[Presence.Offline]: '#454545',
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowStyle: CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
lineHeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const faceStyle: CSSProperties = {
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
// Keep the right side rounded to match size-200 nav avatars.
|
||||||
|
borderTopRightRadius: config.radii.R400,
|
||||||
|
borderBottomRightRadius: config.radii.R400,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps an avatar with a left-side presence indicator border.
|
* Presence strip as a real sibling (not ::before).
|
||||||
* Shows green for online, yellow/orange for unavailable/away, grey for offline.
|
* Folds Avatar uses overflow:hidden, which clips outside ::before bars.
|
||||||
*/
|
*/
|
||||||
export function PresenceAvatar({ presence, children, className }: PresenceAvatarProps) {
|
export function PresenceAvatar({
|
||||||
if (!presence) {
|
presence = Presence.Offline,
|
||||||
return <>{children}</>;
|
children,
|
||||||
}
|
className,
|
||||||
|
}: PresenceAvatarProps) {
|
||||||
|
const stripColor = PRESENCE_COLOR[presence] ?? PRESENCE_COLOR[Presence.Offline];
|
||||||
|
|
||||||
|
const face = isValidElement(children)
|
||||||
|
? cloneElement(children, {
|
||||||
|
className: classNames((children.props as { className?: string }).className),
|
||||||
|
style: {
|
||||||
|
...((children.props as { style?: CSSProperties }).style ?? {}),
|
||||||
|
...faceStyle,
|
||||||
|
},
|
||||||
|
// Avoid the folds radii shorthand fighting the square-left corners.
|
||||||
|
radii: '0',
|
||||||
|
} as Partial<unknown>)
|
||||||
|
: children;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={className} style={rowStyle} data-presence={presence}>
|
||||||
className={classNames(css.PresenceAvatarContainer, css.PresenceIndicator, className, {
|
<span
|
||||||
[css.PresenceOnline]: presence === Presence.Online,
|
aria-hidden
|
||||||
[css.PresenceUnavailable]: presence === Presence.Unavailable,
|
style={{
|
||||||
[css.PresenceOffline]: presence === Presence.Offline,
|
display: 'block',
|
||||||
})}
|
width: 4,
|
||||||
>
|
minWidth: 4,
|
||||||
{children}
|
flex: '0 0 4px',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
// Match folds Avatar size="200" so the strip can't collapse to 0 height.
|
||||||
|
minHeight: config.size.X200,
|
||||||
|
borderRadius: '5px 0 0 5px',
|
||||||
|
backgroundColor: stripColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{face}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { config, color } from 'folds';
|
import { config } from 'folds';
|
||||||
|
|
||||||
export const AvatarPresence = style({
|
export const AvatarPresence = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -20,41 +20,3 @@ export const AvatarPresenceBadge = style({
|
|||||||
borderRadius: config.radii.Pill,
|
borderRadius: config.radii.Pill,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PresenceAvatarContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
position: 'relative',
|
|
||||||
flexShrink: 0,
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
'::before': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: '10%',
|
|
||||||
height: '80%',
|
|
||||||
width: '3px',
|
|
||||||
borderTopRightRadius: '2px',
|
|
||||||
borderBottomRightRadius: '2px',
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PresenceIndicator = style({});
|
|
||||||
|
|
||||||
export const PresenceOnline = style({
|
|
||||||
'::before': {
|
|
||||||
backgroundColor: color.Success.Main,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PresenceUnavailable = style({
|
|
||||||
'::before': {
|
|
||||||
backgroundColor: color.Warning.Main,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PresenceOffline = style({
|
|
||||||
'::before': {
|
|
||||||
backgroundColor: color.Secondary.Main,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Overlay,
|
||||||
|
OverlayCenter,
|
||||||
|
OverlayBackdrop,
|
||||||
|
Header,
|
||||||
|
config,
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
color,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { getSpaceChildren, getStateEvent, isSpace } from '../../utils/room';
|
||||||
|
import { Membership, StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { fetchAllHierarchyRooms } from '../../hooks/useSpaceHierarchy';
|
||||||
|
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||||
|
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||||
|
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||||
|
|
||||||
|
const REMOVE_DELAY_MS = 100;
|
||||||
|
const PREVIEW_LIMIT = 20;
|
||||||
|
|
||||||
|
export type InaccessibleSpaceChild = {
|
||||||
|
parentId: string;
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEditSpaceChildren(mx: MatrixClient, spaceRoom: Room): boolean {
|
||||||
|
const creators = getRoomCreatorsForRoomId(mx, spaceRoom.roomId);
|
||||||
|
const powerLevelsEvent = getStateEvent(spaceRoom, StateEvent.RoomPowerLevels);
|
||||||
|
const powerLevels = (powerLevelsEvent?.getContent() ?? {}) as IPowerLevels;
|
||||||
|
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
||||||
|
return permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InaccessibleScanResult = {
|
||||||
|
inaccessible: InaccessibleSpaceChild[];
|
||||||
|
/** Total valid m.space.child entries walked (editable spaces only). */
|
||||||
|
childCount: number;
|
||||||
|
/** Children skipped because the user is joined to them. */
|
||||||
|
joinedCount: number;
|
||||||
|
/** Unjoined children that hierarchy can still summarize (Joinable / not Inaccessible). */
|
||||||
|
joinableCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects space children that appear as Unknown/Inaccessible in the lobby.
|
||||||
|
* Matches lobby getRoom (joined-only): not joined + no hierarchy summary.
|
||||||
|
* Walks nested subspaces the user can edit.
|
||||||
|
*/
|
||||||
|
export async function collectInaccessibleSpaceChildren(
|
||||||
|
mx: MatrixClient,
|
||||||
|
rootSpaceId: string
|
||||||
|
): Promise<InaccessibleScanResult> {
|
||||||
|
let hierarchySummaries = new Map<string, { room_id: string }>();
|
||||||
|
try {
|
||||||
|
const hierarchyRooms = await fetchAllHierarchyRooms(mx, rootSpaceId, 3);
|
||||||
|
hierarchySummaries = new Map(hierarchyRooms.map((room) => [room.room_id, room]));
|
||||||
|
} catch {
|
||||||
|
// Hierarchy often fails for restricted spaces; treat missing summaries as inaccessible.
|
||||||
|
hierarchySummaries = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const inaccessible: InaccessibleSpaceChild[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let childCount = 0;
|
||||||
|
let joinedCount = 0;
|
||||||
|
let joinableCount = 0;
|
||||||
|
|
||||||
|
const walk = (spaceId: string) => {
|
||||||
|
if (visited.has(spaceId)) return;
|
||||||
|
visited.add(spaceId);
|
||||||
|
|
||||||
|
const spaceRoom = mx.getRoom(spaceId);
|
||||||
|
if (!spaceRoom || spaceRoom.getMyMembership() !== Membership.Join) return;
|
||||||
|
if (!canEditSpaceChildren(mx, spaceRoom)) return;
|
||||||
|
|
||||||
|
for (const childId of getSpaceChildren(spaceRoom)) {
|
||||||
|
childCount += 1;
|
||||||
|
const childRoom = mx.getRoom(childId);
|
||||||
|
// Lobby uses joined-only getRoom; left/invite rooms still render as Inaccessible.
|
||||||
|
const joined = childRoom?.getMyMembership() === Membership.Join;
|
||||||
|
|
||||||
|
if (joined && childRoom && isSpace(childRoom)) {
|
||||||
|
joinedCount += 1;
|
||||||
|
walk(childId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (joined) {
|
||||||
|
joinedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHierarchySummary = hierarchySummaries.has(childId);
|
||||||
|
if (hasHierarchySummary) {
|
||||||
|
joinableCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inaccessible.push({ parentId: spaceId, roomId: childId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(rootSpaceId);
|
||||||
|
return { inaccessible, childCount, joinedCount, joinableCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSpaceChildrenSequentially(
|
||||||
|
mx: MatrixClient,
|
||||||
|
children: InaccessibleSpaceChild[],
|
||||||
|
onProgress?: (current: number, total: number) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const total = children.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < children.length; i += 1) {
|
||||||
|
const { parentId, roomId } = children[i];
|
||||||
|
onProgress?.(i, total);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId);
|
||||||
|
await delay(REMOVE_DELAY_MS);
|
||||||
|
} catch {
|
||||||
|
// Continue removing others even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.(total, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoveInaccessiblePromptProps = {
|
||||||
|
roomId: string;
|
||||||
|
onDone: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RemoveInaccessiblePrompt({
|
||||||
|
roomId,
|
||||||
|
onDone,
|
||||||
|
onCancel,
|
||||||
|
}: RemoveInaccessiblePromptProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
|
||||||
|
const [scanResult, setScanResult] = useState<InaccessibleScanResult | null>(null);
|
||||||
|
|
||||||
|
const [scanState, scan] = useAsyncCallback<InaccessibleScanResult, MatrixError | Error, []>(
|
||||||
|
useCallback(async () => collectInaccessibleSpaceChildren(mx, roomId), [mx, roomId])
|
||||||
|
);
|
||||||
|
|
||||||
|
const children = scanResult?.inaccessible ?? null;
|
||||||
|
|
||||||
|
const [removeState, removeChildren] = useAsyncCallback<undefined, MatrixError | Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
if (!children || children.length === 0) return;
|
||||||
|
await removeSpaceChildrenSequentially(mx, children, (current, total) => {
|
||||||
|
setProgress({ current, total });
|
||||||
|
});
|
||||||
|
setProgress(null);
|
||||||
|
}, [mx, children])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scan();
|
||||||
|
}, [scan]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scanState.status === AsyncStatus.Success) {
|
||||||
|
setScanResult(scanState.data);
|
||||||
|
}
|
||||||
|
}, [scanState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (removeState.status === AsyncStatus.Success) {
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
}, [removeState, onDone]);
|
||||||
|
|
||||||
|
const previewIds = useMemo(() => (children ?? []).slice(0, PREVIEW_LIMIT).map((c) => c.roomId), [children]);
|
||||||
|
const isScanning = scanState.status === AsyncStatus.Loading || scanState.status === AsyncStatus.Idle;
|
||||||
|
const isRemoving = removeState.status === AsyncStatus.Loading;
|
||||||
|
const count = children?.length ?? 0;
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isRemoving && progress) return `Removing: ${progress.current}/${progress.total}`;
|
||||||
|
if (isRemoving) return 'Removing...';
|
||||||
|
if (count === 0) return 'Nothing to Remove';
|
||||||
|
return `Remove ${count} Room${count === 1 ? '' : 's'}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onCancel,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Remove Inaccessible</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onCancel} radii="300" disabled={isRemoving}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{isScanning && (
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
<Spinner size="200" />
|
||||||
|
<Text priority="400">Scanning space hierarchy for inaccessible rooms…</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{scanState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
Failed to scan space. {scanState.error.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{scanState.status === AsyncStatus.Success && count === 0 && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text priority="400">No inaccessible rooms found in this space.</Text>
|
||||||
|
{scanResult && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Scanned {scanResult.childCount} child
|
||||||
|
{scanResult.childCount === 1 ? '' : 'ren'}: {scanResult.joinedCount} joined,{' '}
|
||||||
|
{scanResult.joinableCount} joinable via hierarchy.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{scanState.status === AsyncStatus.Success && count > 0 && (
|
||||||
|
<>
|
||||||
|
<Text priority="400">
|
||||||
|
Remove {count} inaccessible room{count === 1 ? '' : 's'} from this space? This
|
||||||
|
only unlinks them from the space; it does not leave or delete rooms.
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
maxHeight: toRem(200),
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: config.space.S200,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewIds.map((id) => (
|
||||||
|
<Text key={id} size="T200" style={{ wordBreak: 'break-all' }}>
|
||||||
|
{id}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{count > PREVIEW_LIMIT && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
…and {count - PREVIEW_LIMIT} more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{removeState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
Failed to remove some rooms. {removeState.error.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="Critical"
|
||||||
|
onClick={() => removeChildren()}
|
||||||
|
before={
|
||||||
|
isRemoving || isScanning ? (
|
||||||
|
<Spinner fill="Solid" variant="Critical" size="200" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isScanning ||
|
||||||
|
isRemoving ||
|
||||||
|
count === 0 ||
|
||||||
|
removeState.status === AsyncStatus.Success
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B400">{getButtonText()}</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/components/remove-inaccessible-prompt/index.ts
Normal file
1
src/app/components/remove-inaccessible-prompt/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './RemoveInaccessiblePrompt';
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { JoinRule } from 'matrix-js-sdk';
|
import { JoinRule } from 'matrix-js-sdk';
|
||||||
import { AvatarFallback, AvatarImage, color } from 'folds';
|
import { AvatarFallback, AvatarImage, color } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
|
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useEffect, useState } from 'react';
|
||||||
|
import { Blurhash } from 'react-blurhash';
|
||||||
import * as css from './RoomAvatar.css';
|
import * as css from './RoomAvatar.css';
|
||||||
import { joinRuleToIconSrc } from '../../utils/room';
|
import { joinRuleToIconSrc } from '../../utils/room';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
||||||
|
import {
|
||||||
|
getCachedAuthenticatedMediaUrl,
|
||||||
|
isAuthenticatedMediaUrl,
|
||||||
|
} from '../../utils/authenticatedMediaCache';
|
||||||
|
import {
|
||||||
|
getMediaBlurHash,
|
||||||
|
getMediaDimensions,
|
||||||
|
rememberMediaBlurHash,
|
||||||
|
} from '../../state/mediaDimensionCache';
|
||||||
|
|
||||||
type RoomAvatarProps = {
|
type RoomAvatarProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -17,19 +27,36 @@ type RoomAvatarProps = {
|
|||||||
};
|
};
|
||||||
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
|
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||||
|
const blurHash = getMediaBlurHash(src ?? '');
|
||||||
|
const dims = getMediaDimensions(src ?? '');
|
||||||
|
const blobCached =
|
||||||
|
!!src &&
|
||||||
|
(!useAuthentication ||
|
||||||
|
!isAuthenticatedMediaUrl(src) ||
|
||||||
|
!!getCachedAuthenticatedMediaUrl(src));
|
||||||
|
const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
authenticatedSrc &&
|
||||||
|
(isAvatarCached(src) ||
|
||||||
|
(src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
|
||||||
|
) {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}, [authenticatedSrc, src, useAuthentication]);
|
||||||
|
|
||||||
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
||||||
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
cacheAvatar(src);
|
cacheAvatar(src);
|
||||||
pruneAvatarCache();
|
pruneAvatarCache();
|
||||||
|
rememberMediaBlurHash(src ?? '', evt.currentTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
// No src or error - show fallback only
|
if (!src || error) {
|
||||||
if (!authenticatedSrc || error) {
|
|
||||||
return (
|
return (
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
|
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
|
||||||
@@ -40,8 +67,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If already cached, show image directly without fallback flash
|
if (authenticatedSrc && loaded) {
|
||||||
if (loaded) {
|
|
||||||
return (
|
return (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
className={css.RoomAvatar}
|
className={css.RoomAvatar}
|
||||||
@@ -55,12 +81,17 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading state - render fallback with image overlay
|
const blurResolutionX = 32;
|
||||||
|
const blurResolutionY =
|
||||||
|
dims?.w && dims?.h && dims.w > 0
|
||||||
|
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
|
||||||
|
: 32;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colorMXID(roomId ?? ''),
|
backgroundColor: colorMXID(roomId ?? ''),
|
||||||
color: color.Surface.Container,
|
color: color.Surface.Container,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -69,15 +100,39 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||||||
>
|
>
|
||||||
{renderFallback()}
|
{renderFallback()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
<AvatarImage
|
{blurHash && (
|
||||||
className={css.RoomAvatar}
|
<div
|
||||||
style={{ position: 'relative', zIndex: 1 }}
|
style={{
|
||||||
src={authenticatedSrc}
|
position: 'absolute',
|
||||||
alt={alt}
|
inset: 0,
|
||||||
onError={() => setError(true)}
|
overflow: 'hidden',
|
||||||
onLoad={handleLoad}
|
borderRadius: 'inherit',
|
||||||
draggable={false}
|
filter: 'blur(6px)',
|
||||||
/>
|
transform: 'scale(1.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Blurhash
|
||||||
|
hash={blurHash}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
resolutionX={blurResolutionX}
|
||||||
|
resolutionY={blurResolutionY}
|
||||||
|
punch={1.1}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{authenticatedSrc && (
|
||||||
|
<AvatarImage
|
||||||
|
className={css.RoomAvatar}
|
||||||
|
style={{ position: 'relative', zIndex: 1, opacity: loaded ? 1 : 0 }}
|
||||||
|
src={authenticatedSrc}
|
||||||
|
alt={alt}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/app/components/setting-tile/SettingTile.css.ts
Normal file
18
src/app/components/setting-tile/SettingTile.css.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
|
const focusPulse = keyframes({
|
||||||
|
'0%': {
|
||||||
|
outlineColor: color.Primary.Main,
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
outlineColor: 'transparent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SettingTileFocus = style({
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
outline: `${config.borderWidth.B300} solid ${color.Primary.Main}`,
|
||||||
|
outlineOffset: config.space.S100,
|
||||||
|
animation: `${focusPulse} 1.2s ease-out forwards`,
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Text } from 'folds';
|
import { Box, Text } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { BreakWord } from '../../styles/Text.css';
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
|
import { settingAnchorId, useSettingsFocus } from './SettingsFocus';
|
||||||
|
import { SettingTileFocus } from './SettingTile.css';
|
||||||
|
|
||||||
type SettingTileProps = {
|
type SettingTileProps = {
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
@@ -8,10 +11,65 @@ type SettingTileProps = {
|
|||||||
before?: ReactNode;
|
before?: ReactNode;
|
||||||
after?: ReactNode;
|
after?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
/** Override auto-generated scroll anchor. */
|
||||||
|
anchorId?: string;
|
||||||
};
|
};
|
||||||
export function SettingTile({ title, description, before, after, children }: SettingTileProps) {
|
export function SettingTile({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
children,
|
||||||
|
anchorId: anchorIdProp,
|
||||||
|
}: SettingTileProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const { anchorId: focusAnchorId, setAnchorId } = useSettingsFocus();
|
||||||
|
const [highlighted, setHighlighted] = useState(false);
|
||||||
|
|
||||||
|
const autoAnchorId = typeof title === 'string' ? settingAnchorId(title) : undefined;
|
||||||
|
const anchorId = anchorIdProp ?? autoAnchorId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anchorId || !focusAnchorId || anchorId !== focusAnchorId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const node = ref.current;
|
||||||
|
if (node) {
|
||||||
|
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setHighlighted(true);
|
||||||
|
setAnchorId(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempts < 40) {
|
||||||
|
attempts += 1;
|
||||||
|
window.requestAnimationFrame(run);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = window.setTimeout(run, 50);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [anchorId, focusAnchorId, setAnchorId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlighted) return;
|
||||||
|
const timer = window.setTimeout(() => setHighlighted(false), 1400);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [highlighted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box alignItems="Center" gap="300">
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
id={anchorId}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
className={classNames(highlighted && SettingTileFocus)}
|
||||||
|
>
|
||||||
{before && <Box shrink="No">{before}</Box>}
|
{before && <Box shrink="No">{before}</Box>}
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
{title && (
|
{title && (
|
||||||
|
|||||||
32
src/app/components/setting-tile/SettingsFocus.tsx
Normal file
32
src/app/components/setting-tile/SettingsFocus.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type SettingsFocus = {
|
||||||
|
/** Stable anchor id for a setting tile (from settingAnchorId). */
|
||||||
|
anchorId?: string;
|
||||||
|
setAnchorId: (anchorId: string | undefined) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SettingsFocusContext = createContext<SettingsFocus | null>(null);
|
||||||
|
|
||||||
|
export const SettingsFocusProvider = SettingsFocusContext.Provider;
|
||||||
|
|
||||||
|
export const useSettingsFocus = (): SettingsFocus => {
|
||||||
|
const ctx = useContext(SettingsFocusContext);
|
||||||
|
if (!ctx) {
|
||||||
|
return {
|
||||||
|
anchorId: undefined,
|
||||||
|
setAnchorId: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stable DOM id for a settings tile title. */
|
||||||
|
export function settingAnchorId(title: string): string {
|
||||||
|
const slug = title
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
return `settings-item-${slug || 'untitled'}`;
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './SettingTile';
|
export * from './SettingTile';
|
||||||
|
export * from './SettingsFocus';
|
||||||
|
|||||||
@@ -4,5 +4,10 @@ import React from 'react';
|
|||||||
import * as css from './Sidebar.css';
|
import * as css from './Sidebar.css';
|
||||||
|
|
||||||
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
|
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
|
||||||
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
|
<AsSidebar
|
||||||
|
className={classNames(css.Sidebar, className)}
|
||||||
|
data-sidebar=""
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ type SidebarContentProps = {
|
|||||||
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
|
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box direction="Column" grow="Yes">
|
<Box direction="Column" grow="Yes" data-sidebar-scroll-region="" style={{ overflow: 'visible', minWidth: 0 }}>
|
||||||
{scrollable}
|
{scrollable}
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" shrink="No">
|
<Box direction="Column" shrink="No" data-sidebar-sticky="" style={{ overflow: 'visible' }}>
|
||||||
{sticky}
|
{sticky}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const SidebarItem = as<'div', css.SidebarItemVariants>(
|
|||||||
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
|
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
|
||||||
<AsSidebarAvatarBox
|
<AsSidebarAvatarBox
|
||||||
className={classNames(css.SidebarItem({ active }), className)}
|
className={classNames(css.SidebarItem({ active }), className)}
|
||||||
|
data-sidebar-item=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -54,6 +55,7 @@ export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProp
|
|||||||
<Avatar
|
<Avatar
|
||||||
className={classNames(css.SidebarAvatar({ size, outlined }), className)}
|
className={classNames(css.SidebarAvatar({ size, outlined }), className)}
|
||||||
radii={radii}
|
radii={radii}
|
||||||
|
data-sidebar-avatar=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -64,6 +66,7 @@ export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
|
|||||||
({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
|
({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
|
||||||
<AsSidebarFolder
|
<AsSidebarFolder
|
||||||
className={classNames(css.SidebarFolder({ state }), className)}
|
className={classNames(css.SidebarFolder({ state }), className)}
|
||||||
|
data-sidebar-folder=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export const TitleBarDragRegion = style({
|
|||||||
color: color.Surface.OnContainer,
|
color: color.Surface.OnContainer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const TitleBarBackButton = style({
|
||||||
|
WebkitAppRegion: 'no-drag',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
export const SyncStatusBadge = style({
|
export const SyncStatusBadge = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Box, Text } from 'folds';
|
import { Box, Text, IconButton } from 'folds';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules, SyncState } from 'matrix-js-sdk';
|
import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules, SyncState } from 'matrix-js-sdk';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
@@ -18,6 +18,8 @@ import { AccountDataEvent } from '../../../types/matrix/accountData';
|
|||||||
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
|
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
|
||||||
import { isRoomId } from '../../utils/matrix';
|
import { isRoomId } from '../../utils/matrix';
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
|
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
|
||||||
type NewMessageNotification = {
|
type NewMessageNotification = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,6 +44,7 @@ export function TitleBar({ mx, children }: TitleBarProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const { showInTitleBar, goBack } = useBackRoute(mx);
|
||||||
|
|
||||||
// Sync status state
|
// Sync status state
|
||||||
const [syncStateData, setSyncStateData] = useState<{
|
const [syncStateData, setSyncStateData] = useState<{
|
||||||
@@ -652,6 +655,20 @@ export function TitleBar({ mx, children }: TitleBarProps) {
|
|||||||
grow="Yes"
|
grow="Yes"
|
||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
>
|
>
|
||||||
|
{showInTitleBar && (
|
||||||
|
<IconButton
|
||||||
|
className={css.TitleBarBackButton}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
onClick={goBack}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ArrowLeft} size="200" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<Text size="T200" truncate style={{ color: 'inherit' }}>
|
<Text size="T200" truncate style={{ color: 'inherit' }}>
|
||||||
<span style={{ fontWeight: 700 }}>Paarrot</span>{titleParts.suffix}
|
<span style={{ fontWeight: 700 }}>Paarrot</span>{titleParts.suffix}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,95 +1,149 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const CheckButtonContainer = style({
|
const indeterminate = keyframes({
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(250%)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IdleSlot = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
height: '32px',
|
height: '100%',
|
||||||
width: '32px',
|
WebkitAppRegion: 'no-drag',
|
||||||
|
flexShrink: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CheckButton = style({
|
export const GhostCheck = style({
|
||||||
all: 'unset',
|
all: 'unset',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 0,
|
width: toRem(28),
|
||||||
|
height: toRem(28),
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
cursor: 'pointer',
|
color: color.Surface.OnContainer,
|
||||||
color: color.Secondary.Main,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
transition: 'opacity 0.2s, background-color 0.15s',
|
|
||||||
height: '32px',
|
|
||||||
width: '32px',
|
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
WebkitAppRegion: 'no-drag',
|
cursor: 'pointer',
|
||||||
flexShrink: 0,
|
transition: 'opacity 0.15s ease, background-color 0.15s ease',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-visible="true"]': {
|
'&[data-visible="true"]': {
|
||||||
opacity: 0.7,
|
opacity: 0.65,
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
opacity: 1,
|
||||||
|
backgroundColor: color.Surface.ContainerHover,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: color.Surface.ContainerHover,
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
':active': {
|
|
||||||
backgroundColor: color.Surface.ContainerActive,
|
|
||||||
},
|
|
||||||
|
|
||||||
':focus-visible': {
|
|
||||||
outline: `2px solid ${color.Secondary.Main}`,
|
|
||||||
outlineOffset: '2px',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateButton = style({
|
export const Bar = style({
|
||||||
all: 'unset',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: config.space.S200,
|
||||||
padding: 0,
|
height: toRem(22),
|
||||||
|
minWidth: toRem(120),
|
||||||
|
maxWidth: toRem(200),
|
||||||
|
padding: `0 ${config.space.S200}`,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
cursor: 'pointer',
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
color: color.Success.Main,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
backgroundColor: 'transparent',
|
|
||||||
transition: 'background-color 0.15s',
|
|
||||||
height: '32px',
|
|
||||||
width: '32px',
|
|
||||||
WebkitAppRegion: 'no-drag',
|
WebkitAppRegion: 'no-drag',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
});
|
||||||
|
|
||||||
':hover': {
|
export const BarTrack = style({
|
||||||
backgroundColor: color.Surface.ContainerHover,
|
position: 'relative',
|
||||||
},
|
flex: 1,
|
||||||
|
height: toRem(4),
|
||||||
|
borderRadius: toRem(2),
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.ContainerLine,
|
||||||
|
minWidth: toRem(64),
|
||||||
|
});
|
||||||
|
|
||||||
':active': {
|
export const BarFill = style({
|
||||||
backgroundColor: color.Surface.ContainerActive,
|
position: 'absolute',
|
||||||
},
|
inset: '0 auto 0 0',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundColor: color.Success.Main,
|
||||||
|
transition: 'width 120ms linear',
|
||||||
|
});
|
||||||
|
|
||||||
':focus-visible': {
|
export const BarIndeterminate = style({
|
||||||
outline: `2px solid ${color.Success.Main}`,
|
position: 'absolute',
|
||||||
outlineOffset: '2px',
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '40%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundColor: color.Secondary.Main,
|
||||||
|
animation: `${indeterminate} 1s ease-in-out infinite`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BarLabel = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
opacity: 0.9,
|
||||||
|
minWidth: toRem(28),
|
||||||
|
textAlign: 'right',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Chip = style({
|
||||||
|
height: toRem(22),
|
||||||
|
maxWidth: toRem(260),
|
||||||
|
padding: `0 ${config.space.S100} 0 ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.Success.Container,
|
||||||
|
color: color.Success.OnContainer,
|
||||||
|
WebkitAppRegion: 'no-drag',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChipText = style({
|
||||||
|
maxWidth: toRem(140),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChipAction = style({
|
||||||
|
all: 'unset',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: toRem(18),
|
||||||
|
padding: `0 ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.Success.Main,
|
||||||
|
color: color.Success.OnMain,
|
||||||
|
fontSize: toRem(11),
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
filter: 'brightness(1.05)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateMenu = style({
|
export const ChipDismiss = style({
|
||||||
minWidth: toRem(280),
|
all: 'unset',
|
||||||
maxWidth: toRem(320),
|
display: 'inline-flex',
|
||||||
backgroundColor: color.Surface.Container,
|
alignItems: 'center',
|
||||||
borderRadius: config.radii.R400,
|
justifyContent: 'center',
|
||||||
boxShadow: config.shadow.E400,
|
width: toRem(18),
|
||||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
height: toRem(18),
|
||||||
});
|
borderRadius: config.radii.R300,
|
||||||
|
cursor: 'pointer',
|
||||||
export const ProgressText = style({
|
opacity: 0.7,
|
||||||
color: color.Success.Main,
|
fontSize: toRem(14),
|
||||||
fontWeight: 500,
|
lineHeight: 1,
|
||||||
minWidth: toRem(35),
|
selectors: {
|
||||||
textAlign: 'center',
|
'&:hover': {
|
||||||
|
opacity: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,138 +1,136 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
|
import { Box, Text } from 'folds';
|
||||||
import * as css from './UpdateNotification.css';
|
import * as css from './UpdateNotification.css';
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
version: string;
|
version: string;
|
||||||
releaseNotes?: string;
|
releaseNotes?: string;
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
|
mock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
percent: number;
|
percent: number;
|
||||||
transferred: number;
|
transferred: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
mock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'ready';
|
||||||
|
|
||||||
export function UpdateNotification() {
|
export function UpdateNotification() {
|
||||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
const [phase, setPhase] = useState<UpdaterPhase>('idle');
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
const [updateReady, setUpdateReady] = useState(false);
|
const [isMock, setIsMock] = useState(false);
|
||||||
const [checking, setChecking] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if we're in Electron environment
|
const electron = (window as any).electron;
|
||||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
if (!electron?.updater) return undefined;
|
||||||
return;
|
|
||||||
|
const { updater } = electron;
|
||||||
|
|
||||||
|
updater.isMock?.().then((result: { success?: boolean; data?: { mock?: boolean } }) => {
|
||||||
|
if (result?.data?.mock) setIsMock(true);
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
updater.onUpdateAvailable((info: UpdateInfo) => {
|
||||||
|
setUpdateInfo(info);
|
||||||
|
if (info.mock) setIsMock(true);
|
||||||
|
setPhase('available');
|
||||||
|
setDownloadProgress(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
||||||
|
setPhase('downloading');
|
||||||
|
setDownloadProgress(Math.min(100, Math.round(progress.percent)));
|
||||||
|
});
|
||||||
|
|
||||||
|
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
||||||
|
setUpdateInfo(info);
|
||||||
|
setPhase('ready');
|
||||||
|
setDownloadProgress(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onNotAvailable = () => {
|
||||||
|
setPhase('idle');
|
||||||
|
setUpdateInfo(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
};
|
||||||
|
// Optional channel — ignore if preload doesn't expose a dedicated listener
|
||||||
|
if (electron.updater.onUpdateNotAvailable) {
|
||||||
|
electron.updater.onUpdateNotAvailable(onNotAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { updater } = (window as any).electron;
|
return undefined;
|
||||||
|
|
||||||
// Listen for update available
|
|
||||||
updater.onUpdateAvailable((info: UpdateInfo) => {
|
|
||||||
console.log('Update available:', info.version);
|
|
||||||
setUpdateAvailable(true);
|
|
||||||
setUpdateInfo(info);
|
|
||||||
setDownloading(false);
|
|
||||||
setUpdateReady(false);
|
|
||||||
setChecking(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for download progress
|
|
||||||
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
|
||||||
setDownloadProgress(Math.round(progress.percent));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for update downloaded
|
|
||||||
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
|
||||||
console.log('Update downloaded:', info.version);
|
|
||||||
setDownloading(false);
|
|
||||||
setUpdateReady(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup - IPC listeners don't need manual cleanup in this case
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCheckForUpdates = async () => {
|
const handleCheck = useCallback(async () => {
|
||||||
setChecking(true);
|
setPhase('checking');
|
||||||
try {
|
try {
|
||||||
const result = await (window as any).electron.updater.checkForUpdates();
|
const result = await (window as any).electron.updater.checkForUpdates();
|
||||||
console.log('Update check result:', result);
|
if (!result?.success) {
|
||||||
|
setPhase('idle');
|
||||||
// Handle error response (including dev mode error)
|
|
||||||
if (!result.success) {
|
|
||||||
console.warn('Update check failed:', result.error);
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// available event will advance phase; if nothing comes, fall back
|
||||||
// If no update found, show feedback briefly
|
window.setTimeout(() => {
|
||||||
setTimeout(() => {
|
setPhase((current) => (current === 'checking' ? 'idle' : current));
|
||||||
if (!updateAvailable) {
|
}, 4000);
|
||||||
setChecking(false);
|
} catch {
|
||||||
}
|
setPhase('idle');
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check for updates:', error);
|
|
||||||
setChecking(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = useCallback(async () => {
|
||||||
setDownloading(true);
|
setPhase('downloading');
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
try {
|
try {
|
||||||
await (window as any).electron.updater.downloadUpdate();
|
await (window as any).electron.updater.downloadUpdate();
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to download update:', error);
|
setPhase('available');
|
||||||
setDownloading(false);
|
|
||||||
}
|
}
|
||||||
setMenuAnchor(undefined);
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
const handleInstall = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await (window as any).electron.updater.installUpdate();
|
await (window as any).electron.updater.installUpdate();
|
||||||
} catch (error) {
|
if (isMock) {
|
||||||
console.error('Failed to install update:', error);
|
setPhase('idle');
|
||||||
|
setUpdateInfo(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep ready state
|
||||||
}
|
}
|
||||||
};
|
}, [isMock]);
|
||||||
|
|
||||||
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleDismiss = useCallback(() => {
|
||||||
if (menuAnchor) {
|
setPhase('idle');
|
||||||
setMenuAnchor(undefined);
|
setUpdateInfo(null);
|
||||||
} else {
|
setDownloadProgress(0);
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
}, []);
|
||||||
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render anything if not in Electron
|
|
||||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show check button if no update status
|
if (phase === 'idle') {
|
||||||
if (!updateAvailable && !updateReady && !checking) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css.CheckButtonContainer}
|
className={css.IdleSlot}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={css.CheckButton}
|
|
||||||
data-visible={isHovered}
|
|
||||||
onClick={handleCheckForUpdates}
|
|
||||||
aria-label="Check for updates"
|
|
||||||
title="Check for updates"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
className={css.GhostCheck}
|
||||||
|
data-visible={hovered || isMock ? 'true' : undefined}
|
||||||
|
onClick={handleCheck}
|
||||||
|
title={isMock ? 'Check for updates (mock)' : 'Check for updates'}
|
||||||
|
aria-label="Check for updates"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||||
<path
|
<path
|
||||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
d="M8 2V10M8 10L5 7M8 10L11 7"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -140,109 +138,75 @@ export function UpdateNotification() {
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<path
|
<path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
d="M3 14H13"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show checking state
|
if (phase === 'checking') {
|
||||||
if (checking) {
|
|
||||||
return (
|
return (
|
||||||
<button className={css.UpdateButton} disabled type="button">
|
<div className={css.Bar} title="Checking for updates…">
|
||||||
<Spinner variant="Secondary" size="50" />
|
<div className={css.BarTrack}>
|
||||||
</button>
|
<div className={css.BarIndeterminate} />
|
||||||
|
</div>
|
||||||
|
<Text className={css.BarLabel} size="L400">
|
||||||
|
Checking…
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show update available/ready state
|
if (phase === 'downloading') {
|
||||||
if (!updateAvailable && !updateReady) {
|
return (
|
||||||
return null;
|
<div
|
||||||
|
className={css.Bar}
|
||||||
|
title={`Downloading update${updateInfo ? ` ${updateInfo.version}` : ''}… ${downloadProgress}%`}
|
||||||
|
>
|
||||||
|
<div className={css.BarTrack}>
|
||||||
|
<div className={css.BarFill} style={{ width: `${downloadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<Text className={css.BarLabel} size="L400">
|
||||||
|
{downloadProgress}%
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (phase === 'ready') {
|
||||||
<>
|
return (
|
||||||
<button
|
<Box className={css.Chip} alignItems="Center" gap="100">
|
||||||
className={css.UpdateButton}
|
<Text className={css.ChipText} size="L400" truncate>
|
||||||
onClick={handleMenuToggle}
|
{isMock ? 'Mock ready' : 'Update ready'}
|
||||||
aria-label={updateReady ? 'Update ready' : 'Update available'}
|
{updateInfo ? ` · ${updateInfo.version}` : ''}
|
||||||
title={updateReady ? 'Update downloaded - click to install' : 'New version available'}
|
</Text>
|
||||||
type="button"
|
<button type="button" className={css.ChipAction} onClick={handleInstall}>
|
||||||
>
|
Restart
|
||||||
{downloading ? (
|
</button>
|
||||||
<Spinner variant="Secondary" size="50" />
|
</Box>
|
||||||
) : (
|
);
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
}
|
||||||
<path
|
|
||||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M3 14H13"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<PopOut
|
// available
|
||||||
anchor={menuAnchor}
|
return (
|
||||||
position="Bottom"
|
<Box className={css.Chip} alignItems="Center" gap="100">
|
||||||
align="End"
|
<Text className={css.ChipText} size="L400" truncate>
|
||||||
offset={8}
|
{isMock ? 'Mock update' : 'Update'}
|
||||||
content={
|
{updateInfo ? ` ${updateInfo.version}` : ''}
|
||||||
<Menu className={css.UpdateMenu}>
|
</Text>
|
||||||
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
|
<button type="button" className={css.ChipAction} onClick={handleDownload}>
|
||||||
<Box direction="Column" gap="100">
|
Download
|
||||||
<Text size="H5" priority="400">
|
</button>
|
||||||
{updateReady ? 'Update Ready' : 'Update Available'}
|
<button
|
||||||
</Text>
|
type="button"
|
||||||
{updateInfo && (
|
className={css.ChipDismiss}
|
||||||
<Text size="T200" priority="300">
|
onClick={handleDismiss}
|
||||||
Version {updateInfo.version}
|
aria-label="Dismiss update"
|
||||||
</Text>
|
title="Dismiss"
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{updateReady ? (
|
|
||||||
<Button
|
|
||||||
variant="Primary"
|
|
||||||
size="400"
|
|
||||||
onClick={handleInstall}
|
|
||||||
fill="Solid"
|
|
||||||
>
|
|
||||||
<Text size="B400">Install and Restart</Text>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="Primary"
|
|
||||||
size="400"
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={downloading}
|
|
||||||
fill="Solid"
|
|
||||||
>
|
|
||||||
<Text size="B400">
|
|
||||||
{downloading ? `Downloading ${downloadProgress}%` : 'Download Update'}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{null}
|
×
|
||||||
</PopOut>
|
</button>
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { AvatarFallback, AvatarImage, color } from 'folds';
|
import { AvatarFallback, AvatarImage, color } from 'folds';
|
||||||
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
|
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { Blurhash } from 'react-blurhash';
|
||||||
import * as css from './UserAvatar.css';
|
import * as css from './UserAvatar.css';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
||||||
|
import {
|
||||||
// Check if image is already in browser cache
|
getCachedAuthenticatedMediaUrl,
|
||||||
function isImageInBrowserCache(url: string): boolean {
|
isAuthenticatedMediaUrl,
|
||||||
const img = new Image();
|
} from '../../utils/authenticatedMediaCache';
|
||||||
img.src = url;
|
import {
|
||||||
return img.complete && img.naturalWidth > 0;
|
getMediaBlurHash,
|
||||||
}
|
getMediaDimensions,
|
||||||
|
rememberMediaBlurHash,
|
||||||
|
} from '../../state/mediaDimensionCache';
|
||||||
|
|
||||||
type UserAvatarProps = {
|
type UserAvatarProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -25,20 +28,28 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||||
|
const blurHash = getMediaBlurHash(src ?? '');
|
||||||
// Check both our cache and browser's native cache
|
const dims = getMediaDimensions(src ?? '');
|
||||||
const [loaded, setLoaded] = useState(() => {
|
const blobCached =
|
||||||
if (isAvatarCached(src)) return true;
|
!!src &&
|
||||||
if (authenticatedSrc && isImageInBrowserCache(authenticatedSrc)) {
|
(!useAuthentication ||
|
||||||
cacheAvatar(src);
|
!isAuthenticatedMediaUrl(src) ||
|
||||||
return true;
|
!!getCachedAuthenticatedMediaUrl(src));
|
||||||
|
|
||||||
|
const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
authenticatedSrc &&
|
||||||
|
(isAvatarCached(src) ||
|
||||||
|
(src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
|
||||||
|
) {
|
||||||
|
setLoaded(true);
|
||||||
}
|
}
|
||||||
return false;
|
}, [authenticatedSrc, src, useAuthentication]);
|
||||||
});
|
|
||||||
|
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
// Also check on mount if image is already complete (browser cached)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
@@ -51,10 +62,10 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
cacheAvatar(src);
|
cacheAvatar(src);
|
||||||
pruneAvatarCache();
|
pruneAvatarCache();
|
||||||
|
rememberMediaBlurHash(src ?? '', evt.currentTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
// No src or error - show fallback only
|
if (!src || error) {
|
||||||
if (!authenticatedSrc || error) {
|
|
||||||
return (
|
return (
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
|
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
|
||||||
@@ -65,8 +76,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If already cached, show image directly without fallback flash
|
if (authenticatedSrc && loaded) {
|
||||||
if (loaded) {
|
|
||||||
return (
|
return (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
className={classNames(css.UserAvatar, className)}
|
className={classNames(css.UserAvatar, className)}
|
||||||
@@ -80,13 +90,17 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading state - render image with hidden fallback behind it
|
const blurResolutionX = 32;
|
||||||
// Image loads invisibly, then we show it once complete
|
const blurResolutionY =
|
||||||
|
dims?.w && dims?.h && dims.w > 0
|
||||||
|
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
|
||||||
|
: 32;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colorMXID(userId),
|
backgroundColor: colorMXID(userId),
|
||||||
color: color.Surface.Container,
|
color: color.Surface.Container,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -95,16 +109,40 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
>
|
>
|
||||||
{renderFallback()}
|
{renderFallback()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
<AvatarImage
|
{blurHash && (
|
||||||
ref={imgRef}
|
<div
|
||||||
className={classNames(css.UserAvatar, className)}
|
style={{
|
||||||
style={{ position: 'relative', zIndex: 1 }}
|
position: 'absolute',
|
||||||
src={authenticatedSrc}
|
inset: 0,
|
||||||
alt={alt}
|
overflow: 'hidden',
|
||||||
onError={() => setError(true)}
|
borderRadius: 'inherit',
|
||||||
onLoad={handleLoad}
|
filter: 'blur(6px)',
|
||||||
draggable={false}
|
transform: 'scale(1.08)',
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
<Blurhash
|
||||||
|
hash={blurHash}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
resolutionX={blurResolutionX}
|
||||||
|
resolutionY={blurResolutionY}
|
||||||
|
punch={1.1}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{authenticatedSrc && (
|
||||||
|
<AvatarImage
|
||||||
|
ref={imgRef}
|
||||||
|
className={classNames(css.UserAvatar, className)}
|
||||||
|
style={{ position: 'relative', zIndex: 1, opacity: loaded ? 1 : 0 }}
|
||||||
|
src={authenticatedSrc}
|
||||||
|
alt={alt}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const VirtualTile = as<'div', VirtualTileProps>(
|
|||||||
({ className, virtualItem, style, ...props }, ref) => (
|
({ className, virtualItem, style, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
className={classNames(css.VirtualTile, className)}
|
className={classNames(css.VirtualTile, className)}
|
||||||
style={{ top: virtualItem.start, ...style }}
|
style={{ top: virtualItem.start, zIndex: virtualItem.index, ...style }}
|
||||||
data-index={virtualItem.index}
|
data-index={virtualItem.index}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
Participant,
|
Participant,
|
||||||
LocalTrackPublication,
|
LocalTrackPublication,
|
||||||
|
LocalAudioTrack,
|
||||||
Track,
|
Track,
|
||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
@@ -31,7 +32,7 @@ import {
|
|||||||
getLiveKitHomeserverPriority,
|
getLiveKitHomeserverPriority,
|
||||||
} from './useRtcConfig';
|
} from './useRtcConfig';
|
||||||
import { getActiveCallMembers } from './useRoomCallMembers';
|
import { getActiveCallMembers } from './useRoomCallMembers';
|
||||||
import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
|
import { getAudioCaptureOptions, getAudioSettings, AUDIO_SETTINGS_CHANGED_EVENT, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
|
||||||
import { getCallSounds, CallSoundType } from './CallSounds';
|
import { getCallSounds, CallSoundType } from './CallSounds';
|
||||||
|
|
||||||
type CallEventListener = (event: CallEvent) => void;
|
type CallEventListener = (event: CallEvent) => void;
|
||||||
@@ -102,6 +103,10 @@ export class CallService {
|
|||||||
|
|
||||||
private wasMutedBeforeDeafen = false;
|
private wasMutedBeforeDeafen = false;
|
||||||
|
|
||||||
|
private readonly onAudioSettingsChanged = () => {
|
||||||
|
void this.applyAudioCaptureSettings();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new CallService instance
|
* Creates a new CallService instance
|
||||||
* @param config - Service configuration including homeserver and LiveKit URLs
|
* @param config - Service configuration including homeserver and LiveKit URLs
|
||||||
@@ -110,6 +115,7 @@ export class CallService {
|
|||||||
constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
|
constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.matrixClient = matrixClient;
|
this.matrixClient = matrixClient;
|
||||||
|
window.addEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -386,9 +392,13 @@ export class CallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Create and connect to LiveKit room
|
// Step 4: Create and connect to LiveKit room
|
||||||
|
// Pin audioCaptureDefaults so mute/unmute republish doesn't re-enable LiveKit's
|
||||||
|
// voiceIsolation:true default (which ignores noiseSuppression).
|
||||||
|
const audioCaptureOptions = getAudioCaptureOptions();
|
||||||
this.livekitRoom = new Room({
|
this.livekitRoom = new Room({
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
|
audioCaptureDefaults: audioCaptureOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setupLiveKitEventHandlers();
|
this.setupLiveKitEventHandlers();
|
||||||
@@ -400,19 +410,7 @@ export class CallService {
|
|||||||
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
|
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
|
||||||
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
|
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
|
||||||
|
|
||||||
// Get user's audio device preferences
|
console.log('Using audio capture options:', audioCaptureOptions);
|
||||||
const audioSettings = getAudioSettings();
|
|
||||||
console.log('Using audio settings:', audioSettings);
|
|
||||||
|
|
||||||
// Build audio capture options with processing settings
|
|
||||||
const audioCaptureOptions = {
|
|
||||||
deviceId: audioSettings.microphoneId || undefined,
|
|
||||||
noiseSuppression: audioSettings.noiseSuppression,
|
|
||||||
echoCancellation: audioSettings.echoCancellation,
|
|
||||||
autoGainControl: audioSettings.autoGainControl,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Audio capture options:', audioCaptureOptions);
|
|
||||||
|
|
||||||
// Step 5: Publish local tracks based on call type with selected devices and processing
|
// Step 5: Publish local tracks based on call type with selected devices and processing
|
||||||
if (callType === CallType.Video) {
|
if (callType === CallType.Video) {
|
||||||
@@ -881,7 +879,10 @@ export class CallService {
|
|||||||
getCallSounds().playSound(CallSoundType.Undeafen);
|
getCallSounds().playSound(CallSoundType.Undeafen);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
|
this.livekitRoom.localParticipant.setMicrophoneEnabled(
|
||||||
|
!newMuteState,
|
||||||
|
getAudioCaptureOptions()
|
||||||
|
);
|
||||||
this.activeCall.isMuted = newMuteState;
|
this.activeCall.isMuted = newMuteState;
|
||||||
|
|
||||||
// Play mute/unmute sound
|
// Play mute/unmute sound
|
||||||
@@ -929,7 +930,7 @@ export class CallService {
|
|||||||
} else {
|
} else {
|
||||||
// Undeafening - only unmute if user wasn't muted before deafening
|
// Undeafening - only unmute if user wasn't muted before deafening
|
||||||
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
|
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
|
||||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(true);
|
this.livekitRoom.localParticipant.setMicrophoneEnabled(true, getAudioCaptureOptions());
|
||||||
this.activeCall.isMuted = false;
|
this.activeCall.isMuted = false;
|
||||||
}
|
}
|
||||||
this.wasMutedBeforeDeafen = false;
|
this.wasMutedBeforeDeafen = false;
|
||||||
@@ -1129,10 +1130,39 @@ export class CallService {
|
|||||||
return this.activeCall?.isScreenSharing ?? false;
|
return this.activeCall?.isScreenSharing ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply stored mic processing settings to the live published track.
|
||||||
|
* Needed because LiveKit merges voiceIsolation:true by default, and because
|
||||||
|
* settings changes mid-call previously only wrote localStorage.
|
||||||
|
*/
|
||||||
|
async applyAudioCaptureSettings(): Promise<void> {
|
||||||
|
if (!this.livekitRoom || !this.activeCall) return;
|
||||||
|
|
||||||
|
const options = getAudioCaptureOptions();
|
||||||
|
this.livekitRoom.options.audioCaptureDefaults = {
|
||||||
|
...this.livekitRoom.options.audioCaptureDefaults,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
const publication = this.livekitRoom.localParticipant.getTrackPublication(
|
||||||
|
Track.Source.Microphone
|
||||||
|
);
|
||||||
|
const track = publication?.track;
|
||||||
|
if (!(track instanceof LocalAudioTrack)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await track.restartTrack(options);
|
||||||
|
console.log('Reapplied audio capture settings:', options);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reapply audio capture settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans up resources
|
* Cleans up resources
|
||||||
*/
|
*/
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
window.removeEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged);
|
||||||
this.stopMembershipRefresh();
|
this.stopMembershipRefresh();
|
||||||
this.endCall();
|
this.endCall();
|
||||||
this.listeners.clear();
|
this.listeners.clear();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { ErrorCode } from '../../cs-errorcode';
|
import { ErrorCode } from '../../cs-errorcode';
|
||||||
import { millisecondsToMinutes } from '../../utils/common';
|
import { millisecondsToMinutes } from '../../utils/common';
|
||||||
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../../components/create-room';
|
import { createRoomEncryptionState } from '../../components/create-room';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
@@ -28,9 +28,10 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
|
|||||||
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>(
|
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (userId, encrypted) => {
|
async (userId, encrypted) => {
|
||||||
|
// Do not put m.room.power_levels in initial_state — incomplete PL events
|
||||||
|
// (used for org.matrix.msc3401.call.member) cause M_FORBIDDEN / 403 on create.
|
||||||
|
// TrustedPrivateChat already gives both users PL 100, so call membership works.
|
||||||
const initialState: ICreateRoomStateEvent[] = [];
|
const initialState: ICreateRoomStateEvent[] = [];
|
||||||
|
|
||||||
initialState.push(createRoomPowerLevelsState());
|
|
||||||
if (encrypted) initialState.push(createRoomEncryptionState());
|
if (encrypted) initialState.push(createRoomEncryptionState());
|
||||||
|
|
||||||
const result = await mx.createRoom({
|
const result = await mx.createRoom({
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
|
import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { ReactEditor } from 'slate-react';
|
|
||||||
import { IconButton, Line, PopOut } from 'folds';
|
import { IconButton, Line, PopOut } from 'folds';
|
||||||
import { Icon, Icons } from '../../components/icons';
|
import { Icon, Icons } from '../../components/icons';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
getAutocompleteQuery,
|
getAutocompleteQuery,
|
||||||
createEmoticonElement,
|
createEmoticonElement,
|
||||||
moveCursor,
|
moveCursor,
|
||||||
|
safeFocusEditor,
|
||||||
} from '../../components/editor';
|
} from '../../components/editor';
|
||||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
@@ -84,7 +84,7 @@ export function ForumChatComposer({
|
|||||||
|
|
||||||
const handleCloseAutocomplete = useCallback(() => {
|
const handleCloseAutocomplete = useCallback(() => {
|
||||||
setAutocompleteQuery(undefined);
|
setAutocompleteQuery(undefined);
|
||||||
ReactEditor.focus(editor);
|
safeFocusEditor(editor);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,7 +114,7 @@ export function ForumChatComposer({
|
|||||||
onClick={() => setToolbar(!toolbar)}
|
onClick={() => setToolbar(!toolbar)}
|
||||||
aria-label="Formatting"
|
aria-label="Formatting"
|
||||||
>
|
>
|
||||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} size="200" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<UseStateProvider initial={undefined}>
|
<UseStateProvider initial={undefined}>
|
||||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||||
@@ -136,15 +136,15 @@ export function ForumChatComposer({
|
|||||||
returnFocusOnDeactivate={false}
|
returnFocusOnDeactivate={false}
|
||||||
onEmojiSelect={handleEmoticonSelect}
|
onEmojiSelect={handleEmoticonSelect}
|
||||||
onCustomEmojiSelect={handleEmoticonSelect}
|
onCustomEmojiSelect={handleEmoticonSelect}
|
||||||
requestClose={() => {
|
requestClose={() => {
|
||||||
setEmojiBoardTab((t) => {
|
setEmojiBoardTab((t) => {
|
||||||
if (t) {
|
if (t) {
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
if (!mobileOrTablet()) safeFocusEditor(editor);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -158,7 +158,11 @@ export function ForumChatComposer({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Emoji"
|
aria-label="Emoji"
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
|
<Icon
|
||||||
|
src={Icons.Smile}
|
||||||
|
size="200"
|
||||||
|
filled={emojiBoardTab === EmojiBoardTab.Emoji}
|
||||||
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
)}
|
||||||
@@ -173,7 +177,7 @@ export function ForumChatComposer({
|
|||||||
radii="300"
|
radii="300"
|
||||||
aria-label="Send"
|
aria-label="Send"
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Send} />
|
<Icon src={Icons.Send} size="200" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import * as css from './LobbyHeader.css';
|
|||||||
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
||||||
|
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
||||||
@@ -48,6 +49,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
|||||||
|
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||||
|
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
|
||||||
const openSpaceSettings = useOpenSpaceSettings();
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
|
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
@@ -117,6 +119,34 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{canEditChildren && (
|
||||||
|
<UseStateProvider initial={false}>
|
||||||
|
{(promptRemove, setPromptRemove) => (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => setPromptRemove(true)}
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Delete} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={promptRemove}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Remove Inaccessible
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
{promptRemove && (
|
||||||
|
<RemoveInaccessiblePrompt
|
||||||
|
roomId={space.roomId}
|
||||||
|
onDone={requestClose}
|
||||||
|
onCancel={() => setPromptRemove(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
)}
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(promptLeave, setPromptLeave) => (
|
{(promptLeave, setPromptLeave) => (
|
||||||
<>
|
<>
|
||||||
@@ -165,6 +195,7 @@ export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: Lobb
|
|||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
|
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
const { showInPageHeader, goBack } = useBackRoute();
|
||||||
|
|
||||||
const name = useRoomName(space);
|
const name = useRoomName(space);
|
||||||
const avatarMxc = useRoomAvatar(space);
|
const avatarMxc = useRoomAvatar(space);
|
||||||
@@ -270,6 +301,13 @@ export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: Lobb
|
|||||||
<>
|
<>
|
||||||
<PageHeader outlined>
|
<PageHeader outlined>
|
||||||
<Box grow="Yes" alignItems="Center" gap="300">
|
<Box grow="Yes" alignItems="Center" gap="300">
|
||||||
|
{showInPageHeader && (
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={goBack} aria-label="Back">
|
||||||
|
<Icon src={Icons.ArrowLeft} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Avatar size="300" shrink="No">
|
<Avatar size="300" shrink="No">
|
||||||
<RoomAvatar
|
<RoomAvatar
|
||||||
roomId={space.roomId}
|
roomId={space.roomId}
|
||||||
@@ -361,16 +399,12 @@ export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: Lobb
|
|||||||
return (
|
return (
|
||||||
<PageHeader className={showProfile ? undefined : css.Header} balance>
|
<PageHeader className={showProfile ? undefined : css.Header} balance>
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
{screenSize === ScreenSize.Mobile ? (
|
{showInPageHeader ? (
|
||||||
<>
|
<>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<BackRouteHandler>
|
<IconButton onClick={goBack} aria-label="Back">
|
||||||
{(onBack) => (
|
<Icon src={Icons.ArrowLeft} />
|
||||||
<IconButton onClick={onBack}>
|
</IconButton>
|
||||||
<Icon src={Icons.ArrowLeft} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</BackRouteHandler>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" justifyContent="Center">
|
<Box grow="Yes" justifyContent="Center">
|
||||||
{showProfile && (
|
{showProfile && (
|
||||||
|
|||||||
@@ -133,7 +133,11 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
}, [mx, roomItems]);
|
}, [mx, roomItems]);
|
||||||
|
|
||||||
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId));
|
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId));
|
||||||
if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) {
|
// Only hide when we know the user cannot edit children. If power levels are
|
||||||
|
// still loading (spacePermissions undefined), keep inaccessible rows visible
|
||||||
|
// so admins can clean them up.
|
||||||
|
const canEditChild = spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
|
||||||
|
if (spacePermissions && !canEditChild) {
|
||||||
// hide unknown rooms for normal user
|
// hide unknown rooms for normal user
|
||||||
childItems = childItems?.filter((i) => {
|
childItems = childItems?.filter((i) => {
|
||||||
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
|
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||||
import { useRooms } from '../../state/hooks/roomList';
|
import { useRooms, useDirects } from '../../state/hooks/roomList';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||||
@@ -53,7 +53,13 @@ export function MessageSearch({
|
|||||||
}: MessageSearchProps) {
|
}: MessageSearchProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
const nonDirectRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||||
|
const directRooms = useDirects(mx, allRoomsAtom, mDirects);
|
||||||
|
// Include DMs — previously useRooms alone stripped them and broke DM search
|
||||||
|
const allRooms = useMemo(
|
||||||
|
() => [...nonDirectRooms, ...directRooms],
|
||||||
|
[nonDirectRooms, directRooms]
|
||||||
|
);
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
ISearchRequestBody,
|
ISearchRequestBody,
|
||||||
ISearchResponse,
|
ISearchResponse,
|
||||||
ISearchResult,
|
ISearchResult,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
SearchOrderBy,
|
SearchOrderBy,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
@@ -26,6 +29,12 @@ export type SearchResult = {
|
|||||||
groups: ResultGroup[];
|
groups: ResultGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_CONTEXT: IResultContext = {
|
||||||
|
events_before: [],
|
||||||
|
events_after: [],
|
||||||
|
profile_info: {},
|
||||||
|
};
|
||||||
|
|
||||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
|||||||
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
||||||
const roomEvents = result.search_categories.room_events;
|
const roomEvents = result.search_categories.room_events;
|
||||||
|
|
||||||
const searchResult: SearchResult = {
|
return {
|
||||||
nextToken: roomEvents?.next_batch,
|
nextToken: roomEvents?.next_batch,
|
||||||
highlights: roomEvents?.highlights ?? [],
|
highlights: roomEvents?.highlights ?? [],
|
||||||
groups: groupSearchResult(roomEvents?.results ?? []),
|
groups: groupSearchResult(roomEvents?.results ?? []),
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return searchResult;
|
const eventToSearchEvent = (event: MatrixEvent, roomId: string): IEventWithRoomId | undefined => {
|
||||||
|
const eventId = event.getId();
|
||||||
|
if (!eventId) return undefined;
|
||||||
|
|
||||||
|
const content = event.getClearContent() ?? event.getContent();
|
||||||
|
return {
|
||||||
|
event_id: eventId,
|
||||||
|
type: event.getWireType() === 'm.room.encrypted' ? 'm.room.message' : event.getType(),
|
||||||
|
sender: event.getSender() ?? '',
|
||||||
|
origin_server_ts: event.getTs(),
|
||||||
|
content,
|
||||||
|
room_id: roomId,
|
||||||
|
unsigned: event.getUnsigned(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchableBody = (event: MatrixEvent): string | undefined => {
|
||||||
|
if (event.isRedacted()) return undefined;
|
||||||
|
|
||||||
|
// After decryption, clear content is available even if wire type was encrypted
|
||||||
|
const content = event.getClearContent() ?? event.getContent();
|
||||||
|
if (!content || typeof content !== 'object') return undefined;
|
||||||
|
|
||||||
|
// Skip still-encrypted payloads
|
||||||
|
if (event.isEncrypted() && !event.isDecryptionFailure() && !event.getClearContent()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgType = content.msgtype;
|
||||||
|
if (msgType && msgType !== 'm.text' && msgType !== 'm.notice' && msgType !== 'm.emote') {
|
||||||
|
// Still allow filename / body on media
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = typeof content.body === 'string' ? content.body : undefined;
|
||||||
|
const formatted =
|
||||||
|
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
|
||||||
|
return [body, formatted].filter(Boolean).join('\n') || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightsFromTerm = (term: string): string[] =>
|
||||||
|
term
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search decrypted timeline events already loaded for a room.
|
||||||
|
* Server-side search cannot match E2EE message bodies.
|
||||||
|
*/
|
||||||
|
const searchLocalRoomTimeline = (room: Room, term: string): ResultItem[] => {
|
||||||
|
const needle = term.trim().toLowerCase();
|
||||||
|
if (!needle) return [];
|
||||||
|
|
||||||
|
const events = room.getLiveTimeline().getEvents();
|
||||||
|
const matches: ResultItem[] = [];
|
||||||
|
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const event = events[i];
|
||||||
|
const haystack = getSearchableBody(event);
|
||||||
|
if (!haystack || !haystack.toLowerCase().includes(needle)) continue;
|
||||||
|
|
||||||
|
const searchEvent = eventToSearchEvent(event, room.roomId);
|
||||||
|
if (!searchEvent) continue;
|
||||||
|
|
||||||
|
matches.push({
|
||||||
|
rank: 1,
|
||||||
|
event: searchEvent,
|
||||||
|
context: EMPTY_CONTEXT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEncryptedRoom = (mx: MatrixClient, roomId: string): boolean => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
return !!room?.hasEncryptionStateEvent();
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchLocalRooms = (mx: MatrixClient, roomIds: string[], term: string): SearchResult => {
|
||||||
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
|
roomIds.forEach((roomId) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
const items = searchLocalRoomTimeline(room, term);
|
||||||
|
if (items.length > 0) {
|
||||||
|
groups.push({ roomId, items });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
highlights: highlightsFromTerm(term),
|
||||||
|
groups,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageSearchParams = {
|
export type MessageSearchParams = {
|
||||||
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
|
|||||||
rooms?: string[];
|
rooms?: string[];
|
||||||
senders?: string[];
|
senders?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { term, order, rooms, senders } = params;
|
const { term, order, rooms, senders } = params;
|
||||||
|
|
||||||
const searchMessages = useCallback(
|
const searchMessages = useCallback(
|
||||||
async (nextBatch?: string) => {
|
async (nextBatch?: string) => {
|
||||||
if (!term)
|
if (!term) {
|
||||||
return {
|
return {
|
||||||
highlights: [],
|
highlights: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
};
|
};
|
||||||
const limit = 20;
|
}
|
||||||
|
|
||||||
|
const scopedRooms = rooms?.filter(Boolean) ?? [];
|
||||||
|
const encryptedScoped =
|
||||||
|
scopedRooms.length > 0 && scopedRooms.every((roomId) => isEncryptedRoom(mx, roomId));
|
||||||
|
|
||||||
|
// Encrypted-only scope: server cannot see bodies — search loaded timeline locally
|
||||||
|
if (encryptedScoped && !nextBatch) {
|
||||||
|
return searchLocalRooms(mx, scopedRooms, term);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = 20;
|
||||||
const requestBody: ISearchRequestBody = {
|
const requestBody: ISearchRequestBody = {
|
||||||
search_categories: {
|
search_categories: {
|
||||||
room_events: {
|
room_events: {
|
||||||
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const r = await mx.search({
|
try {
|
||||||
body: requestBody,
|
const r = await mx.search({
|
||||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
body: requestBody,
|
||||||
});
|
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||||
return parseSearchResult(r);
|
});
|
||||||
|
const parsed = parseSearchResult(r);
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.groups.length === 0 &&
|
||||||
|
!nextBatch &&
|
||||||
|
scopedRooms.length > 0 &&
|
||||||
|
scopedRooms.some((roomId) => isEncryptedRoom(mx, roomId))
|
||||||
|
) {
|
||||||
|
return searchLocalRooms(
|
||||||
|
mx,
|
||||||
|
scopedRooms.filter((id) => isEncryptedRoom(mx, id)),
|
||||||
|
term
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
if (!nextBatch && scopedRooms.length > 0) {
|
||||||
|
return searchLocalRooms(mx, scopedRooms, term);
|
||||||
|
}
|
||||||
|
throw new Error('Message search failed');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mx, term, order, rooms, senders]
|
[mx, term, order, rooms, senders]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
|
|||||||
className={classNames(css.CategoryButton, className)}
|
className={classNames(css.CategoryButton, className)}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
data-nav-category-btn=""
|
||||||
before={
|
before={
|
||||||
<Icon
|
<Icon
|
||||||
className={css.CategoryButtonIcon}
|
className={css.CategoryButtonIcon}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
|||||||
import { TypingIndicator } from '../../components/typing-indicator';
|
import { TypingIndicator } from '../../components/typing-indicator';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
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';
|
||||||
@@ -46,6 +46,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
|
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
|
||||||
|
import { PresenceAvatar } from '../../components/presence';
|
||||||
|
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -289,13 +291,17 @@ export function RoomNavItem({
|
|||||||
const avatarUrl = direct
|
const avatarUrl = direct
|
||||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
||||||
|
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const isDirect = Boolean(direct || mDirects.has(room.roomId));
|
||||||
|
const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined;
|
||||||
|
const dmPresence = useUserPresence(dmUserId ?? '');
|
||||||
|
|
||||||
const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl);
|
const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl);
|
||||||
const typingMember = useRoomTypingMember(room.roomId).filter(
|
const typingMember = useRoomTypingMember(room.roomId).filter(
|
||||||
(receipt) => receipt.userId !== mx.getUserId()
|
(receipt) => receipt.userId !== mx.getUserId()
|
||||||
);
|
);
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
|
||||||
|
|
||||||
// Get first participated thread for preview
|
// Get first participated thread for preview
|
||||||
const firstThreadPreview = useMemo(() => {
|
const firstThreadPreview = useMemo(() => {
|
||||||
@@ -317,7 +323,7 @@ export function RoomNavItem({
|
|||||||
|
|
||||||
// Get parent space for DMs
|
// Get parent space for DMs
|
||||||
const parentSpaceInfo = (() => {
|
const parentSpaceInfo = (() => {
|
||||||
if (!direct || !mDirects.has(room.roomId)) return undefined;
|
if (!isDirect || !mDirects.has(room.roomId)) return undefined;
|
||||||
|
|
||||||
const orphanParents = getOrphanParents(roomToParents, room.roomId);
|
const orphanParents = getOrphanParents(roomToParents, room.roomId);
|
||||||
if (orphanParents.length === 0) return undefined;
|
if (orphanParents.length === 0) return undefined;
|
||||||
@@ -383,29 +389,60 @@ export function RoomNavItem({
|
|||||||
<NavLink to={linkPath} onClick={handleRoomClick}>
|
<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 &&
|
||||||
<Avatar size="200" radii="400">
|
(isDirect ? (
|
||||||
{showAvatar || avatarUrl ? (
|
<PresenceAvatar
|
||||||
<RoomAvatar
|
presence={
|
||||||
roomId={room.roomId}
|
dmUserId && dmUserId !== mx.getSafeUserId()
|
||||||
src={avatarUrl}
|
? dmPresence?.presence ?? Presence.Offline
|
||||||
alt={room.name}
|
: Presence.Offline
|
||||||
renderFallback={() => (
|
}
|
||||||
<Text as="span" size="H6">
|
>
|
||||||
{nameInitials(room.name)}
|
<Avatar size="200" radii="400">
|
||||||
</Text>
|
{showAvatar || avatarUrl ? (
|
||||||
)}
|
<RoomAvatar
|
||||||
/>
|
roomId={room.roomId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={room.name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text as="span" size="H6">
|
||||||
|
{nameInitials(room.name)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RoomIcon
|
||||||
|
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||||
|
filled={selected}
|
||||||
|
size="100"
|
||||||
|
joinRule={room.getJoinRule()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
</PresenceAvatar>
|
||||||
) : (
|
) : (
|
||||||
<RoomIcon
|
<Avatar size="200" radii="400">
|
||||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
{showAvatar || avatarUrl ? (
|
||||||
filled={selected}
|
<RoomAvatar
|
||||||
size="100"
|
roomId={room.roomId}
|
||||||
joinRule={room.getJoinRule()}
|
src={avatarUrl}
|
||||||
/>
|
alt={room.name}
|
||||||
)}
|
renderFallback={() => (
|
||||||
</Avatar>
|
<Text as="span" size="H6">
|
||||||
)}
|
{nameInitials(room.name)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RoomIcon
|
||||||
|
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||||
|
filled={selected}
|
||||||
|
size="100"
|
||||||
|
joinRule={room.getJoinRule()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
|
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
|
||||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||||
{room.name}
|
{room.name}
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ export function UnjoinedSubRoomItem({ roomId, depth, isLast = false }: UnjoinedS
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{ paddingLeft, padding: `${config.space.S100} ${config.space.S200}`, minHeight: '1.5rem' }}
|
data-nav-depth={depth}
|
||||||
|
style={{
|
||||||
|
paddingLeft,
|
||||||
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
|
minHeight: '1.5rem',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="200"
|
gap="200"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Box, Line } from 'folds';
|
|||||||
import { decodeRouteParam } from '../../pages/pathUtils';
|
import { decodeRouteParam } from '../../pages/pathUtils';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
import { RoomView } from './RoomView';
|
import { RoomView } from './RoomView';
|
||||||
import { MembersDrawer } from './MembersDrawer';
|
import { MembersDrawer } from './MembersDrawer';
|
||||||
|
import { MediaDrawer } from './room-media-menu';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -16,6 +17,7 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||||
|
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||||
import { isForum } from '../../utils/room';
|
import { isForum } from '../../utils/room';
|
||||||
import { ForumRoomView } from './ForumRoomView';
|
import { ForumRoomView } from './ForumRoomView';
|
||||||
|
|
||||||
@@ -26,13 +28,18 @@ export function Room() {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
||||||
|
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [isPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const [isMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
const markAsRead = useMarkAsRead(mx);
|
const markAsRead = useMarkAsRead(mx);
|
||||||
const forumRoom = isForum(room);
|
const forumRoom = isForum(room);
|
||||||
|
const skinny = screenSize !== ScreenSize.Desktop;
|
||||||
|
const showMediaSolo = skinny && isMediaDrawer;
|
||||||
|
const showRightDrawer =
|
||||||
|
!skinny && (isPeopleDrawer || isMediaDrawer);
|
||||||
|
|
||||||
// Update titlebar with current room ID
|
// Update titlebar with current room ID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,11 +62,21 @@ export function Room() {
|
|||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
|
{!showMediaSolo &&
|
||||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
(forumRoom ? (
|
||||||
|
<ForumRoomView room={room} eventId={eventId} />
|
||||||
|
) : (
|
||||||
|
<RoomView room={room} eventId={eventId} />
|
||||||
|
))}
|
||||||
|
{showMediaSolo && <MediaDrawer room={room} solo />}
|
||||||
|
{!showMediaSolo && showRightDrawer && (
|
||||||
<>
|
<>
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
{isMediaDrawer ? (
|
||||||
|
<MediaDrawer room={room} />
|
||||||
|
) : (
|
||||||
|
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
17
src/app/features/room/RoomInput.css.ts
Normal file
17
src/app/features/room/RoomInput.css.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config } from 'folds';
|
||||||
|
import * as editorCss from '../../components/editor/Editor.css';
|
||||||
|
|
||||||
|
export const RoomInputWrap = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Notebook-flat composer chrome — Stationery only (other themes keep Editor inset border) */
|
||||||
|
globalStyle(`.stationery ${RoomInputWrap} .${editorCss.Editor}`, {
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: 'none',
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderLeft: 'none',
|
||||||
|
borderRight: 'none',
|
||||||
|
borderBottom: 'none',
|
||||||
|
});
|
||||||
@@ -10,7 +10,6 @@ import React, {
|
|||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
|
||||||
import { Transforms, Editor } from 'slate';
|
import { Transforms, Editor } from 'slate';
|
||||||
import { Box, Dialog, IconButton, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, color, config, toRem } from 'folds';
|
import { Box, Dialog, IconButton, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
@@ -32,6 +31,7 @@ import {
|
|||||||
EmoticonAutocomplete,
|
EmoticonAutocomplete,
|
||||||
createEmoticonElement,
|
createEmoticonElement,
|
||||||
moveCursor,
|
moveCursor,
|
||||||
|
safeFocusEditor,
|
||||||
resetEditorHistory,
|
resetEditorHistory,
|
||||||
customHtmlEqualsPlainText,
|
customHtmlEqualsPlainText,
|
||||||
trimCustomHtml,
|
trimCustomHtml,
|
||||||
@@ -45,7 +45,6 @@ import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
|||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import {
|
import {
|
||||||
TUploadContent,
|
TUploadContent,
|
||||||
encryptFile,
|
|
||||||
getImageInfo,
|
getImageInfo,
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
mxcUrlToHttp,
|
mxcUrlToHttp,
|
||||||
@@ -54,6 +53,7 @@ import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
|||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||||
import { useFileDropZone } from '../../hooks/useFileDrop';
|
import { useFileDropZone } from '../../hooks/useFileDrop';
|
||||||
|
import { useRoomUploadFiles } from '../../hooks/useRoomUploadFiles';
|
||||||
import {
|
import {
|
||||||
TUploadItem,
|
TUploadItem,
|
||||||
TUploadMetadata,
|
TUploadMetadata,
|
||||||
@@ -76,7 +76,6 @@ import {
|
|||||||
createUploadFamilyObserverAtom,
|
createUploadFamilyObserverAtom,
|
||||||
} from '../../state/upload';
|
} from '../../state/upload';
|
||||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||||
import { safeFile } from '../../utils/mimeTypes';
|
|
||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -101,13 +100,25 @@ import colorMXID from '../../../util/colorMXID';
|
|||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
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';
|
||||||
|
import * as css from './RoomInput.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -212,42 +223,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
}, [commands]);
|
}, [commands]);
|
||||||
|
|
||||||
|
const { handleFiles: enqueueFiles } = useRoomUploadFiles(room);
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
setUploadBoard(true);
|
setUploadBoard(true);
|
||||||
const safeFiles = files.map(safeFile);
|
await enqueueFiles(files);
|
||||||
const fileItems: TUploadItem[] = [];
|
|
||||||
|
|
||||||
if (room.hasEncryptionStateEvent()) {
|
|
||||||
const encryptFiles = fulfilledPromiseSettledResult(
|
|
||||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
|
||||||
);
|
|
||||||
encryptFiles.forEach((ef) =>
|
|
||||||
fileItems.push({
|
|
||||||
...ef,
|
|
||||||
metadata: {
|
|
||||||
markedAsSpoiler: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
safeFiles.forEach((f) =>
|
|
||||||
fileItems.push({
|
|
||||||
file: f,
|
|
||||||
originalFile: f,
|
|
||||||
encInfo: undefined,
|
|
||||||
metadata: {
|
|
||||||
markedAsSpoiler: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setSelectedFiles({
|
|
||||||
type: 'PUT',
|
|
||||||
item: fileItems,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[setSelectedFiles, room]
|
[enqueueFiles]
|
||||||
);
|
);
|
||||||
const pickFile = useFilePicker(handleFiles, true);
|
const pickFile = useFilePicker(handleFiles, true);
|
||||||
const handlePaste = useFilePasteHandler(handleFiles);
|
const handlePaste = useFilePasteHandler(handleFiles);
|
||||||
@@ -306,12 +288,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);
|
||||||
@@ -559,7 +563,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
|
|
||||||
const handleCloseAutocomplete = useCallback(() => {
|
const handleCloseAutocomplete = useCallback(() => {
|
||||||
setAutocompleteQuery(undefined);
|
setAutocompleteQuery(undefined);
|
||||||
ReactEditor.focus(editor);
|
safeFocusEditor(editor);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
||||||
@@ -584,7 +588,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref} className={css.RoomInputWrap}>
|
||||||
{selectedFiles.length > 0 && (
|
{selectedFiles.length > 0 && (
|
||||||
<UploadBoard
|
<UploadBoard
|
||||||
header={
|
header={
|
||||||
@@ -690,8 +694,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
borderTopLeftRadius: config.radii.R400,
|
borderRadius: isStationeryTheme(theme) ? 0 : undefined,
|
||||||
borderTopRightRadius: config.radii.R400,
|
borderTopLeftRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||||
|
borderTopRightRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||||
marginBottom: config.space.S100,
|
marginBottom: config.space.S100,
|
||||||
}}
|
}}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
@@ -764,7 +769,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon src={Icons.PlusCircle} />
|
<Icon src={Icons.PlusCircle} size="200" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<PluginButtonSlot location="composer-actions" />
|
<PluginButtonSlot location="composer-actions" />
|
||||||
</>
|
</>
|
||||||
@@ -777,7 +782,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => setToolbar(!toolbar)}
|
onClick={() => setToolbar(!toolbar)}
|
||||||
>
|
>
|
||||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} size="200" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<UseStateProvider initial={undefined}>
|
<UseStateProvider initial={undefined}>
|
||||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||||
@@ -803,7 +808,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
requestClose={() => {
|
requestClose={() => {
|
||||||
setEmojiBoardTab((t) => {
|
setEmojiBoardTab((t) => {
|
||||||
if (t) {
|
if (t) {
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
if (!mobileOrTablet()) safeFocusEditor(editor);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
@@ -822,6 +827,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
src={Icons.Smile}
|
src={Icons.Smile}
|
||||||
|
size="200"
|
||||||
filled={emojiBoardTab === EmojiBoardTab.Emoji}
|
filled={emojiBoardTab === EmojiBoardTab.Emoji}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -830,7 +836,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
<PluginButtonSlot location="text-composer-toolbar" />
|
<PluginButtonSlot location="text-composer-toolbar" />
|
||||||
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
||||||
<Icon src={Icons.Send} />
|
<Icon src={Icons.Send} size="200" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export const RoomInputPlaceholder = style({
|
|||||||
minHeight: toRem(48),
|
minHeight: toRem(48),
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export const TimelineFloat = recipe({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 1,
|
// Above timeline media carousels / sticky message chrome
|
||||||
|
zIndex: 10,
|
||||||
minWidth: 'max-content',
|
minWidth: 'max-content',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ import {
|
|||||||
useIntersectionObserver,
|
useIntersectionObserver,
|
||||||
} from '../../hooks/useIntersectionObserver';
|
} from '../../hooks/useIntersectionObserver';
|
||||||
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||||
import { useDebounce } from '../../hooks/useDebounce';
|
|
||||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
import * as css from './RoomTimeline.css';
|
import * as css from './RoomTimeline.css';
|
||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
@@ -103,6 +102,14 @@ import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalSc
|
|||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { useRoomUnread } from '../../state/hooks/unread';
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
|
import {
|
||||||
|
clearRoomReturnAnchor,
|
||||||
|
clearRoomScrollState,
|
||||||
|
getRoomReturnAnchor,
|
||||||
|
getRoomScrollState,
|
||||||
|
saveRoomScrollState,
|
||||||
|
setRoomReturnAnchor,
|
||||||
|
} from '../../state/roomScrollCache';
|
||||||
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
||||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
@@ -620,6 +627,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
|
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
|
||||||
|
|
||||||
|
const savedScrollRef = useRef(
|
||||||
|
eventId || scrollToLatest ? undefined : getRoomScrollState(room.roomId)
|
||||||
|
);
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
const [unreadInfo, setUnreadInfo] = useState(() =>
|
const [unreadInfo, setUnreadInfo] = useState(() =>
|
||||||
@@ -631,7 +642,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
|
|
||||||
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
||||||
const [atBottom, setAtBottom] = useState<boolean>(true);
|
const [atBottom, setAtBottom] = useState<boolean>(
|
||||||
|
() => savedScrollRef.current?.atBottom ?? true
|
||||||
|
);
|
||||||
const atBottomRef = useRef(atBottom);
|
const atBottomRef = useRef(atBottom);
|
||||||
atBottomRef.current = atBottom;
|
atBottomRef.current = atBottom;
|
||||||
|
|
||||||
@@ -639,16 +652,54 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
sender: string;
|
sender: string;
|
||||||
content: string;
|
content: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [returnToEventId, setReturnToEventIdState] = useState<string | null>(
|
||||||
|
() => getRoomReturnAnchor(room.roomId) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const setReturnToEventId = useCallback(
|
||||||
|
(eventId: string | null) => {
|
||||||
|
if (eventId) setRoomReturnAnchor(room.roomId, eventId);
|
||||||
|
else clearRoomReturnAnchor(room.roomId);
|
||||||
|
setReturnToEventIdState(eventId);
|
||||||
|
},
|
||||||
|
[room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const timelineContentRef = useRef<HTMLDivElement>(null);
|
const timelineContentRef = useRef<HTMLDivElement>(null);
|
||||||
const timelineContentHeightRef = useRef(0);
|
const timelineContentHeightRef = useRef(0);
|
||||||
|
const restoringScrollRef = useRef(false);
|
||||||
const scrollToBottomRef = useRef({
|
const scrollToBottomRef = useRef({
|
||||||
count: 0,
|
count: 0,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getViewportAnchorEventId = useCallback((): string | undefined => {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (!scrollEl) return undefined;
|
||||||
|
|
||||||
|
const scrollRect = scrollEl.getBoundingClientRect();
|
||||||
|
const centerY = scrollRect.top + scrollRect.height / 2;
|
||||||
|
const items = scrollEl.querySelectorAll<HTMLElement>('[data-message-id]');
|
||||||
|
|
||||||
|
let bestId: string | undefined;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
|
||||||
|
items.forEach((el) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) return;
|
||||||
|
const mid = (rect.top + rect.bottom) / 2;
|
||||||
|
const dist = Math.abs(mid - centerY);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
bestId = el.getAttribute('data-message-id') ?? undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bestId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [focusItem, setFocusItem] = useState<
|
const [focusItem, setFocusItem] = useState<
|
||||||
| {
|
| {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -680,9 +731,49 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
);
|
);
|
||||||
const parseMemberEvent = useMemberEventParser();
|
const parseMemberEvent = useMemberEventParser();
|
||||||
|
|
||||||
const [timeline, setTimeline] = useState<Timeline>(() =>
|
const [timeline, setTimeline] = useState<Timeline>(() => {
|
||||||
eventId ? getEmptyTimeline() : getInitialTimeline(room)
|
if (eventId) return getEmptyTimeline();
|
||||||
|
const initial = getInitialTimeline(room);
|
||||||
|
const saved = savedScrollRef.current;
|
||||||
|
if (!saved?.range) return initial;
|
||||||
|
|
||||||
|
const evLength = getTimelinesEventsCount(initial.linkedTimelines);
|
||||||
|
if (evLength === 0) return initial;
|
||||||
|
|
||||||
|
const start = Math.min(saved.range.start, Math.max(0, evLength - 1));
|
||||||
|
const end = Math.min(Math.max(start + 1, saved.range.end), evLength);
|
||||||
|
|
||||||
|
return { ...initial, range: { start, end } };
|
||||||
|
});
|
||||||
|
const timelineRef = useRef(timeline);
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
|
||||||
|
const getRangeCenterEventId = useCallback((): string | undefined => {
|
||||||
|
const { linkedTimelines, range } = timelineRef.current;
|
||||||
|
if (range.end <= range.start) return undefined;
|
||||||
|
const mid = Math.floor((range.start + range.end - 1) / 2);
|
||||||
|
const [tm, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, mid);
|
||||||
|
if (!tm) return undefined;
|
||||||
|
return getTimelineEvent(tm, getTimelineRelativeIndex(mid, baseIndex))?.getId();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persistTimelineScroll = useCallback(
|
||||||
|
(roomId: string) => {
|
||||||
|
if (eventId) return;
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (!scrollEl) return;
|
||||||
|
|
||||||
|
const distanceFromBottom =
|
||||||
|
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
|
||||||
|
saveRoomScrollState(roomId, {
|
||||||
|
top: scrollEl.scrollTop,
|
||||||
|
atBottom: distanceFromBottom <= 50,
|
||||||
|
range: timelineRef.current.range,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[eventId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
|
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
|
||||||
const liveTimelineLinked =
|
const liveTimelineLinked =
|
||||||
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
|
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
|
||||||
@@ -693,6 +784,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
|
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
|
||||||
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
||||||
|
|
||||||
|
// Historical / non-live windows are never "at bottom" of the room.
|
||||||
|
// When we return to the live end, re-check scroll — IntersectionObserver may
|
||||||
|
// not re-fire if the anchor stayed intersecting across the transition.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!liveTimelineLinked || !rangeAtEnd) {
|
||||||
|
setAtBottom(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl && isScrolledToBottom(scrollEl, 100)) {
|
||||||
|
setAtBottom(true);
|
||||||
|
}
|
||||||
|
}, [liveTimelineLinked, rangeAtEnd]);
|
||||||
|
|
||||||
const handleTimelinePagination = useTimelinePagination(
|
const handleTimelinePagination = useTimelinePagination(
|
||||||
mx,
|
mx,
|
||||||
timeline,
|
timeline,
|
||||||
@@ -757,6 +862,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||||
const isBottomPinnedEvent =
|
const isBottomPinnedEvent =
|
||||||
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
||||||
|
const wasAwayFromBottom = !atBottomRef.current;
|
||||||
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
||||||
|
|
||||||
// Always update timeline with new message
|
// Always update timeline with new message
|
||||||
@@ -777,6 +883,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sending while scrolled up jumps to latest — offer return to previous spot
|
||||||
|
if (wasAwayFromBottom && isBottomPinnedEvent) {
|
||||||
|
const anchorEventId =
|
||||||
|
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||||
|
if (anchorEventId) {
|
||||||
|
setReturnToEventId(anchorEventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (!document.hasFocus() && !unreadInfo) {
|
} else if (!document.hasFocus() && !unreadInfo) {
|
||||||
// Show unread notification for other users' messages when unfocused
|
// Show unread notification for other users' messages when unfocused
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
setUnreadInfo(getRoomUnreadInfo(room));
|
||||||
@@ -803,7 +917,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, room, unreadInfo, hideActivity, markAsRead]
|
[mx, room, unreadInfo, hideActivity, markAsRead, eventId, getViewportAnchorEventId, getRangeCenterEventId, setReturnToEventId]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -861,6 +975,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const scrollElement = getScrollElement();
|
const scrollElement = getScrollElement();
|
||||||
if (!editorBaseEntry || !scrollElement) return;
|
if (!editorBaseEntry || !scrollElement) return;
|
||||||
|
|
||||||
|
if (restoringScrollRef.current) return;
|
||||||
|
|
||||||
if (isScrolledToBottom(scrollElement)) {
|
if (isScrolledToBottom(scrollElement)) {
|
||||||
autoScrollToBottom(scrollElement, false, true);
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
}
|
}
|
||||||
@@ -882,6 +998,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const previousHeight = timelineContentHeightRef.current;
|
const previousHeight = timelineContentHeightRef.current;
|
||||||
timelineContentHeightRef.current = nextHeight;
|
timelineContentHeightRef.current = nextHeight;
|
||||||
|
|
||||||
|
if (restoringScrollRef.current) {
|
||||||
|
const saved = savedScrollRef.current;
|
||||||
|
if (saved) {
|
||||||
|
if (saved.atBottom) {
|
||||||
|
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||||
|
} else {
|
||||||
|
scrollElement.scrollTop = saved.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
|
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -904,30 +1032,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [room, hideActivity, markAsRead]);
|
}, [room, hideActivity, markAsRead]);
|
||||||
|
|
||||||
const debounceSetAtBottom = useDebounce(
|
|
||||||
useCallback((entry: IntersectionObserverEntry) => {
|
|
||||||
if (!entry.isIntersecting) {
|
|
||||||
setAtBottom(false);
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
{ wait: 1000 }
|
|
||||||
);
|
|
||||||
useIntersectionObserver(
|
useIntersectionObserver(
|
||||||
useCallback(
|
useCallback(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const target = atBottomAnchorRef.current;
|
const target = atBottomAnchorRef.current;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const targetEntry = getIntersectionObserverEntry(target, entries);
|
const targetEntry = getIntersectionObserverEntry(target, entries);
|
||||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
if (!targetEntry) return;
|
||||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
|
||||||
setAtBottom(true);
|
// Leave the live bottom immediately so Jump to Latest stays available.
|
||||||
setLatestUnreadMessage(null);
|
// Only intersection clears atBottom here; !atLiveEnd is handled by the
|
||||||
if (document.hasFocus()) {
|
// liveTimelineLinked/rangeAtEnd effect so we don't stick false when the
|
||||||
tryAutoMarkAsRead();
|
// live end reconnects without a new intersection event.
|
||||||
}
|
if (!targetEntry.isIntersecting) {
|
||||||
|
setAtBottom(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!atLiveEndRef.current) return;
|
||||||
|
|
||||||
|
setAtBottom(true);
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
|
if (document.hasFocus()) {
|
||||||
|
tryAutoMarkAsRead();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[debounceSetAtBottom, tryAutoMarkAsRead]
|
[tryAutoMarkAsRead]
|
||||||
),
|
),
|
||||||
useCallback(
|
useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -991,9 +1121,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [eventId, loadEventTimeline]);
|
}, [eventId, loadEventTimeline]);
|
||||||
|
|
||||||
|
// Restore return-to-previous when switching rooms (survives remount via cache).
|
||||||
|
useEffect(() => {
|
||||||
|
setReturnToEventIdState(getRoomReturnAnchor(room.roomId) ?? null);
|
||||||
|
}, [room.roomId]);
|
||||||
|
|
||||||
// Scroll to latest when clicking on already-selected room
|
// Scroll to latest when clicking on already-selected room
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollToLatest) {
|
if (scrollToLatest) {
|
||||||
|
clearRoomScrollState(room.roomId);
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
setUnreadInfo(undefined);
|
setUnreadInfo(undefined);
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
@@ -1003,12 +1139,87 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [scrollToLatest, room]);
|
}, [scrollToLatest, room]);
|
||||||
|
|
||||||
// Scroll to bottom on initial timeline load
|
// Remember scroll position when leaving a room (switch DM, nav away, etc.).
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const roomId = room.roomId;
|
||||||
|
return () => {
|
||||||
|
persistTimelineScroll(roomId);
|
||||||
|
};
|
||||||
|
}, [room.roomId, persistTimelineScroll]);
|
||||||
|
|
||||||
|
// Keep scroll cache updated while reading (useEffect cleanup runs after DOM teardown).
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (!scrollEl || eventId) return undefined;
|
||||||
|
|
||||||
|
const roomId = room.roomId;
|
||||||
|
let raf = 0;
|
||||||
|
const onScroll = () => {
|
||||||
|
if (restoringScrollRef.current) return;
|
||||||
|
window.cancelAnimationFrame(raf);
|
||||||
|
raf = window.requestAnimationFrame(() => {
|
||||||
|
persistTimelineScroll(roomId);
|
||||||
|
// Keep Jump to Latest in sync with real scroll position when on the live end.
|
||||||
|
// IntersectionObserver alone can miss transitions and leave atBottom stuck.
|
||||||
|
if (atLiveEndRef.current) {
|
||||||
|
const at = isScrolledToBottom(scrollEl, 100);
|
||||||
|
setAtBottom(at);
|
||||||
|
if (at) setLatestUnreadMessage(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf);
|
||||||
|
scrollEl.removeEventListener('scroll', onScroll);
|
||||||
|
};
|
||||||
|
}, [room.roomId, eventId, persistTimelineScroll]);
|
||||||
|
|
||||||
|
// Restore saved scroll or default to bottom on first open.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl) {
|
if (!scrollEl) return undefined;
|
||||||
|
|
||||||
|
if (scrollToLatest) {
|
||||||
autoScrollToBottom(scrollEl, false, true);
|
autoScrollToBottom(scrollEl, false, true);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saved = savedScrollRef.current;
|
||||||
|
if (!saved) {
|
||||||
|
autoScrollToBottom(scrollEl, false, true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoringScrollRef.current = true;
|
||||||
|
setAtBottom(saved.atBottom);
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
if (saved.atBottom) {
|
||||||
|
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||||
|
} else {
|
||||||
|
scrollEl.scrollTop = saved.top;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
restore();
|
||||||
|
const raf1 = window.requestAnimationFrame(() => {
|
||||||
|
restore();
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
restore();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
restore();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
restoringScrollRef.current = false;
|
||||||
|
}, 150);
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf1);
|
||||||
|
restoringScrollRef.current = false;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// if live timeline is linked and unreadInfo change
|
// if live timeline is linked and unreadInfo change
|
||||||
@@ -1052,7 +1263,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
// scroll to bottom of timeline
|
// scroll to bottom of timeline
|
||||||
const scrollToBottomCount = scrollToBottomRef.current.count;
|
const scrollToBottomCount = scrollToBottomRef.current.count;
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (scrollToBottomCount > 0) {
|
if (scrollToBottomCount > 0 && !restoringScrollRef.current) {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl) {
|
if (scrollEl) {
|
||||||
autoScrollToBottom(
|
autoScrollToBottom(
|
||||||
@@ -1060,6 +1271,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
scrollToBottomRef.current.smooth,
|
scrollToBottomRef.current.smooth,
|
||||||
scrollToBottomRef.current.force
|
scrollToBottomRef.current.force
|
||||||
);
|
);
|
||||||
|
if (atLiveEndRef.current) {
|
||||||
|
setAtBottom(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [scrollToBottomCount]);
|
}, [scrollToBottomCount]);
|
||||||
@@ -1110,16 +1324,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}, [room, hour24Clock]);
|
}, [room, hour24Clock]);
|
||||||
|
|
||||||
const handleJumpToLatest = () => {
|
const handleJumpToLatest = () => {
|
||||||
|
const anchorEventId =
|
||||||
|
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||||
|
if (anchorEventId) {
|
||||||
|
setReturnToEventId(anchorEventId);
|
||||||
|
}
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
|
clearRoomScrollState(room.roomId);
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
setTimeline(getInitialTimeline(room));
|
|
||||||
|
// Already on the live timeline: only snap the virtual window to the end.
|
||||||
|
// A full getInitialTimeline() rebuild feels like a chat reload when far back.
|
||||||
|
if (!eventId && liveTimelineLinked) {
|
||||||
|
setTimeline((ct) => {
|
||||||
|
const evLength = getTimelinesEventsCount(ct.linkedTimelines);
|
||||||
|
return {
|
||||||
|
linkedTimelines: ct.linkedTimelines,
|
||||||
|
range: {
|
||||||
|
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||||
|
end: evLength,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTimeline(getInitialTimeline(room));
|
||||||
|
}
|
||||||
|
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
scrollToBottomRef.current.force = true;
|
scrollToBottomRef.current.force = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReturnToPrevious = () => {
|
||||||
|
if (!returnToEventId) return;
|
||||||
|
const targetId = returnToEventId;
|
||||||
|
setReturnToEventId(null);
|
||||||
|
handleOpenEvent(targetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearReturnToPrevious = () => {
|
||||||
|
setReturnToEventId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
const handleJumpToUnread = () => {
|
||||||
if (unreadInfo?.readUptoEventId) {
|
if (unreadInfo?.readUptoEventId) {
|
||||||
setTimeline(getEmptyTimeline());
|
setTimeline(getEmptyTimeline());
|
||||||
@@ -2266,36 +2515,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
return callSummaryJSX || eventJSX;
|
return callSummaryJSX || eventJSX;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showUnreadTop =
|
||||||
|
!!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
|
||||||
|
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||||
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
||||||
<TimelineFloat position="Top">
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
before={<Icon size="50" src={Icons.MessageUnread} />}
|
|
||||||
onClick={handleJumpToUnread}
|
|
||||||
>
|
|
||||||
<Text size="L400">Jump to Unread</Text>
|
|
||||||
</Chip>
|
|
||||||
|
|
||||||
<Chip
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
before={<Icon size="50" src={Icons.CheckTwice} />}
|
|
||||||
onClick={handleMarkAsRead}
|
|
||||||
>
|
|
||||||
<Text size="L400">Mark as Read</Text>
|
|
||||||
</Chip>
|
|
||||||
</TimelineFloat>
|
|
||||||
)}
|
|
||||||
<Scroll ref={scrollRef} visibility="Hover">
|
|
||||||
<Box
|
<Box
|
||||||
ref={timelineContentRef}
|
ref={timelineContentRef}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
justifyContent="End"
|
justifyContent="End"
|
||||||
|
data-chatroll=""
|
||||||
style={{
|
style={{
|
||||||
minHeight: '100%',
|
minHeight: '100%',
|
||||||
padding: `${config.space.S600} 0`,
|
padding: `${config.space.S600} 0`,
|
||||||
@@ -2381,10 +2612,65 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</MessageBase>
|
</MessageBase>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
<span ref={atBottomAnchorRef} />
|
{/* 1×1 so IntersectionObserver reliably detects the live bottom */}
|
||||||
|
<span
|
||||||
|
ref={atBottomAnchorRef}
|
||||||
|
aria-hidden
|
||||||
|
style={{ display: 'block', width: 1, height: 1 }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
{!atBottom && (
|
{(showUnreadTop || returnToEventId) && (
|
||||||
|
<TimelineFloat position="Top">
|
||||||
|
{returnToEventId && (
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.ArrowTop} />}
|
||||||
|
onClick={handleReturnToPrevious}
|
||||||
|
>
|
||||||
|
<Text size="L400">Return to Previous</Text>
|
||||||
|
</Chip>
|
||||||
|
<IconButton
|
||||||
|
title="Dismiss"
|
||||||
|
aria-label="Dismiss return to previous"
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={handleClearReturnToPrevious}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showUnreadTop && (
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.MessageUnread} />}
|
||||||
|
onClick={handleJumpToUnread}
|
||||||
|
>
|
||||||
|
<Text size="L400">Jump to Unread</Text>
|
||||||
|
</Chip>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.CheckTwice} />}
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
>
|
||||||
|
<Text size="L400">Mark as Read</Text>
|
||||||
|
</Chip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TimelineFloat>
|
||||||
|
)}
|
||||||
|
{!atLiveBottom && (
|
||||||
<TimelineFloat position="Bottom">
|
<TimelineFloat position="Bottom">
|
||||||
<Box direction="Column" gap="200" alignItems="Center">
|
<Box direction="Column" gap="200" alignItems="Center">
|
||||||
{latestUnreadMessage && (
|
{latestUnreadMessage && (
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { RoomTimeline } from './RoomTimeline';
|
|||||||
import { RoomViewTyping } from './RoomViewTyping';
|
import { RoomViewTyping } from './RoomViewTyping';
|
||||||
import { RoomTombstone } from './RoomTombstone';
|
import { RoomTombstone } from './RoomTombstone';
|
||||||
import { RoomInput } from './RoomInput';
|
import { RoomInput } from './RoomInput';
|
||||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
import { RoomViewFollowing } from './RoomViewFollowing';
|
||||||
|
import * as roomViewCss from './RoomViewFollowing.css';
|
||||||
import { Page } from '../../components/page';
|
import { Page } from '../../components/page';
|
||||||
import { RoomViewHeader } from './RoomViewHeader';
|
import { RoomViewHeader } from './RoomViewHeader';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
@@ -109,7 +110,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
<ThreadView room={room} threadRootId={activeThreadId} />
|
<ThreadView room={room} threadRootId={activeThreadId} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
|
||||||
<RoomTimeline
|
<RoomTimeline
|
||||||
key={roomId}
|
key={roomId}
|
||||||
room={room}
|
room={room}
|
||||||
@@ -117,40 +118,42 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
roomInputRef={roomInputRef}
|
roomInputRef={roomInputRef}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
/>
|
/>
|
||||||
<RoomViewTyping room={room} />
|
<div className={roomViewCss.RoomViewBottomFloat}>
|
||||||
|
<RoomViewTyping room={room} />
|
||||||
|
{!hideActivity && <RoomViewFollowing room={room} />}
|
||||||
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" direction="Column">
|
<Box shrink="No" direction="Column">
|
||||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
{tombstoneEvent ? (
|
||||||
{tombstoneEvent ? (
|
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||||
<RoomTombstone
|
<RoomTombstone
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
body={tombstoneEvent.getContent().body}
|
body={tombstoneEvent.getContent().body}
|
||||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<>
|
) : (
|
||||||
{canMessage && (
|
<>
|
||||||
<RoomInput
|
{canMessage && (
|
||||||
room={room}
|
<RoomInput
|
||||||
editor={editor}
|
room={room}
|
||||||
roomId={roomId}
|
editor={editor}
|
||||||
fileDropContainerRef={roomViewRef}
|
roomId={roomId}
|
||||||
ref={roomInputRef}
|
fileDropContainerRef={roomViewRef}
|
||||||
/>
|
ref={roomInputRef}
|
||||||
)}
|
/>
|
||||||
{!canMessage && (
|
)}
|
||||||
<RoomInputPlaceholder
|
{!canMessage && (
|
||||||
style={{ padding: config.space.S200 }}
|
<RoomInputPlaceholder
|
||||||
alignItems="Center"
|
style={{ padding: config.space.S200 }}
|
||||||
justifyContent="Center"
|
alignItems="Center"
|
||||||
>
|
justifyContent="Center"
|
||||||
<Text align="Center">You do not have permission to post in this room</Text>
|
>
|
||||||
</RoomInputPlaceholder>
|
<Text align="Center">You do not have permission to post in this room</Text>
|
||||||
)}
|
</RoomInputPlaceholder>
|
||||||
</>
|
)}
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import { style } from '@vanilla-extract/css';
|
|||||||
import { recipe } from '@vanilla-extract/recipes';
|
import { recipe } from '@vanilla-extract/recipes';
|
||||||
import { DefaultReset, color, config, toRem } from 'folds';
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
/** Floats typing + read receipts over the bottom of the timeline. */
|
||||||
|
export const RoomViewBottomFloat = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
export const RoomViewFollowingPlaceholder = style([
|
export const RoomViewFollowingPlaceholder = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
@@ -15,10 +29,12 @@ export const RoomViewFollowing = recipe({
|
|||||||
{
|
{
|
||||||
minHeight: toRem(28),
|
minHeight: toRem(28),
|
||||||
padding: `0 ${config.space.S400}`,
|
padding: `0 ${config.space.S400}`,
|
||||||
width: '100%',
|
marginLeft: 'auto',
|
||||||
backgroundColor: color.Surface.Container,
|
maxWidth: '50%',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
color: color.Surface.OnContainer,
|
color: color.Surface.OnContainer,
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
variants: {
|
variants: {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
|||||||
|
|
||||||
const eventId = latestEvent?.getId();
|
const eventId = latestEvent?.getId();
|
||||||
|
|
||||||
|
if (names.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{eventId && (
|
{eventId && (
|
||||||
@@ -56,64 +60,58 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
|||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
<Box
|
<Box
|
||||||
as={names.length > 0 ? 'button' : 'div'}
|
as="button"
|
||||||
onClick={names.length > 0 ? () => setOpen(true) : undefined}
|
onClick={() => setOpen(true)}
|
||||||
className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
|
className={classNames(css.RoomViewFollowing({ clickable: true }), className)}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="End"
|
justifyContent="End"
|
||||||
gap="200"
|
gap="200"
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{names.length > 0 && (
|
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
|
||||||
<>
|
<Text size="T300" truncate>
|
||||||
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
|
{names.length === 1 && <b>{names[0]}</b>}
|
||||||
<Text size="T300" truncate>
|
{names.length === 2 && (
|
||||||
{names.length === 1 && (
|
<>
|
||||||
<b>{names[0]}</b>
|
<b>{names[0]}</b>
|
||||||
)}
|
<Text as="span" size="Inherit" priority="300">
|
||||||
{names.length === 2 && (
|
{' and '}
|
||||||
<>
|
</Text>
|
||||||
<b>{names[0]}</b>
|
<b>{names[1]}</b>
|
||||||
<Text as="span" size="Inherit" priority="300">
|
</>
|
||||||
{' and '}
|
)}
|
||||||
</Text>
|
{names.length === 3 && (
|
||||||
<b>{names[1]}</b>
|
<>
|
||||||
</>
|
<b>{names[0]}</b>
|
||||||
)}
|
<Text as="span" size="Inherit" priority="300">
|
||||||
{names.length === 3 && (
|
{', '}
|
||||||
<>
|
</Text>
|
||||||
<b>{names[0]}</b>
|
<b>{names[1]}</b>
|
||||||
<Text as="span" size="Inherit" priority="300">
|
<Text as="span" size="Inherit" priority="300">
|
||||||
{', '}
|
{' and '}
|
||||||
</Text>
|
</Text>
|
||||||
<b>{names[1]}</b>
|
<b>{names[2]}</b>
|
||||||
<Text as="span" size="Inherit" priority="300">
|
</>
|
||||||
{' and '}
|
)}
|
||||||
</Text>
|
{names.length > 3 && (
|
||||||
<b>{names[2]}</b>
|
<>
|
||||||
</>
|
<b>{names[0]}</b>
|
||||||
)}
|
<Text as="span" size="Inherit" priority="300">
|
||||||
{names.length > 3 && (
|
{', '}
|
||||||
<>
|
</Text>
|
||||||
<b>{names[0]}</b>
|
<b>{names[1]}</b>
|
||||||
<Text as="span" size="Inherit" priority="300">
|
<Text as="span" size="Inherit" priority="300">
|
||||||
{', '}
|
{', '}
|
||||||
</Text>
|
</Text>
|
||||||
<b>{names[1]}</b>
|
<b>{names[2]}</b>
|
||||||
<Text as="span" size="Inherit" priority="300">
|
<Text as="span" size="Inherit" priority="300">
|
||||||
{', '}
|
{' and '}
|
||||||
</Text>
|
</Text>
|
||||||
<b>{names[2]}</b>
|
<b>{names.length - 3} others</b>
|
||||||
<Text as="span" size="Inherit" priority="300">
|
</>
|
||||||
{' and '}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<b>{names.length - 3} others</b>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,19 +7,19 @@ import { JoinRule, Room } from 'matrix-js-sdk';
|
|||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
import { getHomeSearchPath, getSpaceSearchPath, getDirectSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||||
import * as css from './RoomViewHeader.css';
|
import * as css from './RoomViewHeader.css';
|
||||||
import { useRoomUnread } from '../../state/hooks/unread';
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
@@ -36,11 +36,12 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
|
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
|
||||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
||||||
import { RoomPinMenu } from './room-pin-menu';
|
import { RoomPinMenu } from './room-pin-menu';
|
||||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||||
|
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
import {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
@@ -56,6 +57,8 @@ import { useRoomCall, useRoomCallMembers } from '../call';
|
|||||||
import { CallState, CallType } from '../call/types';
|
import { CallState, CallType } from '../call/types';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
||||||
|
import { useDirectSelected } from '../../hooks/router/useDirectSelected';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -419,7 +422,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
|
|||||||
{isConnecting ? (
|
{isConnecting ? (
|
||||||
<Spinner size="100" variant="Secondary" />
|
<Spinner size="100" variant="Secondary" />
|
||||||
) : (
|
) : (
|
||||||
<Icon size="400" src={Icons.Phone} filled={isActive && activeCall?.callType === CallType.Voice} />
|
<Icon size="300" src={Icons.Phone} filled={isActive && activeCall?.callType === CallType.Voice} />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -445,7 +448,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
|
|||||||
{isConnecting ? (
|
{isConnecting ? (
|
||||||
<Spinner size="100" variant="Secondary" />
|
<Spinner size="100" variant="Secondary" />
|
||||||
) : (
|
) : (
|
||||||
<Icon size="400" src={Icons.VideoCamera} filled={isActive && activeCall?.callType === CallType.Video} />
|
<Icon size="300" src={Icons.VideoCamera} filled={isActive && activeCall?.callType === CallType.Video} />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -463,23 +466,37 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
const { showInPageHeader, goBack } = useBackRoute();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const onDirectPath = useDirectSelected();
|
||||||
|
const theme = useTheme();
|
||||||
|
const stationery = isStationeryTheme(theme);
|
||||||
|
|
||||||
const pinnedEvents = useRoomPinnedEvents(room);
|
const pinnedEvents = useRoomPinnedEvents(room);
|
||||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
const isDirect = mDirects.has(room.roomId);
|
||||||
const ecryptedRoom = !!encryptionEvent;
|
const avatarMxc = useRoomAvatar(room, isDirect);
|
||||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
|
||||||
const name = useRoomName(room);
|
const name = useRoomName(room);
|
||||||
const topic = useRoomTopic(room);
|
const topic = useRoomTopic(room);
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined;
|
||||||
|
const dmAvatarMxc =
|
||||||
|
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
||||||
|
(isDirect ? avatarMxc : undefined);
|
||||||
|
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
|
||||||
|
const dmFolderTabColor =
|
||||||
|
stationery && isDirect
|
||||||
|
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const [isMediaDrawer, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||||
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
@@ -488,7 +505,9 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
};
|
};
|
||||||
const path = space
|
const path = space
|
||||||
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
||||||
: getHomeSearchPath();
|
: isDirect || onDirectPath
|
||||||
|
? getDirectSearchPath()
|
||||||
|
: getHomeSearchPath();
|
||||||
navigate(withSearchParam(path, searchParams));
|
navigate(withSearchParam(path, searchParams));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -500,22 +519,51 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTogglePeopleDrawer = () => {
|
||||||
|
setPeopleDrawer((drawer) => {
|
||||||
|
const next = !drawer;
|
||||||
|
if (next) setMediaDrawer(false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMediaDrawer = () => {
|
||||||
|
setMediaDrawer((open) => {
|
||||||
|
const next = !open;
|
||||||
|
if (next) setPeopleDrawer(false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader balance={screenSize === ScreenSize.Mobile}>
|
<PageHeader balance={showInPageHeader}>
|
||||||
<Box grow="Yes" gap="300">
|
<Box grow="Yes" gap="300">
|
||||||
{screenSize === ScreenSize.Mobile && (
|
{showInPageHeader && (
|
||||||
<BackRouteHandler>
|
<Box shrink="No" alignItems="Center">
|
||||||
{(onBack) => (
|
<IconButton onClick={goBack} aria-label="Back">
|
||||||
<Box shrink="No" alignItems="Center">
|
<Icon size="300" src={Icons.ArrowLeft} />
|
||||||
<IconButton onClick={onBack}>
|
</IconButton>
|
||||||
<Icon src={Icons.ArrowLeft} />
|
</Box>
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</BackRouteHandler>
|
|
||||||
)}
|
)}
|
||||||
<Box grow="Yes" alignItems="Center" gap="300">
|
<Box
|
||||||
{screenSize !== ScreenSize.Mobile && (
|
data-folder-tab="room"
|
||||||
|
data-folder-tab-dm={isDirect ? '' : undefined}
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: '100%',
|
||||||
|
...(dmFolderTabColor
|
||||||
|
? {
|
||||||
|
['--folder-tab-fill' as string]: dmFolderTabColor,
|
||||||
|
background: dmFolderTabColor,
|
||||||
|
backgroundColor: dmFolderTabColor,
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!showInPageHeader && (
|
||||||
<Avatar size="300">
|
<Avatar size="300">
|
||||||
<RoomAvatar
|
<RoomAvatar
|
||||||
roomId={room.roomId}
|
roomId={room.roomId}
|
||||||
@@ -531,7 +579,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
<Box direction="Column">
|
<Box direction="Column" style={{ minWidth: 0 }}>
|
||||||
<Box alignItems="Center" gap="100">
|
<Box alignItems="Center" gap="100">
|
||||||
<Text size={topic ? 'H5' : 'H3'} truncate>
|
<Text size={topic ? 'H5' : 'H3'} truncate>
|
||||||
{name}
|
{name}
|
||||||
@@ -581,26 +629,25 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box grow="Yes" />
|
||||||
<Box shrink="No" alignItems="Center" gap="100">
|
<Box shrink="No" alignItems="Center" gap="100">
|
||||||
<CallIndicator roomId={room.roomId} />
|
<CallIndicator roomId={room.roomId} />
|
||||||
<RoomCallButtons roomId={room.roomId} />
|
<RoomCallButtons roomId={room.roomId} />
|
||||||
{!ecryptedRoom && (
|
<TooltipProvider
|
||||||
<TooltipProvider
|
position="Bottom"
|
||||||
position="Bottom"
|
offset={4}
|
||||||
offset={4}
|
tooltip={
|
||||||
tooltip={
|
<Tooltip>
|
||||||
<Tooltip>
|
<Text>Search</Text>
|
||||||
<Text>Search</Text>
|
</Tooltip>
|
||||||
</Tooltip>
|
}
|
||||||
}
|
>
|
||||||
>
|
{(triggerRef) => (
|
||||||
{(triggerRef) => (
|
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
<Icon size="300" src={Icons.Search} />
|
||||||
<Icon size="400" src={Icons.Search} />
|
</IconButton>
|
||||||
</IconButton>
|
)}
|
||||||
)}
|
</TooltipProvider>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
@@ -634,7 +681,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
|
<Icon size="300" src={Icons.Pin} filled={!!pinMenuAnchor} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -668,8 +715,12 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
<IconButton
|
||||||
<Icon size="400" src={Icons.User} />
|
ref={triggerRef}
|
||||||
|
onClick={handleTogglePeopleDrawer}
|
||||||
|
aria-pressed={peopleDrawer}
|
||||||
|
>
|
||||||
|
<Icon size="300" src={Icons.User} filled={peopleDrawer} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -689,7 +740,27 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
onClick={() => setActiveThreadId(undefined)}
|
onClick={() => setActiveThreadId(undefined)}
|
||||||
aria-pressed={!!activeThreadId}
|
aria-pressed={!!activeThreadId}
|
||||||
>
|
>
|
||||||
<Icon size="400" src={activeThreadId ? Icons.ArrowLeft : Icons.Thread} filled={!!activeThreadId} />
|
<Icon size="300" src={activeThreadId ? Icons.ArrowLeft : Icons.Thread} filled={!!activeThreadId} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>{isMediaDrawer ? 'Hide Shared Media' : 'Show Shared Media'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={handleToggleMediaDrawer}
|
||||||
|
aria-pressed={isMediaDrawer}
|
||||||
|
aria-label="Shared Media"
|
||||||
|
>
|
||||||
|
<Icon size="300" src={Icons.Photo} filled={isMediaDrawer} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -706,7 +777,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
||||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
<Icon size="300" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { keyframes, style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, color, config } from 'folds';
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
const SlideUpAnime = keyframes({
|
const SlideUpAnime = keyframes({
|
||||||
from: {
|
from: {
|
||||||
@@ -13,15 +13,17 @@ const SlideUpAnime = keyframes({
|
|||||||
export const RoomViewTyping = style([
|
export const RoomViewTyping = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
padding: `0 ${config.space.S500}`,
|
minHeight: toRem(28),
|
||||||
width: '100%',
|
padding: `0 ${config.space.S400}`,
|
||||||
backgroundColor: color.Surface.Container,
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
color: color.Surface.OnContainer,
|
color: color.Surface.OnContainer,
|
||||||
position: 'absolute',
|
pointerEvents: 'auto',
|
||||||
bottom: 0,
|
|
||||||
animation: `${SlideUpAnime} 100ms ease-in-out`,
|
animation: `${SlideUpAnime} 100ms ease-in-out`,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const TypingText = style({
|
export const TypingText = style({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
overflow: 'clip',
|
overflow: 'clip',
|
||||||
|
|||||||
@@ -45,78 +45,76 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<Box
|
||||||
<Box
|
className={classNames(css.RoomViewTyping, className)}
|
||||||
className={classNames(css.RoomViewTyping, className)}
|
alignItems="Center"
|
||||||
alignItems="Center"
|
gap="400"
|
||||||
gap="400"
|
{...props}
|
||||||
{...props}
|
ref={ref}
|
||||||
ref={ref}
|
>
|
||||||
>
|
<TypingIndicator />
|
||||||
<TypingIndicator />
|
<Text className={css.TypingText} size="T300" truncate>
|
||||||
<Text className={css.TypingText} size="T300" truncate>
|
{typingNames.length === 1 && (
|
||||||
{typingNames.length === 1 && (
|
<>
|
||||||
<>
|
<b>{typingNames[0]}</b>
|
||||||
<b>{typingNames[0]}</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{' is typing...'}
|
||||||
{' is typing...'}
|
</Text>
|
||||||
</Text>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
{typingNames.length === 2 && (
|
||||||
{typingNames.length === 2 && (
|
<>
|
||||||
<>
|
<b>{typingNames[0]}</b>
|
||||||
<b>{typingNames[0]}</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{' and '}
|
||||||
{' and '}
|
</Text>
|
||||||
</Text>
|
<b>{typingNames[1]}</b>
|
||||||
<b>{typingNames[1]}</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{' are typing...'}
|
||||||
{' are typing...'}
|
</Text>
|
||||||
</Text>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
{typingNames.length === 3 && (
|
||||||
{typingNames.length === 3 && (
|
<>
|
||||||
<>
|
<b>{typingNames[0]}</b>
|
||||||
<b>{typingNames[0]}</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{', '}
|
||||||
{', '}
|
</Text>
|
||||||
</Text>
|
<b>{typingNames[1]}</b>
|
||||||
<b>{typingNames[1]}</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{' and '}
|
||||||
{' and '}
|
</Text>
|
||||||
</Text>
|
<b>{typingNames[2]}</b>
|
||||||
<b>{typingNames[2]}</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{' are typing...'}
|
||||||
{' are typing...'}
|
</Text>
|
||||||
</Text>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
{typingNames.length > 3 && (
|
||||||
{typingNames.length > 3 && (
|
<>
|
||||||
<>
|
<b>{typingNames[0]}</b>
|
||||||
<b>{typingNames[0]}</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{', '}
|
||||||
{', '}
|
</Text>
|
||||||
</Text>
|
<b>{typingNames[1]}</b>
|
||||||
<b>{typingNames[1]}</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{', '}
|
||||||
{', '}
|
</Text>
|
||||||
</Text>
|
<b>{typingNames[2]}</b>
|
||||||
<b>{typingNames[2]}</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{' and '}
|
||||||
{' and '}
|
</Text>
|
||||||
</Text>
|
<b>{typingNames.length - 3} others</b>
|
||||||
<b>{typingNames.length - 3} others</b>
|
<Text as="span" size="Inherit" priority="300">
|
||||||
<Text as="span" size="Inherit" priority="300">
|
{' are typing...'}
|
||||||
{' are typing...'}
|
</Text>
|
||||||
</Text>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</Text>
|
||||||
</Text>
|
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
|
||||||
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
|
<Icon size="50" src={Icons.Cross} />
|
||||||
<Icon size="50" src={Icons.Cross} />
|
</IconButton>
|
||||||
</IconButton>
|
</Box>
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
|||||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
import { RoomInput } from './RoomInput';
|
import { RoomInput } from './RoomInput';
|
||||||
import { RoomViewTyping } from './RoomViewTyping';
|
import { RoomViewTyping } from './RoomViewTyping';
|
||||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
import { RoomViewFollowing } from './RoomViewFollowing';
|
||||||
|
import * as roomViewCss from './RoomViewFollowing.css';
|
||||||
import { Message, Reactions, EncryptedContent } from './message';
|
import { Message, Reactions, EncryptedContent } from './message';
|
||||||
import { Reply } from '../../components/message';
|
import { Reply } from '../../components/message';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
@@ -731,8 +732,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Thread messages — grows to fill remaining space */}
|
{/* Thread messages — grows to fill remaining space */}
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
|
||||||
<Box grow="Yes" style={{ position: 'relative' }} ref={containerRef}>
|
<Box grow="Yes" style={{ position: 'relative', minHeight: 0 }} ref={containerRef}>
|
||||||
<Scroll ref={scrollRef} visibility="Hover">
|
<Scroll ref={scrollRef} visibility="Hover">
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
@@ -759,22 +760,22 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
</Box>
|
</Box>
|
||||||
<RoomViewTyping room={room} />
|
<div className={roomViewCss.RoomViewBottomFloat}>
|
||||||
|
<RoomViewTyping room={room} />
|
||||||
|
{!hideActivity && <RoomViewFollowing room={room} />}
|
||||||
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Thread input */}
|
{/* Thread input */}
|
||||||
<Box shrink="No" direction="Column">
|
<Box shrink="No" direction="Column">
|
||||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
<RoomInput
|
||||||
<RoomInput
|
room={room}
|
||||||
room={room}
|
editor={editor}
|
||||||
editor={editor}
|
roomId={room.roomId}
|
||||||
roomId={room.roomId}
|
fileDropContainerRef={containerRef}
|
||||||
fileDropContainerRef={containerRef}
|
threadRootId={threadRootId}
|
||||||
threadRootId={threadRootId}
|
ref={inputRef}
|
||||||
ref={inputRef}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -802,8 +802,9 @@ export const Message = as<'div', MessageProps>(
|
|||||||
gap="300"
|
gap="300"
|
||||||
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
||||||
justifyContent="SpaceBetween"
|
justifyContent="SpaceBetween"
|
||||||
alignItems="Baseline"
|
alignItems="Center"
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
|
data-message-header=""
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Username
|
<Username
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import React, {
|
|||||||
import { Box, Chip, IconButton, Line, PopOut, RectCords, Spinner, Text, as, config } from 'folds';
|
import { Box, Chip, IconButton, Line, PopOut, RectCords, Spinner, Text, as, config } from 'folds';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { Editor, Transforms } from 'slate';
|
import { Editor, Transforms } from 'slate';
|
||||||
import { ReactEditor } from 'slate-react';
|
|
||||||
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
|
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
htmlToEditorInput,
|
htmlToEditorInput,
|
||||||
moveCursor,
|
moveCursor,
|
||||||
plainToEditorInput,
|
plainToEditorInput,
|
||||||
|
safeFocusEditor,
|
||||||
toMatrixCustomHTML,
|
toMatrixCustomHTML,
|
||||||
toPlainText,
|
toPlainText,
|
||||||
trimCustomHtml,
|
trimCustomHtml,
|
||||||
@@ -185,7 +185,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseAutocomplete = useCallback(() => {
|
const handleCloseAutocomplete = useCallback(() => {
|
||||||
ReactEditor.focus(editor);
|
safeFocusEditor(editor);
|
||||||
setAutocompleteQuery(undefined);
|
setAutocompleteQuery(undefined);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
editor.insertFragment(initialValue);
|
editor.insertFragment(initialValue);
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
if (!mobileOrTablet()) safeFocusEditor(editor);
|
||||||
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
|
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -282,7 +282,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => setToolbar(!toolbar)}
|
onClick={() => setToolbar(!toolbar)}
|
||||||
>
|
>
|
||||||
<Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
<Icon size="200" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<UseStateProvider initial={undefined}>
|
<UseStateProvider initial={undefined}>
|
||||||
{(anchor: RectCords | undefined, setAnchor) => (
|
{(anchor: RectCords | undefined, setAnchor) => (
|
||||||
@@ -300,7 +300,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
requestClose={() => {
|
requestClose={() => {
|
||||||
setAnchor((v) => {
|
setAnchor((v) => {
|
||||||
if (v) {
|
if (v) {
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
if (!mobileOrTablet()) safeFocusEditor(editor);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return v;
|
return v;
|
||||||
@@ -321,7 +321,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
|
<Icon size="200" src={Icons.Smile} filled={anchor !== undefined} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
className={classNames(css.ReactionsContainer, className)}
|
className={classNames(css.ReactionsContainer, className)}
|
||||||
gap="200"
|
gap="200"
|
||||||
wrap="Wrap"
|
wrap="Wrap"
|
||||||
|
data-reactions=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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)));
|
||||||
@@ -59,10 +69,14 @@ export const getImageMsgContent = async (
|
|||||||
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
|
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
|
||||||
};
|
};
|
||||||
if (imgEl) {
|
if (imgEl) {
|
||||||
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
|
const naturalW = imgEl.naturalWidth || imgEl.width;
|
||||||
|
const naturalH = imgEl.naturalHeight || imgEl.height;
|
||||||
|
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(naturalW, 512, naturalH));
|
||||||
|
|
||||||
content.info = {
|
content.info = {
|
||||||
...getImageInfo(imgEl, file),
|
...getImageInfo(imgEl, file),
|
||||||
|
w: naturalW,
|
||||||
|
h: naturalH,
|
||||||
[MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
|
[MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -74,6 +88,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const MediaDrawer = style({
|
||||||
|
width: toRem(399),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerSolo = style({
|
||||||
|
width: '100%',
|
||||||
|
flexGrow: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerHeader = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerContentBase = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerContent = style({
|
||||||
|
padding: `${config.space.S100} ${config.space.S200} ${config.space.S200}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaMonthSection = style({
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaMonthHeader = style({
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
margin: '5px 0',
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDaySection = style({
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDayHeader = style({
|
||||||
|
padding: `${config.space.S100} ${config.space.S100} ${config.space.S200}`,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaGrid = style({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||||
|
gap: config.space.S200,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTile = style({
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTileImg = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTileBadge = style({
|
||||||
|
position: 'absolute',
|
||||||
|
right: config.space.S100,
|
||||||
|
bottom: config.space.S100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: toRem(24),
|
||||||
|
height: toRem(24),
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||||
|
color: '#fff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaEmpty = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
textAlign: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaFooter = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserFiltersScroll = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: `${color.SurfaceVariant.ContainerLine} ${color.SurfaceVariant.Container}`,
|
||||||
|
selectors: {
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
height: toRem(8),
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.Container}`,
|
||||||
|
backgroundClip: 'padding-box',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb:hover': {
|
||||||
|
backgroundColor: color.Surface.ContainerLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserFilters = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S500,
|
||||||
|
padding: `${config.space.S200} ${config.space.S200}`,
|
||||||
|
width: 'max-content',
|
||||||
|
minWidth: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserChip = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserChipLabel = style({
|
||||||
|
maxWidth: toRem(72),
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Header,
|
||||||
|
IconButton,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
as,
|
||||||
|
config,
|
||||||
|
Chip,
|
||||||
|
Avatar,
|
||||||
|
} from 'folds';
|
||||||
|
import { MsgType, Room } from 'matrix-js-sdk';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
|
import * as css from './RoomMediaMenu.css';
|
||||||
|
import { RoomMediaItem, useRoomMediaEvents } from '../../../hooks/useRoomMediaEvents';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import {
|
||||||
|
decryptFile,
|
||||||
|
downloadEncryptedMedia,
|
||||||
|
getMxIdLocalPart,
|
||||||
|
isAuthenticatedMediaUrl,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
} from '../../../utils/matrix';
|
||||||
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
|
import { Image } from '../../../components/media';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
|
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||||
|
import { VideoFrameThumbnail } from '../../../components/message/content/VideoFrameThumbnail';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
|
||||||
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
|
import {
|
||||||
|
getMediaDrawerScrollTop,
|
||||||
|
isMediaDrawerAtom,
|
||||||
|
setMediaDrawerScrollTop,
|
||||||
|
} from '../../../state/mediaDrawer';
|
||||||
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
|
|
||||||
|
const fetchAuthenticatedMedia = async (
|
||||||
|
url: string,
|
||||||
|
accessToken: string | null
|
||||||
|
): Promise<string> => {
|
||||||
|
try {
|
||||||
|
let response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status === 401 && accessToken) {
|
||||||
|
response = await fetch(url, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) return url;
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaTileThumbProps = {
|
||||||
|
item: RoomMediaItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MediaTileImageThumb({ item }: { item: RoomMediaItem & { thumbMxc: string } }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
|
||||||
|
if (item.thumbEncInfo) {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, item.thumbMxc, useAuthentication) ?? item.thumbMxc;
|
||||||
|
const fileContent = await downloadEncryptedMedia(
|
||||||
|
mediaUrl,
|
||||||
|
(encBuf) =>
|
||||||
|
decryptFile(encBuf, item.thumbMimeType ?? FALLBACK_MIMETYPE, item.thumbEncInfo!),
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
return URL.createObjectURL(fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaUrl =
|
||||||
|
mxcUrlToHttp(mx, item.thumbMxc, useAuthentication, 240, 240, 'crop') ?? item.thumbMxc;
|
||||||
|
|
||||||
|
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||||
|
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaUrl;
|
||||||
|
}, [mx, item.thumbMxc, item.thumbEncInfo, item.thumbMimeType, useAuthentication])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSrc();
|
||||||
|
}, [loadSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||||
|
const src = srcState.data;
|
||||||
|
if (!src.startsWith('blob:')) return undefined;
|
||||||
|
return () => URL.revokeObjectURL(src);
|
||||||
|
}, [srcState]);
|
||||||
|
|
||||||
|
if (srcState.status === AsyncStatus.Success) {
|
||||||
|
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||||
|
{srcState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.Photo} size="400" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaTileFullImageThumb({ item }: { item: RoomMediaItem }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, item.fullMxc, useAuthentication) ?? item.fullMxc;
|
||||||
|
|
||||||
|
if (item.fullEncInfo) {
|
||||||
|
const fileContent = await downloadEncryptedMedia(
|
||||||
|
mediaUrl,
|
||||||
|
(encBuf) =>
|
||||||
|
decryptFile(encBuf, item.fullMimeType ?? FALLBACK_MIMETYPE, item.fullEncInfo!),
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
return URL.createObjectURL(fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||||
|
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaUrl;
|
||||||
|
}, [mx, item.fullMxc, item.fullEncInfo, item.fullMimeType, useAuthentication])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSrc();
|
||||||
|
}, [loadSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||||
|
const src = srcState.data;
|
||||||
|
if (!src.startsWith('blob:')) return undefined;
|
||||||
|
return () => URL.revokeObjectURL(src);
|
||||||
|
}, [srcState]);
|
||||||
|
|
||||||
|
if (srcState.status === AsyncStatus.Success) {
|
||||||
|
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||||
|
{srcState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.Photo} size="400" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaTileThumb({ item }: MediaTileThumbProps) {
|
||||||
|
if (item.thumbMxc) {
|
||||||
|
return <MediaTileImageThumb item={{ ...item, thumbMxc: item.thumbMxc }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.msgtype === MsgType.Video) {
|
||||||
|
return (
|
||||||
|
<Box style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
|
>
|
||||||
|
<Spinner size="200" />
|
||||||
|
</Box>
|
||||||
|
<VideoFrameThumbnail
|
||||||
|
mimeType={item.fullMimeType ?? 'video/mp4'}
|
||||||
|
url={item.fullMxc}
|
||||||
|
encInfo={item.fullEncInfo}
|
||||||
|
renderImage={(src) => (
|
||||||
|
<Image className={css.MediaTileImg} src={src} alt={item.body} loading="lazy" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MediaTileFullImageThumb item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaDayGroup = {
|
||||||
|
dayKey: string;
|
||||||
|
dayLabel: string;
|
||||||
|
items: RoomMediaItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaMonthGroup = {
|
||||||
|
monthKey: string;
|
||||||
|
monthLabel: string;
|
||||||
|
days: MediaDayGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sameDayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDayLabel = (ts: number): string => {
|
||||||
|
const date = new Date(ts);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(today.getDate() - 1);
|
||||||
|
|
||||||
|
if (sameDayFormatter.format(date) === sameDayFormatter.format(today)) return 'Today';
|
||||||
|
if (sameDayFormatter.format(date) === sameDayFormatter.format(yesterday)) return 'Yesterday';
|
||||||
|
return dayFormatter.format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupMediaByMonthAndDay = (items: RoomMediaItem[]): MediaMonthGroup[] => {
|
||||||
|
const months: MediaMonthGroup[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const date = new Date(item.ts);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
let month = months[months.length - 1];
|
||||||
|
if (!month || month.monthKey !== monthKey) {
|
||||||
|
month = {
|
||||||
|
monthKey,
|
||||||
|
monthLabel: monthFormatter.format(date),
|
||||||
|
days: [],
|
||||||
|
};
|
||||||
|
months.push(month);
|
||||||
|
}
|
||||||
|
|
||||||
|
let day = month.days[month.days.length - 1];
|
||||||
|
if (!day || day.dayKey !== dayKey) {
|
||||||
|
day = {
|
||||||
|
dayKey,
|
||||||
|
dayLabel: formatDayLabel(item.ts),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
month.days.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
day.items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return months;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaSenderChip = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
avatarMxc?: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaDrawerProps = {
|
||||||
|
room: Room;
|
||||||
|
/** Full-page takeover when the layout is too narrow for a side rail. */
|
||||||
|
solo?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MediaDrawer = as<'div', MediaDrawerProps>(({ room, solo = false, className, ...props }, ref) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const [, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||||
|
const { items, loading, hasMore, loadMore } = useRoomMediaEvents(room);
|
||||||
|
const [excludedSenders, setExcludedSenders] = useState<Set<string>>(() => new Set());
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const restoredScrollRef = useRef(false);
|
||||||
|
|
||||||
|
const senders = useMemo<MediaSenderChip[]>(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const item of items) {
|
||||||
|
counts.set(item.sender, (counts.get(item.sender) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.map(([userId, count]) => ({
|
||||||
|
userId,
|
||||||
|
count,
|
||||||
|
name: getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
|
||||||
|
avatarMxc: getMemberAvatarMxc(room, userId),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||||
|
}, [items, room]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(
|
||||||
|
() => items.filter((item) => !excludedSenders.has(item.sender)),
|
||||||
|
[items, excludedSenders]
|
||||||
|
);
|
||||||
|
const sections = useMemo(() => groupMediaByMonthAndDay(filteredItems), [filteredItems]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
restoredScrollRef.current = false;
|
||||||
|
}, [room.roomId]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (restoredScrollRef.current || items.length === 0) return;
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const saved = getMediaDrawerScrollTop(room.roomId);
|
||||||
|
if (typeof saved === 'number') {
|
||||||
|
el.scrollTop = saved;
|
||||||
|
}
|
||||||
|
restoredScrollRef.current = true;
|
||||||
|
}, [room.roomId, items.length]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setMediaDrawer(false);
|
||||||
|
}, [setMediaDrawer]);
|
||||||
|
|
||||||
|
const handleSenderClick = useCallback(
|
||||||
|
(evt: React.MouseEvent, userId: string) => {
|
||||||
|
if (evt.ctrlKey || evt.metaKey) {
|
||||||
|
evt.preventDefault();
|
||||||
|
const allIds = senders.map((sender) => sender.userId);
|
||||||
|
setExcludedSenders((prev) => {
|
||||||
|
const enabledIds = allIds.filter((id) => !prev.has(id));
|
||||||
|
const alreadySolo = enabledIds.length === 1 && enabledIds[0] === userId;
|
||||||
|
if (alreadySolo) return new Set();
|
||||||
|
return new Set(allIds.filter((id) => id !== userId));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExcludedSenders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(userId)) next.delete(userId);
|
||||||
|
else next.add(userId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[senders]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(
|
||||||
|
(eventId: string) => {
|
||||||
|
navigateRoom(room.roomId, eventId);
|
||||||
|
if (solo) setMediaDrawer(false);
|
||||||
|
},
|
||||||
|
[navigateRoom, room.roomId, solo, setMediaDrawer]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
(evt: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = evt.currentTarget;
|
||||||
|
setMediaDrawerScrollTop(room.roomId, el.scrollTop);
|
||||||
|
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||||
|
if (nearBottom && hasMore && !loading) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasMore, loading, loadMore, room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
grow={solo ? 'Yes' : undefined}
|
||||||
|
direction="Column"
|
||||||
|
className={classNames(
|
||||||
|
solo ? css.MediaDrawerSolo : css.MediaDrawer,
|
||||||
|
ContainerColor({ variant: 'Background' }),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Header className={css.MediaDrawerHeader} size="600" variant="Background">
|
||||||
|
<Box grow="Yes" alignItems="Center">
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
Shared Media
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{senders.length > 0 && (
|
||||||
|
<div className={css.MediaUserFiltersScroll}>
|
||||||
|
<div className={css.MediaUserFilters}>
|
||||||
|
{senders.map((sender) => {
|
||||||
|
const enabled = !excludedSenders.has(sender.userId);
|
||||||
|
const avatarUrl = sender.avatarMxc
|
||||||
|
? mxcUrlToHttp(mx, sender.avatarMxc, useAuthentication, 48, 48, 'crop') ??
|
||||||
|
undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={sender.userId}
|
||||||
|
className={css.MediaUserChip}
|
||||||
|
variant={enabled ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
outlined
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed={enabled}
|
||||||
|
onClick={(evt) => handleSenderClick(evt, sender.userId)}
|
||||||
|
before={
|
||||||
|
<Avatar size="200">
|
||||||
|
<UserAvatar
|
||||||
|
userId={sender.userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={sender.name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
{sender.name.slice(0, 1).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className={css.MediaUserChipLabel} size="T200" truncate>
|
||||||
|
{sender.name}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box grow="Yes" direction="Column" className={css.MediaDrawerContentBase}>
|
||||||
|
<Scroll
|
||||||
|
ref={scrollRef}
|
||||||
|
visibility="Hover"
|
||||||
|
hideTrack
|
||||||
|
variant="Background"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<div className={css.MediaDrawerContent}>
|
||||||
|
{items.length === 0 && !loading && (
|
||||||
|
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||||
|
No shared photos or videos yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && filteredItems.length === 0 && (
|
||||||
|
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||||
|
No media from the selected people.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sections.map((month) => (
|
||||||
|
<section key={month.monthKey} className={css.MediaMonthSection}>
|
||||||
|
<Text className={css.MediaMonthHeader} size="H6" as="h3">
|
||||||
|
{month.monthLabel}
|
||||||
|
</Text>
|
||||||
|
{month.days.map((day) => (
|
||||||
|
<div key={day.dayKey} className={css.MediaDaySection}>
|
||||||
|
<Text className={css.MediaDayHeader} size="T200" priority="300" as="h4">
|
||||||
|
{day.dayLabel}
|
||||||
|
</Text>
|
||||||
|
<div className={css.MediaGrid}>
|
||||||
|
{day.items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.eventId}
|
||||||
|
type="button"
|
||||||
|
className={css.MediaTile}
|
||||||
|
onClick={() => handleOpen(item.eventId)}
|
||||||
|
title={item.body}
|
||||||
|
aria-label={item.body}
|
||||||
|
>
|
||||||
|
<MediaTileThumb item={item} />
|
||||||
|
{item.msgtype === MsgType.Video && (
|
||||||
|
<span className={css.MediaTileBadge}>
|
||||||
|
<Icon src={Icons.Play} size="100" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(loading || hasMore) && (
|
||||||
|
<div className={css.MediaFooter} style={{ minHeight: config.space.S700 }}>
|
||||||
|
{loading && <Spinner size="400" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
1
src/app/features/room/room-media-menu/index.ts
Normal file
1
src/app/features/room/room-media-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './RoomMediaMenu';
|
||||||
@@ -145,11 +145,23 @@ export function Search({ requestClose }: SearchProps) {
|
|||||||
|
|
||||||
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
const roomName = getRoom(roomId)?.name ?? roomId;
|
const room = getRoom(roomId);
|
||||||
|
const roomName = room?.name ?? roomId;
|
||||||
if (mDirects.has(roomId)) {
|
if (mDirects.has(roomId)) {
|
||||||
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
||||||
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
|
if (!targetUserId) return roomName;
|
||||||
if (targetUsername) return [roomName, targetUsername];
|
const targetUsername = getMxIdLocalPart(targetUserId);
|
||||||
|
const targetServer = getMxIdServer(targetUserId);
|
||||||
|
const member = room?.getMember(targetUserId);
|
||||||
|
const displayName = member?.name || member?.rawDisplayName;
|
||||||
|
return [
|
||||||
|
roomName,
|
||||||
|
displayName,
|
||||||
|
targetUsername,
|
||||||
|
targetUserId,
|
||||||
|
targetServer ? `@${targetUsername}:${targetServer}` : undefined,
|
||||||
|
targetServer,
|
||||||
|
].filter((s): s is string => typeof s === 'string' && s.length > 0);
|
||||||
}
|
}
|
||||||
return roomName;
|
return roomName;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Avatar, Box, Button, config, IconButton, MenuItem, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
config,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
MenuItem,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
import { Icon, Icons, IconSrc } from '../../components/icons';
|
import { Icon, Icons, IconSrc } from '../../components/icons';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { General } from './general';
|
import { General } from './general';
|
||||||
@@ -22,18 +34,17 @@ import { Plugins } from './plugins';
|
|||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||||
|
import { fuzzyFilter } from '../../utils/fuzzy';
|
||||||
|
import { SETTINGS_SEARCH_CATALOG } from './settingsSearchCatalog';
|
||||||
|
import { SettingsPages } from './SettingsPages';
|
||||||
|
import { SettingsSearchResult } from './styles.css';
|
||||||
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
|
import {
|
||||||
|
SettingsFocusProvider,
|
||||||
|
settingAnchorId,
|
||||||
|
} from '../../components/setting-tile';
|
||||||
|
|
||||||
export enum SettingsPages {
|
export { SettingsPages };
|
||||||
GeneralPage,
|
|
||||||
AccountPage,
|
|
||||||
NotificationPage,
|
|
||||||
AudioPage,
|
|
||||||
DevicesPage,
|
|
||||||
EmojisStickersPage,
|
|
||||||
PluginsPage,
|
|
||||||
DeveloperToolsPage,
|
|
||||||
AboutPage,
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettingsMenuItem = {
|
type SettingsMenuItem = {
|
||||||
page: SettingsPages;
|
page: SettingsPages;
|
||||||
@@ -112,7 +123,37 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||||||
if (initialPage) return initialPage;
|
if (initialPage) return initialPage;
|
||||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
|
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
|
||||||
});
|
});
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [focusAnchorId, setFocusAnchorId] = useState<string | undefined>();
|
||||||
const menuItems = useSettingsMenuItems();
|
const menuItems = useSettingsMenuItems();
|
||||||
|
const pageNameByPage = useMemo(() => {
|
||||||
|
const map = new Map<SettingsPages, string>();
|
||||||
|
menuItems.forEach((item) => map.set(item.page, item.name));
|
||||||
|
return map;
|
||||||
|
}, [menuItems]);
|
||||||
|
const iconByPage = useMemo(() => {
|
||||||
|
const map = new Map<SettingsPages, IconSrc>();
|
||||||
|
menuItems.forEach((item) => map.set(item.page, item.icon));
|
||||||
|
return map;
|
||||||
|
}, [menuItems]);
|
||||||
|
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
const q = searchQuery.trim();
|
||||||
|
if (!q) return [];
|
||||||
|
return fuzzyFilter(SETTINGS_SEARCH_CATALOG, q, (entry) => [
|
||||||
|
entry.title,
|
||||||
|
pageNameByPage.get(entry.page),
|
||||||
|
...(entry.keywords ?? []),
|
||||||
|
]).slice(0, 40);
|
||||||
|
}, [pageNameByPage, searchQuery]);
|
||||||
|
|
||||||
|
const settingsFocus = useMemo(
|
||||||
|
() => ({
|
||||||
|
anchorId: focusAnchorId,
|
||||||
|
setAnchorId: setFocusAnchorId,
|
||||||
|
}),
|
||||||
|
[focusAnchorId]
|
||||||
|
);
|
||||||
|
|
||||||
const handlePageRequestClose = () => {
|
const handlePageRequestClose = () => {
|
||||||
if (screenSize === ScreenSize.Mobile) {
|
if (screenSize === ScreenSize.Mobile) {
|
||||||
@@ -122,120 +163,244 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const openSearchResult = useCallback(
|
||||||
<PageRoot
|
(page: SettingsPages, title: string) => {
|
||||||
nav={
|
const pageName = pageNameByPage.get(page);
|
||||||
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
|
// Page-level entries just open the page; tile entries scroll into view.
|
||||||
<PageNav size="300">
|
if (title !== pageName) {
|
||||||
<PageNavHeader outlined={false}>
|
setFocusAnchorId(settingAnchorId(title));
|
||||||
<Box grow="Yes" gap="200">
|
} else {
|
||||||
<Avatar size="200" radii="300">
|
setFocusAnchorId(undefined);
|
||||||
<UserAvatar
|
|
||||||
userId={userId}
|
|
||||||
src={avatarUrl}
|
|
||||||
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
|
|
||||||
/>
|
|
||||||
</Avatar>
|
|
||||||
<Text size="H4" truncate>
|
|
||||||
Settings
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box shrink="No">
|
|
||||||
{screenSize === ScreenSize.Mobile && (
|
|
||||||
<IconButton onClick={requestClose} variant="Background">
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</PageNavHeader>
|
|
||||||
<Box grow="Yes" direction="Column">
|
|
||||||
<PageNavContent>
|
|
||||||
<div style={{ flexGrow: 1 }}>
|
|
||||||
{menuItems.map((item) => (
|
|
||||||
<MenuItem
|
|
||||||
key={item.name}
|
|
||||||
variant="Background"
|
|
||||||
radii="400"
|
|
||||||
aria-pressed={activePage === item.page}
|
|
||||||
before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
|
|
||||||
onClick={() => setActivePage(item.page)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
|
|
||||||
}}
|
|
||||||
size="T300"
|
|
||||||
truncate
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageNavContent>
|
|
||||||
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
|
|
||||||
<UseStateProvider initial={false}>
|
|
||||||
{(logout, setLogout) => (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Critical"
|
|
||||||
fill="None"
|
|
||||||
radii="Pill"
|
|
||||||
before={<Icon src={Icons.Power} size="100" />}
|
|
||||||
onClick={() => setLogout(true)}
|
|
||||||
>
|
|
||||||
<Text size="B400">Logout</Text>
|
|
||||||
</Button>
|
|
||||||
{logout && (
|
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
onDeactivate: () => setLogout(false),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogoutDialog handleClose={() => setLogout(false)} />
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</UseStateProvider>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</PageNav>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
setActivePage(page);
|
||||||
{activePage === SettingsPages.GeneralPage && (
|
setSearchQuery('');
|
||||||
<General requestClose={handlePageRequestClose} />
|
},
|
||||||
)}
|
[pageNameByPage]
|
||||||
{activePage === SettingsPages.AccountPage && (
|
);
|
||||||
<Account requestClose={handlePageRequestClose} />
|
|
||||||
)}
|
// Fallback scroll if the page just mounted and tiles need a moment to render.
|
||||||
{activePage === SettingsPages.NotificationPage && (
|
useEffect(() => {
|
||||||
<Notifications requestClose={handlePageRequestClose} />
|
if (!focusAnchorId) return;
|
||||||
)}
|
let cancelled = false;
|
||||||
{activePage === SettingsPages.AudioPage && (
|
let attempts = 0;
|
||||||
<Audio requestClose={handlePageRequestClose} />
|
|
||||||
)}
|
const tryScroll = () => {
|
||||||
{activePage === SettingsPages.DevicesPage && (
|
if (cancelled) return;
|
||||||
<Devices requestClose={handlePageRequestClose} />
|
const el = document.getElementById(focusAnchorId);
|
||||||
)}
|
if (el) {
|
||||||
{activePage === SettingsPages.EmojisStickersPage && (
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
return;
|
||||||
)}
|
}
|
||||||
{activePage === SettingsPages.PluginsPage && (
|
if (attempts < 40) {
|
||||||
<Plugins requestClose={handlePageRequestClose} />
|
attempts += 1;
|
||||||
)}
|
window.requestAnimationFrame(tryScroll);
|
||||||
{activePage === SettingsPages.DeveloperToolsPage && (
|
} else {
|
||||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
setFocusAnchorId(undefined);
|
||||||
)}
|
}
|
||||||
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
};
|
||||||
</PageRoot>
|
|
||||||
|
const timer = window.setTimeout(tryScroll, 50);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [focusAnchorId, activePage]);
|
||||||
|
|
||||||
|
const isSearching = searchQuery.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsFocusProvider value={settingsFocus}>
|
||||||
|
<PageRoot
|
||||||
|
nav={
|
||||||
|
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
|
||||||
|
<PageNav size="300">
|
||||||
|
<PageNavHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Avatar size="200" radii="300">
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Text size="H4" truncate>
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
{screenSize === ScreenSize.Mobile && (
|
||||||
|
<IconButton onClick={requestClose} variant="Background">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</PageNavHeader>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Box
|
||||||
|
style={{ padding: `${config.space.S100} ${config.space.S200}`, width: '100%' }}
|
||||||
|
shrink="No"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
variant="Background"
|
||||||
|
size="400"
|
||||||
|
placeholder="Search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(evt) => setSearchQuery(evt.target.value)}
|
||||||
|
before={<Icon size="100" src={Icons.Search} />}
|
||||||
|
after={
|
||||||
|
searchQuery ? (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
variant="Background"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<PageNavContent>
|
||||||
|
<div style={{ flexGrow: 1 }}>
|
||||||
|
{isSearching ? (
|
||||||
|
searchResults.length > 0 ? (
|
||||||
|
searchResults.map(({ item }) => (
|
||||||
|
<button
|
||||||
|
key={`${item.page}-${item.title}`}
|
||||||
|
type="button"
|
||||||
|
className={SettingsSearchResult}
|
||||||
|
aria-pressed={activePage === item.page}
|
||||||
|
onClick={() => openSearchResult(item.page, item.title)}
|
||||||
|
>
|
||||||
|
<Box shrink="No" style={{ paddingTop: config.space.S100 }}>
|
||||||
|
<Icon
|
||||||
|
src={iconByPage.get(item.page) ?? Icons.Setting}
|
||||||
|
size="100"
|
||||||
|
filled={activePage === item.page}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
|
||||||
|
<Text
|
||||||
|
className={BreakWord}
|
||||||
|
size="T300"
|
||||||
|
style={{
|
||||||
|
fontWeight:
|
||||||
|
activePage === item.page ? config.fontWeight.W600 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{pageNameByPage.get(item.page)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
>
|
||||||
|
<Text size="T300" priority="300" className={BreakWord}>
|
||||||
|
No settings match “{searchQuery.trim()}”
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
menuItems.map((item) => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.name}
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
aria-pressed={activePage === item.page}
|
||||||
|
before={
|
||||||
|
<Icon src={item.icon} size="100" filled={activePage === item.page} />
|
||||||
|
}
|
||||||
|
onClick={() => setActivePage(item.page)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontWeight:
|
||||||
|
activePage === item.page ? config.fontWeight.W600 : undefined,
|
||||||
|
}}
|
||||||
|
size="T300"
|
||||||
|
truncate
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageNavContent>
|
||||||
|
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
|
||||||
|
<UseStateProvider initial={false}>
|
||||||
|
{(logout, setLogout) => (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
radii="Pill"
|
||||||
|
before={<Icon src={Icons.Power} size="100" />}
|
||||||
|
onClick={() => setLogout(true)}
|
||||||
|
>
|
||||||
|
<Text size="B400">Logout</Text>
|
||||||
|
</Button>
|
||||||
|
{logout && (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
onDeactivate: () => setLogout(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogoutDialog handleClose={() => setLogout(false)} />
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageNav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activePage === SettingsPages.GeneralPage && (
|
||||||
|
<General requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.AccountPage && (
|
||||||
|
<Account requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.NotificationPage && (
|
||||||
|
<Notifications requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.AudioPage && (
|
||||||
|
<Audio requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.DevicesPage && (
|
||||||
|
<Devices requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.EmojisStickersPage && (
|
||||||
|
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.PluginsPage && (
|
||||||
|
<Plugins requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.DeveloperToolsPage && (
|
||||||
|
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
||||||
|
</PageRoot>
|
||||||
|
</SettingsFocusProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/app/features/settings/SettingsPages.ts
Normal file
11
src/app/features/settings/SettingsPages.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export enum SettingsPages {
|
||||||
|
GeneralPage,
|
||||||
|
AccountPage,
|
||||||
|
NotificationPage,
|
||||||
|
AudioPage,
|
||||||
|
DevicesPage,
|
||||||
|
EmojisStickersPage,
|
||||||
|
PluginsPage,
|
||||||
|
DeveloperToolsPage,
|
||||||
|
AboutPage,
|
||||||
|
}
|
||||||
@@ -83,6 +83,9 @@ function loadAudioSettings(): AudioSettings {
|
|||||||
return DEFAULT_AUDIO_SETTINGS;
|
return DEFAULT_AUDIO_SETTINGS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dispatched on window after audio settings are persisted. */
|
||||||
|
export const AUDIO_SETTINGS_CHANGED_EVENT = 'paarrot-audio-settings-changed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves audio settings to localStorage
|
* Saves audio settings to localStorage
|
||||||
* @param settings - The audio settings to save
|
* @param settings - The audio settings to save
|
||||||
@@ -90,6 +93,7 @@ function loadAudioSettings(): AudioSettings {
|
|||||||
function saveAudioSettings(settings: AudioSettings): void {
|
function saveAudioSettings(settings: AudioSettings): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(AUDIO_SETTINGS_KEY, JSON.stringify(settings));
|
localStorage.setItem(AUDIO_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
window.dispatchEvent(new CustomEvent(AUDIO_SETTINGS_CHANGED_EVENT));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save audio settings:', e);
|
console.error('Failed to save audio settings:', e);
|
||||||
}
|
}
|
||||||
@@ -103,6 +107,40 @@ export function getAudioSettings(): AudioSettings {
|
|||||||
return loadAudioSettings();
|
return loadAudioSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LiveKit / getUserMedia capture options from stored settings.
|
||||||
|
* Always sets voiceIsolation explicitly — LiveKit 2.20 defaults it to true,
|
||||||
|
* which overrides noiseSuppression when left unset.
|
||||||
|
*/
|
||||||
|
export function getAudioCaptureOptions(): {
|
||||||
|
deviceId?: string;
|
||||||
|
noiseSuppression: boolean;
|
||||||
|
echoCancellation: boolean;
|
||||||
|
autoGainControl: boolean;
|
||||||
|
voiceIsolation: boolean;
|
||||||
|
} {
|
||||||
|
const settings = getAudioSettings();
|
||||||
|
return {
|
||||||
|
deviceId: settings.microphoneId || undefined,
|
||||||
|
noiseSuppression: settings.noiseSuppression,
|
||||||
|
echoCancellation: settings.echoCancellation,
|
||||||
|
autoGainControl: settings.autoGainControl,
|
||||||
|
voiceIsolation: settings.noiseSuppression,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MediaTrackConstraints for getUserMedia / applyConstraints from UI settings. */
|
||||||
|
function getMicTrackConstraints(settings: AudioSettings): MediaTrackConstraints {
|
||||||
|
return {
|
||||||
|
...(settings.microphoneId ? { deviceId: { exact: settings.microphoneId } } : {}),
|
||||||
|
noiseSuppression: settings.noiseSuppression,
|
||||||
|
echoCancellation: settings.echoCancellation,
|
||||||
|
autoGainControl: settings.autoGainControl,
|
||||||
|
// Experimental; Chromium / Electron. Keep in sync with noise suppression.
|
||||||
|
voiceIsolation: settings.noiseSuppression,
|
||||||
|
} as MediaTrackConstraints;
|
||||||
|
}
|
||||||
|
|
||||||
type DeviceSelectorProps = {
|
type DeviceSelectorProps = {
|
||||||
devices: AudioDevice[];
|
devices: AudioDevice[];
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
@@ -635,22 +673,9 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
} else {
|
} else {
|
||||||
// Start monitoring
|
// Start monitoring
|
||||||
try {
|
try {
|
||||||
const constraints: MediaStreamConstraints = {
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: settings.microphoneId
|
audio: getMicTrackConstraints(settings),
|
||||||
? {
|
});
|
||||||
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 audioContext = new AudioContext();
|
||||||
const analyser = audioContext.createAnalyser();
|
const analyser = audioContext.createAnalyser();
|
||||||
analyser.fftSize = 256;
|
analyser.fftSize = 256;
|
||||||
@@ -685,7 +710,7 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
}
|
}
|
||||||
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
||||||
|
|
||||||
// Cleanup monitoring on unmount or when settings change
|
// Cleanup monitoring on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (micAnimFrameRef.current !== null) {
|
if (micAnimFrameRef.current !== null) {
|
||||||
@@ -700,14 +725,25 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Restart monitoring when mic settings change
|
// Apply processing toggles to the live monitor track without tearing it down.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (micMonitoring) {
|
if (!micMonitoring) return;
|
||||||
handleMicMonitoringToggle(); // Stop
|
const track = micMonitorStreamRef.current?.getAudioTracks()[0];
|
||||||
setTimeout(() => handleMicMonitoringToggle(), 100); // Restart
|
if (!track) return;
|
||||||
}
|
track
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
.applyConstraints({
|
||||||
}, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
noiseSuppression: settings.noiseSuppression,
|
||||||
|
echoCancellation: settings.echoCancellation,
|
||||||
|
autoGainControl: settings.autoGainControl,
|
||||||
|
voiceIsolation: settings.noiseSuppression,
|
||||||
|
} as MediaTrackConstraints)
|
||||||
|
.catch((e) => console.error('Failed to apply mic processing constraints:', e));
|
||||||
|
}, [
|
||||||
|
micMonitoring,
|
||||||
|
settings.noiseSuppression,
|
||||||
|
settings.echoCancellation,
|
||||||
|
settings.autoGainControl,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
|||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI';
|
import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { fuzzyFilter } from '../../../utils/fuzzy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders settings UI for a plugin based on its settings schema
|
* Renders settings UI for a plugin based on its settings schema
|
||||||
@@ -320,13 +321,6 @@ export function Plugins({ requestClose }: PluginsProps) {
|
|||||||
[installedPlugins]
|
[installedPlugins]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fuzzySearch = (query: string, text: string): boolean => {
|
|
||||||
if (!query) return true;
|
|
||||||
const searchLower = query.toLowerCase();
|
|
||||||
const textLower = text.toLowerCase();
|
|
||||||
return textLower.includes(searchLower);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => {
|
const PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
@@ -365,22 +359,22 @@ export function Plugins({ requestClose }: PluginsProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filteredMarketplacePlugins = useMemo(() => {
|
const filteredMarketplacePlugins = useMemo(() => {
|
||||||
if (!searchQuery) return marketplacePlugins;
|
if (!searchQuery.trim()) return marketplacePlugins;
|
||||||
return marketplacePlugins.filter((plugin) =>
|
return fuzzyFilter(marketplacePlugins, searchQuery, (plugin) => [
|
||||||
fuzzySearch(searchQuery, plugin.name) ||
|
plugin.name,
|
||||||
fuzzySearch(searchQuery, plugin.description) ||
|
plugin.description,
|
||||||
fuzzySearch(searchQuery, plugin.author) ||
|
plugin.author,
|
||||||
plugin.tags.some((tag) => fuzzySearch(searchQuery, tag))
|
...plugin.tags,
|
||||||
);
|
]).map(({ item }) => item);
|
||||||
}, [marketplacePlugins, searchQuery]);
|
}, [marketplacePlugins, searchQuery]);
|
||||||
|
|
||||||
const filteredInstalledPlugins = useMemo(() => {
|
const filteredInstalledPlugins = useMemo(() => {
|
||||||
if (!searchQuery) return installedPlugins;
|
if (!searchQuery.trim()) return installedPlugins;
|
||||||
return installedPlugins.filter((plugin) =>
|
return fuzzyFilter(installedPlugins, searchQuery, (plugin) => [
|
||||||
fuzzySearch(searchQuery, plugin.name) ||
|
plugin.name,
|
||||||
fuzzySearch(searchQuery, plugin.description) ||
|
plugin.description,
|
||||||
fuzzySearch(searchQuery, plugin.author)
|
plugin.author,
|
||||||
);
|
]).map(({ item }) => item);
|
||||||
}, [installedPlugins, searchQuery]);
|
}, [installedPlugins, searchQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
137
src/app/features/settings/settingsSearchCatalog.ts
Normal file
137
src/app/features/settings/settingsSearchCatalog.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { SettingsPages } from './SettingsPages';
|
||||||
|
|
||||||
|
export type SettingsSearchEntry = {
|
||||||
|
page: SettingsPages;
|
||||||
|
/** Primary label shown in results. */
|
||||||
|
title: string;
|
||||||
|
/** Extra terms for fuzzy matching (aliases, related words). */
|
||||||
|
keywords?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog of settings pages and individual tiles for fuzzy search.
|
||||||
|
* Keep titles aligned with SettingTile labels in each page.
|
||||||
|
*/
|
||||||
|
export const SETTINGS_SEARCH_CATALOG: SettingsSearchEntry[] = [
|
||||||
|
// Pages
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'General', keywords: ['appearance', 'theme', 'messages', 'editor'] },
|
||||||
|
{ page: SettingsPages.AccountPage, title: 'Account', keywords: ['profile', 'email', 'mxid', 'ignore'] },
|
||||||
|
{
|
||||||
|
page: SettingsPages.NotificationPage,
|
||||||
|
title: 'Notifications',
|
||||||
|
keywords: ['alerts', 'push', 'desktop', 'sound', 'mentions'],
|
||||||
|
},
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Audio & Video', keywords: ['microphone', 'speaker', 'call', 'camera'] },
|
||||||
|
{ page: SettingsPages.DevicesPage, title: 'Devices', keywords: ['sessions', 'verification', 'backup', 'export'] },
|
||||||
|
{
|
||||||
|
page: SettingsPages.EmojisStickersPage,
|
||||||
|
title: 'Emojis & Stickers',
|
||||||
|
keywords: ['emoticons', 'stickers', 'telegram', 'custom emoji'],
|
||||||
|
},
|
||||||
|
{ page: SettingsPages.PluginsPage, title: 'Plugins', keywords: ['extensions', 'addons', 'marketplace'] },
|
||||||
|
{
|
||||||
|
page: SettingsPages.DeveloperToolsPage,
|
||||||
|
title: 'Developer Tools',
|
||||||
|
keywords: ['debug', 'token', 'account data'],
|
||||||
|
},
|
||||||
|
{ page: SettingsPages.AboutPage, title: 'About', keywords: ['version', 'cache', 'protocol', 'paarrot'] },
|
||||||
|
|
||||||
|
// General
|
||||||
|
{
|
||||||
|
page: SettingsPages.GeneralPage,
|
||||||
|
title: 'System Theme',
|
||||||
|
keywords: ['appearance', 'dark', 'light', 'auto'],
|
||||||
|
},
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Theme', keywords: ['appearance', 'color scheme'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Monochrome Mode', keywords: ['grayscale', 'appearance'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Emoji Style', keywords: ['appearance', 'twemoji', 'native'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Page Zoom', keywords: ['scale', 'accessibility', 'size'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Date Format', keywords: ['time', 'calendar'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: '24-Hour Time Format', keywords: ['clock', 'time'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'ENTER for Newline', keywords: ['editor', 'keyboard', 'shift enter'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Markdown Formatting', keywords: ['editor', 'formatting'] },
|
||||||
|
{
|
||||||
|
page: SettingsPages.GeneralPage,
|
||||||
|
title: 'Hide Typing & Read Receipts',
|
||||||
|
keywords: ['privacy', 'activity', 'typing'],
|
||||||
|
},
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Auto-Join Space Rooms', keywords: ['spaces', 'join'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Message Layout', keywords: ['bubbles', 'compact', 'modern'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Message Spacing', keywords: ['density', 'gap'] },
|
||||||
|
{
|
||||||
|
page: SettingsPages.GeneralPage,
|
||||||
|
title: 'Scroll to Latest on Room Reselect',
|
||||||
|
keywords: ['timeline', 'jump'],
|
||||||
|
},
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Legacy Username Color', keywords: ['appearance', 'mxid color'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Hide Membership Change', keywords: ['joins', 'leaves', 'timeline'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Hide Profile Change', keywords: ['avatar', 'displayname', 'timeline'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Disable Media Auto Load', keywords: ['images', 'bandwidth', 'lazy'] },
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Url Preview', keywords: ['link', 'embed', 'unfurl'] },
|
||||||
|
{
|
||||||
|
page: SettingsPages.GeneralPage,
|
||||||
|
title: 'Url Preview in Encrypted Room',
|
||||||
|
keywords: ['e2ee', 'link', 'embed'],
|
||||||
|
},
|
||||||
|
{ page: SettingsPages.GeneralPage, title: 'Show Hidden Events', keywords: ['state', 'debug', 'timeline'] },
|
||||||
|
|
||||||
|
// Account
|
||||||
|
{ page: SettingsPages.AccountPage, title: 'Profile', keywords: ['displayname', 'avatar', 'name'] },
|
||||||
|
{ page: SettingsPages.AccountPage, title: 'Email Address', keywords: ['contact', 'mail'] },
|
||||||
|
{ page: SettingsPages.AccountPage, title: 'Ignored Users', keywords: ['block', 'mute', 'ignore'] },
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
{ page: SettingsPages.NotificationPage, title: 'Desktop Notifications', keywords: ['system', 'os'] },
|
||||||
|
{ page: SettingsPages.NotificationPage, title: 'Notification Sound', keywords: ['audio', 'alert'] },
|
||||||
|
{ page: SettingsPages.NotificationPage, title: 'Email Notification', keywords: ['mail'] },
|
||||||
|
{ page: SettingsPages.NotificationPage, title: 'Background Notifications', keywords: ['tray', 'electron'] },
|
||||||
|
{ page: SettingsPages.NotificationPage, title: '1-to-1 Chats', keywords: ['dm', 'direct'] },
|
||||||
|
{ page: SettingsPages.NotificationPage, title: 'Rooms', keywords: ['messages', 'push'] },
|
||||||
|
{ page: SettingsPages.NotificationPage, title: 'Mention @room', keywords: ['highlight', 'ping'] },
|
||||||
|
{ page: SettingsPages.NotificationPage, title: 'Keyword Messages', keywords: ['highlight', 'filter'] },
|
||||||
|
{ page: SettingsPages.NotificationPage, title: 'Special Messages', keywords: ['mentions', 'displayname'] },
|
||||||
|
{ page: SettingsPages.NotificationPage, title: 'Test Notification', keywords: ['preview', 'sound'] },
|
||||||
|
|
||||||
|
// Audio & Video
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Input Device', keywords: ['microphone', 'mic'] },
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Output Device', keywords: ['speaker', 'headphones'] },
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Noise Suppression', keywords: ['microphone', 'processing'] },
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Echo Cancellation', keywords: ['microphone', 'aec'] },
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Auto Gain Control', keywords: ['microphone', 'agc'] },
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Resolution', keywords: ['screen share', 'video'] },
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Bitrate', keywords: ['screen share', 'quality'] },
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Frame Rate', keywords: ['screen share', 'fps'] },
|
||||||
|
{ page: SettingsPages.AudioPage, title: 'Show Remote Cursor', keywords: ['screen share', 'pointer'] },
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
{ page: SettingsPages.DevicesPage, title: 'Device Verification', keywords: ['crypto', 'cross signing'] },
|
||||||
|
{ page: SettingsPages.DevicesPage, title: 'Device Dashboard', keywords: ['sessions', 'logout'] },
|
||||||
|
{ page: SettingsPages.DevicesPage, title: 'Export Messages Data', keywords: ['backup', 'download'] },
|
||||||
|
{ page: SettingsPages.DevicesPage, title: 'Import Messages Data', keywords: ['backup', 'restore'] },
|
||||||
|
|
||||||
|
// Emojis & Stickers
|
||||||
|
{
|
||||||
|
page: SettingsPages.EmojisStickersPage,
|
||||||
|
title: 'Auto-convert Text Emoticons',
|
||||||
|
keywords: ['smileys', 'colon'],
|
||||||
|
},
|
||||||
|
{ page: SettingsPages.EmojisStickersPage, title: 'Telegram Bot Token', keywords: ['stickers', 'import'] },
|
||||||
|
{ page: SettingsPages.EmojisStickersPage, title: 'Sticker Pack URL', keywords: ['telegram', 'import'] },
|
||||||
|
|
||||||
|
// Developer Tools
|
||||||
|
{
|
||||||
|
page: SettingsPages.DeveloperToolsPage,
|
||||||
|
title: 'Enable Developer Tools',
|
||||||
|
keywords: ['debug'],
|
||||||
|
},
|
||||||
|
{ page: SettingsPages.DeveloperToolsPage, title: 'Access Token', keywords: ['auth', 'secret'] },
|
||||||
|
{ page: SettingsPages.DeveloperToolsPage, title: 'Account Data', keywords: ['global', 'events'] },
|
||||||
|
|
||||||
|
// About
|
||||||
|
{ page: SettingsPages.AboutPage, title: 'Clear Cache & Reload', keywords: ['reset', 'storage'] },
|
||||||
|
{
|
||||||
|
page: SettingsPages.AboutPage,
|
||||||
|
title: 'Protocol Handler (paarrot://)',
|
||||||
|
keywords: ['deeplink', 'url scheme'],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,6 +1,27 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { config } from 'folds';
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
export const SequenceCardStyle = style({
|
export const SequenceCardStyle = style({
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const SettingsSearchResult = style({
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: config.space.S200,
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.Background.ContainerHover,
|
||||||
|
},
|
||||||
|
'&[aria-pressed="true"]': {
|
||||||
|
backgroundColor: color.Background.ContainerActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useRoomsUnread } from '../../state/hooks/unread';
|
|||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
||||||
|
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
import { copyToClipboard } from '../../utils/dom';
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
|
||||||
@@ -32,6 +33,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { getSpaceFeedPath, getSpaceLobbyPath } from '../../pages/pathUtils';
|
import { getSpaceFeedPath, getSpaceLobbyPath } from '../../pages/pathUtils';
|
||||||
import { shouldShowForumLobby } from '../../utils/room';
|
import { shouldShowForumLobby } from '../../utils/room';
|
||||||
import { useSpaceFeedSelected } from '../../hooks/router/useSelectedSpace';
|
import { useSpaceFeedSelected } from '../../hooks/router/useSelectedSpace';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
|
||||||
type ForumAddOptions = {
|
type ForumAddOptions = {
|
||||||
item: HierarchyItem;
|
item: HierarchyItem;
|
||||||
@@ -65,6 +67,7 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
|
|||||||
|
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||||
|
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
|
||||||
const openSpaceSettings = useOpenSpaceSettings();
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const markRoomsAsRead = useMarkRoomsAsRead(mx);
|
const markRoomsAsRead = useMarkRoomsAsRead(mx);
|
||||||
@@ -304,6 +307,34 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
|
|||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{canEditChildren && (
|
||||||
|
<UseStateProvider initial={false}>
|
||||||
|
{(promptRemove, setPromptRemove) => (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => setPromptRemove(true)}
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Delete} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={promptRemove}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Remove Inaccessible
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
{promptRemove && (
|
||||||
|
<RemoveInaccessiblePrompt
|
||||||
|
roomId={room.roomId}
|
||||||
|
onDone={requestClose}
|
||||||
|
onCancel={() => setPromptRemove(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
)}
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(promptLeave, setPromptLeave) => (
|
{(promptLeave, setPromptLeave) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
export const useDirectSelected = (): boolean => {
|
export const useDirectSelected = (): boolean => {
|
||||||
const directMatch = useMatch({
|
const directMatch = useMatch({
|
||||||
@@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => {
|
|||||||
|
|
||||||
return !!match;
|
return !!match;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDirectSearchSelected = (): boolean => {
|
||||||
|
const match = useMatch({
|
||||||
|
path: getDirectSearchPath(),
|
||||||
|
caseSensitive: true,
|
||||||
|
end: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!match;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,89 +1,57 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getCurrentAccessToken } from '../utils/auth';
|
import {
|
||||||
|
getCachedAuthenticatedMediaUrl,
|
||||||
|
isAuthenticatedMediaUrl,
|
||||||
|
resolveAuthenticatedMediaUrl,
|
||||||
|
} from '../utils/authenticatedMediaCache';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches media with authentication and returns a blob URL.
|
* Resolves media URLs for Matrix authenticated media.
|
||||||
* This is needed because service workers may not work reliably in Tauri/WebKit environments,
|
* Uses a shared blob-URL cache so avatars/thumbs are not re-fetched on every remount.
|
||||||
* and cross-origin image requests cannot include Authorization headers.
|
|
||||||
*/
|
*/
|
||||||
export const useAuthenticatedMediaUrl = (
|
export const useAuthenticatedMediaUrl = (
|
||||||
src: string | undefined,
|
src: string | undefined,
|
||||||
useAuthentication: boolean
|
useAuthentication: boolean
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
const [blobUrl, setBlobUrl] = useState<string | undefined>(() => {
|
||||||
|
if (!src) return undefined;
|
||||||
|
if (!useAuthentication || !isAuthenticatedMediaUrl(src)) return src;
|
||||||
|
return getCachedAuthenticatedMediaUrl(src);
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
setBlobUrl(undefined);
|
setBlobUrl(undefined);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not using authentication, just return the original URL
|
if (!useAuthentication || !isAuthenticatedMediaUrl(src)) {
|
||||||
if (!useAuthentication) {
|
|
||||||
setBlobUrl(src);
|
setBlobUrl(src);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is an authenticated media URL
|
const cached = getCachedAuthenticatedMediaUrl(src);
|
||||||
const isAuthenticatedMediaUrl =
|
if (cached) {
|
||||||
src.includes('/_matrix/client/v1/media/download') ||
|
setBlobUrl(cached);
|
||||||
src.includes('/_matrix/client/v1/media/thumbnail') ||
|
return undefined;
|
||||||
(src.includes('/_matrix/media/') &&
|
|
||||||
(src.includes('/download/') || src.includes('/thumbnail/')));
|
|
||||||
|
|
||||||
if (!isAuthenticatedMediaUrl) {
|
|
||||||
setBlobUrl(src);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let objectUrl: string | undefined;
|
|
||||||
|
|
||||||
const fetchMedia = async () => {
|
resolveAuthenticatedMediaUrl(src, useAuthentication)
|
||||||
try {
|
.then((url) => {
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
if (!cancelled) setBlobUrl(url);
|
||||||
const accessToken = getCurrentAccessToken();
|
})
|
||||||
let response = await fetch(src, {
|
.catch((error) => {
|
||||||
method: 'GET',
|
console.warn('[useAuthenticatedMediaUrl] Error fetching authenticated media:', error);
|
||||||
headers: accessToken
|
|
||||||
? { Authorization: `Bearer ${accessToken}` }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken) {
|
|
||||||
console.warn('[useAuthenticatedMediaUrl] Auth failed (401), attempting unauthenticated fallback for:', src);
|
|
||||||
response = await fetch(src, { method: 'GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn(`Failed to fetch authenticated media: ${response.status}`);
|
|
||||||
// Fall back to original URL in case server doesn't require auth
|
|
||||||
if (!cancelled) setBlobUrl(src);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
if (!cancelled) {
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
|
||||||
setBlobUrl(objectUrl);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Error fetching authenticated media:', error);
|
|
||||||
// Fall back to original URL
|
|
||||||
if (!cancelled) setBlobUrl(src);
|
if (!cancelled) setBlobUrl(src);
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
fetchMedia();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (objectUrl) {
|
// Shared cache owns blob URLs — do not revoke on unmount.
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [src, useAuthentication, mx]);
|
}, [src, useAuthentication, mx]);
|
||||||
|
|
||||||
@@ -98,42 +66,11 @@ export const useAuthenticatedMediaFetch = () => {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (src: string): Promise<string> => {
|
async (src: string, useAuthentication = true): Promise<string> => {
|
||||||
const isAuthenticatedMediaUrl =
|
|
||||||
src.includes('/_matrix/client/v1/media/download') ||
|
|
||||||
src.includes('/_matrix/client/v1/media/thumbnail') ||
|
|
||||||
(src.includes('/_matrix/media/') &&
|
|
||||||
(src.includes('/download/') || src.includes('/thumbnail/')));
|
|
||||||
|
|
||||||
if (!isAuthenticatedMediaUrl) {
|
|
||||||
return src;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
return await resolveAuthenticatedMediaUrl(src, useAuthentication);
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
let response = await fetch(src, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: accessToken
|
|
||||||
? { Authorization: `Bearer ${accessToken}` }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken) {
|
|
||||||
console.warn('[useAuthenticatedMediaFetch] Auth failed (401), attempting unauthenticated fallback');
|
|
||||||
response = await fetch(src, { method: 'GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn(`Failed to fetch authenticated media: ${response.status}`);
|
|
||||||
return src;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error fetching authenticated media:', error);
|
console.warn('[useAuthenticatedMediaFetch] Error fetching authenticated media:', error);
|
||||||
return src;
|
return src;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
151
src/app/hooks/useBackRoute.ts
Normal file
151
src/app/hooks/useBackRoute.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { Location, matchPath, useLocation, useParams } from 'react-router-dom';
|
||||||
|
import type { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
decodeRouteParam,
|
||||||
|
getDirectPath,
|
||||||
|
getExplorePath,
|
||||||
|
getHomePath,
|
||||||
|
getInboxPath,
|
||||||
|
getSpacePath,
|
||||||
|
} from '../pages/pathUtils';
|
||||||
|
import {
|
||||||
|
DIRECT_PATH,
|
||||||
|
EXPLORE_PATH,
|
||||||
|
HOME_PATH,
|
||||||
|
INBOX_PATH,
|
||||||
|
SPACE_FEED_PATH,
|
||||||
|
SPACE_LOBBY_PATH,
|
||||||
|
SPACE_PATH,
|
||||||
|
SPACE_ROOM_PATH,
|
||||||
|
SPACE_SEARCH_PATH,
|
||||||
|
} from '../pages/paths';
|
||||||
|
import { ScreenSize } from './useScreenSize';
|
||||||
|
import { useNavigateWithTransition } from './useViewTransitions';
|
||||||
|
import { useMatrixClientOptionally } from './useMatrixClient';
|
||||||
|
import { useScreenSizeContext } from './useScreenSize';
|
||||||
|
import { getCanonicalAliasOrRoomId, getCanonicalAliasRoomId, isRoomAlias } from '../utils/matrix';
|
||||||
|
|
||||||
|
type BackRouteContext = {
|
||||||
|
location: Location;
|
||||||
|
mx: MatrixClient;
|
||||||
|
selectedSpaceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const spaceListPath = (mx: MatrixClient, spaceId: string, spaceIdOrAlias: string): string =>
|
||||||
|
getSpacePath(getCanonicalAliasOrRoomId(mx, spaceId) ?? spaceIdOrAlias);
|
||||||
|
|
||||||
|
export const getBackRouteTarget = ({
|
||||||
|
location,
|
||||||
|
mx,
|
||||||
|
selectedSpaceId,
|
||||||
|
}: BackRouteContext): string | null => {
|
||||||
|
const { pathname } = location;
|
||||||
|
|
||||||
|
const homeMatch = matchPath({ path: HOME_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
|
if (homeMatch) {
|
||||||
|
const atRoot = matchPath({ path: HOME_PATH, caseSensitive: true, end: true }, pathname);
|
||||||
|
if (!atRoot) {
|
||||||
|
return getHomePath();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directMatch = matchPath({ path: DIRECT_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
|
if (directMatch) {
|
||||||
|
const atRoot = matchPath({ path: DIRECT_PATH, caseSensitive: true, end: true }, pathname);
|
||||||
|
if (!atRoot) {
|
||||||
|
return getDirectPath();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
|
if (spaceMatch?.params.spaceIdOrAlias) {
|
||||||
|
const spaceIdOrAlias = decodeRouteParam(spaceMatch.params.spaceIdOrAlias);
|
||||||
|
if (!spaceIdOrAlias) return null;
|
||||||
|
|
||||||
|
const spaceId = selectedSpaceId ?? spaceIdOrAlias;
|
||||||
|
const listPath = spaceListPath(mx, spaceId, spaceIdOrAlias);
|
||||||
|
|
||||||
|
const inRoom = matchPath({ path: SPACE_ROOM_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
|
if (inRoom) {
|
||||||
|
return listPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atLobby = matchPath({ path: SPACE_LOBBY_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
|
if (atLobby) {
|
||||||
|
return listPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atFeed = matchPath({ path: SPACE_FEED_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
|
if (atFeed) {
|
||||||
|
return listPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atSearch = matchPath({ path: SPACE_SEARCH_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
|
if (atSearch) {
|
||||||
|
return listPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exploreMatch = matchPath({ path: EXPLORE_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
|
if (exploreMatch) {
|
||||||
|
const atRoot = matchPath({ path: EXPLORE_PATH, caseSensitive: true, end: true }, pathname);
|
||||||
|
if (!atRoot) {
|
||||||
|
return getExplorePath();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboxMatch = matchPath({ path: INBOX_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
|
if (inboxMatch) {
|
||||||
|
const atRoot = matchPath({ path: INBOX_PATH, caseSensitive: true, end: true }, pathname);
|
||||||
|
if (!atRoot) {
|
||||||
|
return getInboxPath();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showBackInPageHeader = (screenSize: ScreenSize): boolean =>
|
||||||
|
screenSize !== ScreenSize.Desktop;
|
||||||
|
|
||||||
|
export const showBackInTitleBar = (screenSize: ScreenSize, canGoBack: boolean): boolean =>
|
||||||
|
screenSize === ScreenSize.Desktop && canGoBack;
|
||||||
|
|
||||||
|
export const useBackRoute = (mxOverride?: MatrixClient | null) => {
|
||||||
|
const navigate = useNavigateWithTransition();
|
||||||
|
const location = useLocation();
|
||||||
|
const mxFromContext = useMatrixClientOptionally();
|
||||||
|
const mx = mxOverride ?? mxFromContext;
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
const { spaceIdOrAlias: rawSpaceIdOrAlias } = useParams();
|
||||||
|
const spaceIdOrAlias = decodeRouteParam(rawSpaceIdOrAlias);
|
||||||
|
const selectedSpaceId =
|
||||||
|
mx && spaceIdOrAlias && isRoomAlias(spaceIdOrAlias)
|
||||||
|
? getCanonicalAliasRoomId(mx, spaceIdOrAlias)
|
||||||
|
: spaceIdOrAlias;
|
||||||
|
|
||||||
|
const target = useMemo(() => {
|
||||||
|
if (!mx) return null;
|
||||||
|
return getBackRouteTarget({ location, mx, selectedSpaceId });
|
||||||
|
}, [location, mx, selectedSpaceId]);
|
||||||
|
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
if (target) {
|
||||||
|
navigate(target);
|
||||||
|
}
|
||||||
|
}, [navigate, target]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canGoBack: target !== null,
|
||||||
|
goBack,
|
||||||
|
showInPageHeader: showBackInPageHeader(screenSize) && target !== null,
|
||||||
|
showInTitleBar: showBackInTitleBar(screenSize, target !== null),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -26,7 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate';
|
|||||||
import { Membership, StateEvent } from '../../types/matrix/room';
|
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||||
import { getStateEvent } from '../utils/room';
|
import { getStateEvent } from '../utils/room';
|
||||||
import { splitWithSpace } from '../utils/common';
|
import { splitWithSpace } from '../utils/common';
|
||||||
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../components/create-room';
|
import { createRoomEncryptionState } from '../components/create-room';
|
||||||
|
|
||||||
export const SHRUG = '¯\\_(ツ)_/¯';
|
export const SHRUG = '¯\\_(ツ)_/¯';
|
||||||
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
|
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
|
||||||
@@ -218,7 +218,8 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||||||
invite: userIds,
|
invite: userIds,
|
||||||
visibility: Visibility.Private,
|
visibility: Visibility.Private,
|
||||||
preset: Preset.TrustedPrivateChat,
|
preset: Preset.TrustedPrivateChat,
|
||||||
initial_state: [createRoomPowerLevelsState(), createRoomEncryptionState()],
|
// No power_levels in initial_state — incomplete PL for call.member causes 403
|
||||||
|
initial_state: [createRoomEncryptionState()],
|
||||||
});
|
});
|
||||||
addRoomIdToMDirect(mx, result.room_id, userIds[0]);
|
addRoomIdToMDirect(mx, result.room_id, userIds[0]);
|
||||||
navigateRoom(result.room_id);
|
navigateRoom(result.room_id);
|
||||||
|
|||||||
26
src/app/hooks/useCompactNav.ts
Normal file
26
src/app/hooks/useCompactNav.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useMatch } from 'react-router-dom';
|
||||||
|
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from './useScreenSize';
|
||||||
|
|
||||||
|
/** Skinny window layout (≤750px): master-detail instead of 3-column desktop. */
|
||||||
|
export const useCompactNav = (): boolean => {
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
return screenSize === ScreenSize.Mobile;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Routes where sidebar + channel list are shown together (no room/content pane). */
|
||||||
|
export const useIsCompactListRoute = (): boolean => {
|
||||||
|
const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
|
||||||
|
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
|
||||||
|
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
|
||||||
|
const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
|
||||||
|
const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true });
|
||||||
|
|
||||||
|
return !!(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useShowCompactMasterView = (): boolean => {
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const isListRoute = useIsCompactListRoute();
|
||||||
|
return compact && isListRoute;
|
||||||
|
};
|
||||||
@@ -10,3 +10,7 @@ export function useMatrixClient(): MatrixClient {
|
|||||||
if (!mx) throw new Error('MatrixClient not initialized!');
|
if (!mx) throw new Error('MatrixClient not initialized!');
|
||||||
return mx;
|
return mx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMatrixClientOptionally(): MatrixClient | null {
|
||||||
|
return useContext(MatrixClientContext);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useClientConfig } from './useClientConfig';
|
import { useClientConfig } from './useClientConfig';
|
||||||
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
||||||
|
import { isElectron } from '../utils/tauri';
|
||||||
|
|
||||||
|
/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
|
||||||
|
export const ELECTRON_PROTOCOL_HOST = 'desktop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute URL for the given in-app path.
|
||||||
|
*
|
||||||
|
* In Electron, SSO (and other browser handoffs) must return via paarrot://desktop/...
|
||||||
|
* — a named host so consent UI identifies the desktop app, and a real pathname
|
||||||
|
* (not a hash) because browsers strip fragments when launching custom protocols.
|
||||||
|
*/
|
||||||
export const usePathWithOrigin = (path: string): string => {
|
export const usePathWithOrigin = (path: string): string => {
|
||||||
const { hashRouter } = useClientConfig();
|
const { hashRouter } = useClientConfig();
|
||||||
const { origin } = window.location;
|
const { origin } = window.location;
|
||||||
|
|
||||||
const pathWithOrigin = useMemo(() => {
|
const pathWithOrigin = useMemo(() => {
|
||||||
|
if (isElectron()) {
|
||||||
|
const basename = trimSlash(hashRouter?.basename ?? '');
|
||||||
|
const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
|
||||||
|
const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
|
||||||
|
return `paarrot://${ELECTRON_PROTOCOL_HOST}${route}`;
|
||||||
|
}
|
||||||
|
|
||||||
let url: string = trimSlash(origin);
|
let url: string = trimSlash(origin);
|
||||||
|
|
||||||
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
||||||
|
|||||||
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import {
|
||||||
|
Direction,
|
||||||
|
IRoomEvent,
|
||||||
|
MatrixEvent,
|
||||||
|
MsgType,
|
||||||
|
RelationType,
|
||||||
|
Room,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||||
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { IEncryptedFile } from '../../types/matrix/common';
|
||||||
|
|
||||||
|
export type RoomMediaItem = {
|
||||||
|
eventId: string;
|
||||||
|
msgtype: typeof MsgType.Image | typeof MsgType.Video;
|
||||||
|
/** MXC used for an image thumbnail when available. */
|
||||||
|
thumbMxc?: string;
|
||||||
|
thumbMimeType?: string;
|
||||||
|
thumbEncInfo?: EncryptedAttachmentInfo;
|
||||||
|
/** Full media MXC — used to extract a video frame when no image thumb exists. */
|
||||||
|
fullMxc: string;
|
||||||
|
fullMimeType?: string;
|
||||||
|
fullEncInfo?: EncryptedAttachmentInfo;
|
||||||
|
body: string;
|
||||||
|
ts: number;
|
||||||
|
sender: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const TARGET_BATCH = 24;
|
||||||
|
const MAX_PAGES_PER_LOAD = 8;
|
||||||
|
|
||||||
|
type RoomMediaCache = {
|
||||||
|
items: RoomMediaItem[];
|
||||||
|
hasMore: boolean;
|
||||||
|
nextToken: string | null;
|
||||||
|
seenIds: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Survives MediaDrawer remounts within the same room/session. */
|
||||||
|
const mediaCache = new Map<string, RoomMediaCache>();
|
||||||
|
|
||||||
|
const getOrCreateCache = (roomId: string): RoomMediaCache => {
|
||||||
|
const existing = mediaCache.get(roomId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created: RoomMediaCache = {
|
||||||
|
items: [],
|
||||||
|
hasMore: true,
|
||||||
|
nextToken: null,
|
||||||
|
seenIds: new Set(),
|
||||||
|
};
|
||||||
|
mediaCache.set(roomId, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => {
|
||||||
|
if (mEvent.isRedacted()) return undefined;
|
||||||
|
|
||||||
|
const relation = mEvent.getRelation();
|
||||||
|
if (relation?.rel_type === RelationType.Replace) return undefined;
|
||||||
|
|
||||||
|
const content = mEvent.getContent();
|
||||||
|
const msgtype = content.msgtype;
|
||||||
|
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video) return undefined;
|
||||||
|
|
||||||
|
const file = content.file as IEncryptedFile | undefined;
|
||||||
|
const info = content.info as
|
||||||
|
| {
|
||||||
|
mimetype?: string;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
thumbnail_info?: { mimetype?: string };
|
||||||
|
thumbnail_file?: IEncryptedFile;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const fullMxc =
|
||||||
|
(typeof content.url === 'string' && content.url) ||
|
||||||
|
(typeof file?.url === 'string' && file.url) ||
|
||||||
|
undefined;
|
||||||
|
if (!fullMxc) return undefined;
|
||||||
|
|
||||||
|
const thumbFile = info?.thumbnail_file;
|
||||||
|
const thumbMxc =
|
||||||
|
(typeof info?.thumbnail_url === 'string' && info.thumbnail_url) ||
|
||||||
|
(typeof thumbFile?.url === 'string' && thumbFile.url) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
const sender = mEvent.getSender();
|
||||||
|
if (!eventId || !sender) return undefined;
|
||||||
|
|
||||||
|
const usingThumbEnc = !!thumbMxc && thumbMxc === thumbFile?.url;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId,
|
||||||
|
msgtype,
|
||||||
|
thumbMxc,
|
||||||
|
thumbMimeType: usingThumbEnc
|
||||||
|
? info?.thumbnail_info?.mimetype
|
||||||
|
: thumbMxc
|
||||||
|
? info?.thumbnail_info?.mimetype
|
||||||
|
: undefined,
|
||||||
|
thumbEncInfo: usingThumbEnc ? thumbFile : undefined,
|
||||||
|
fullMxc,
|
||||||
|
fullMimeType: info?.mimetype,
|
||||||
|
fullEncInfo: file,
|
||||||
|
body: typeof content.body === 'string' ? content.body : msgtype,
|
||||||
|
ts: mEvent.getTs(),
|
||||||
|
sender,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginates room history for image/video messages without mutating the live timeline.
|
||||||
|
*/
|
||||||
|
export const useRoomMediaEvents = (room: Room) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const roomId = room.roomId;
|
||||||
|
const cacheRef = useRef(getOrCreateCache(roomId));
|
||||||
|
if (cacheRef.current !== mediaCache.get(roomId)) {
|
||||||
|
cacheRef.current = getOrCreateCache(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, setItems] = useState<RoomMediaItem[]>(() => cacheRef.current.items);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(() => cacheRef.current.hasMore);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
|
const decryptEvent = useCallback(
|
||||||
|
async (raw: IRoomEvent | MatrixEvent) => {
|
||||||
|
const mEvent = raw instanceof MatrixEvent ? raw : new MatrixEvent(raw);
|
||||||
|
if (mEvent.isEncrypted() && mx.getCrypto()) {
|
||||||
|
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
|
||||||
|
}
|
||||||
|
return mEvent;
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchPages = useCallback(async () => {
|
||||||
|
const cache = cacheRef.current;
|
||||||
|
const collected: RoomMediaItem[] = [];
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES_PER_LOAD; page += 1) {
|
||||||
|
const response = await mx.createMessagesRequest(
|
||||||
|
roomId,
|
||||||
|
cache.nextToken,
|
||||||
|
PAGE_SIZE,
|
||||||
|
Direction.Backward
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.nextToken = response.end ?? null;
|
||||||
|
if (!response.end) {
|
||||||
|
cache.hasMore = false;
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of response.chunk ?? []) {
|
||||||
|
const mEvent = await decryptEvent(raw);
|
||||||
|
const item = toMediaItem(mEvent);
|
||||||
|
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||||
|
cache.seenIds.add(item.eventId);
|
||||||
|
collected.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collected.length >= TARGET_BATCH || !response.end) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}, [mx, roomId, decryptEvent]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
const cache = cacheRef.current;
|
||||||
|
if (loadingRef.current || !cache.hasMore) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collected = await fetchPages();
|
||||||
|
if (collected.length > 0) {
|
||||||
|
cache.items = [...cache.items, ...collected];
|
||||||
|
setItems(cache.items);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchPages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cache = getOrCreateCache(roomId);
|
||||||
|
cacheRef.current = cache;
|
||||||
|
|
||||||
|
// Already loaded for this room — restore without re-fetching.
|
||||||
|
if (cache.items.length > 0 || cache.nextToken !== null || !cache.hasMore) {
|
||||||
|
setItems(cache.items);
|
||||||
|
setHasMore(cache.hasMore);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const bootstrap = async () => {
|
||||||
|
cache.seenIds = new Set();
|
||||||
|
cache.nextToken = null;
|
||||||
|
cache.hasMore = true;
|
||||||
|
cache.items = [];
|
||||||
|
setItems([]);
|
||||||
|
setHasMore(true);
|
||||||
|
setLoading(true);
|
||||||
|
loadingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localFound: RoomMediaItem[] = [];
|
||||||
|
const localEvents = room.getLiveTimeline().getEvents();
|
||||||
|
for (let i = localEvents.length - 1; i >= 0; i -= 1) {
|
||||||
|
const mEvent = await decryptEvent(localEvents[i]);
|
||||||
|
const item = toMediaItem(mEvent);
|
||||||
|
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||||
|
cache.seenIds.add(item.eventId);
|
||||||
|
localFound.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
if (localFound.length > 0) {
|
||||||
|
cache.items = localFound;
|
||||||
|
setItems(localFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collected = await fetchPages();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (collected.length > 0) {
|
||||||
|
cache.items = [...cache.items, ...collected];
|
||||||
|
setItems(cache.items);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// Only re-bootstrap when switching rooms — not when callback identities change.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
return { items, loading, hasMore, loadMore };
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { NavigateOptions, useNavigate } from 'react-router-dom';
|
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
||||||
import {
|
import {
|
||||||
@@ -7,21 +7,25 @@ import {
|
|||||||
getHomeRoomPath,
|
getHomeRoomPath,
|
||||||
getSpacePath,
|
getSpacePath,
|
||||||
getSpaceRoomPath,
|
getSpaceRoomPath,
|
||||||
|
withRoomEventId,
|
||||||
} from '../pages/pathUtils';
|
} from '../pages/pathUtils';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
||||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
|
import { useSelectedRoom } from './router/useSelectedRoom';
|
||||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
|
||||||
export const useRoomNavigate = () => {
|
export const useRoomNavigate = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const spaceSelectedId = useSelectedSpace();
|
const spaceSelectedId = useSelectedSpace();
|
||||||
|
const selectedRoomId = useSelectedRoom();
|
||||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
const navigateSpace = useCallback(
|
const navigateSpace = useCallback(
|
||||||
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
|
|||||||
|
|
||||||
const navigateRoom = useCallback(
|
const navigateRoom = useCallback(
|
||||||
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
||||||
|
// Already viewing this room — keep the current home/direct/space URL and only
|
||||||
|
// change the event id. Otherwise guessPerfectParent can yank the sidebar to
|
||||||
|
// wherever the room "belongs".
|
||||||
|
if (selectedRoomId === roomId) {
|
||||||
|
navigate(withRoomEventId(location.pathname, eventId), opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
||||||
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
||||||
@@ -44,6 +56,19 @@ export const useRoomNavigate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
|
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
|
||||||
|
|
||||||
|
// Prefer Direct for m.direct rooms unless we are already browsing a parent space
|
||||||
|
// of this room (keep space chrome). Opening via space while roomToParents is still
|
||||||
|
// catching up used to hit JoinBeforeNavigate even though the DM was listed.
|
||||||
|
if (mDirects.has(roomId)) {
|
||||||
|
const stayInSelectedSpace =
|
||||||
|
Boolean(spaceSelectedId) && orphanParents.includes(spaceSelectedId!);
|
||||||
|
if (!stayInSelectedSpace) {
|
||||||
|
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (orphanParents.length > 0) {
|
if (orphanParents.length > 0) {
|
||||||
let parentSpace: string;
|
let parentSpace: string;
|
||||||
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
|
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
|
||||||
@@ -61,14 +86,18 @@ export const useRoomNavigate = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mDirects.has(roomId)) {
|
|
||||||
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
},
|
},
|
||||||
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
|
[
|
||||||
|
mx,
|
||||||
|
navigate,
|
||||||
|
location.pathname,
|
||||||
|
selectedRoomId,
|
||||||
|
spaceSelectedId,
|
||||||
|
roomToParents,
|
||||||
|
mDirects,
|
||||||
|
developerTools,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
roomIdToUploadItemsAtomFamily,
|
||||||
|
TUploadItem,
|
||||||
|
} from '../state/room/roomInputDrafts';
|
||||||
|
import { encryptFile } from '../utils/matrix';
|
||||||
|
import { safeFile } from '../utils/mimeTypes';
|
||||||
|
import { fulfilledPromiseSettledResult } from '../utils/common';
|
||||||
|
import { useFilePicker } from './useFilePicker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues files into the room composer upload board (shared by RoomInput and header media).
|
||||||
|
*/
|
||||||
|
export const useRoomUploadFiles = (room: Room) => {
|
||||||
|
const setSelectedFiles = useSetAtom(roomIdToUploadItemsAtomFamily(room.roomId));
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
const safeFiles = files.map(safeFile);
|
||||||
|
const fileItems: TUploadItem[] = [];
|
||||||
|
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
const encryptFiles = fulfilledPromiseSettledResult(
|
||||||
|
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||||
|
);
|
||||||
|
encryptFiles.forEach((ef) =>
|
||||||
|
fileItems.push({
|
||||||
|
...ef,
|
||||||
|
metadata: {
|
||||||
|
markedAsSpoiler: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
safeFiles.forEach((f) =>
|
||||||
|
fileItems.push({
|
||||||
|
file: f,
|
||||||
|
originalFile: f,
|
||||||
|
encInfo: undefined,
|
||||||
|
metadata: {
|
||||||
|
markedAsSpoiler: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFiles({
|
||||||
|
type: 'PUT',
|
||||||
|
item: fileItems,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSelectedFiles, room]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickFiles = useFilePicker(handleFiles, true);
|
||||||
|
|
||||||
|
return { handleFiles, pickFiles };
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { lightTheme } from 'folds';
|
import { lightTheme } from 'folds';
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||||
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css';
|
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
||||||
@@ -17,6 +17,11 @@ export type Theme = {
|
|||||||
classNames: string[];
|
classNames: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isStationeryTheme = (theme: Theme | string): boolean => {
|
||||||
|
const id = typeof theme === 'string' ? theme : theme.id;
|
||||||
|
return id === 'stationery-theme' || id === 'stationery-dark-theme';
|
||||||
|
};
|
||||||
|
|
||||||
export const LightTheme: Theme = {
|
export const LightTheme: Theme = {
|
||||||
id: 'light-theme',
|
id: 'light-theme',
|
||||||
kind: ThemeKind.Light,
|
kind: ThemeKind.Light,
|
||||||
@@ -63,6 +68,16 @@ export const CatppuccinMochaTheme: Theme = {
|
|||||||
kind: ThemeKind.Dark,
|
kind: ThemeKind.Dark,
|
||||||
classNames: ['catppuccin-mocha-theme', catppuccinMochaTheme, onDarkFontWeight, 'prism-dark'],
|
classNames: ['catppuccin-mocha-theme', catppuccinMochaTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
};
|
};
|
||||||
|
export const StationeryTheme: Theme = {
|
||||||
|
id: 'stationery-theme',
|
||||||
|
kind: ThemeKind.Light,
|
||||||
|
classNames: ['stationery-theme', 'stationery', stationeryTheme, onLightFontWeight, 'prism-light'],
|
||||||
|
};
|
||||||
|
export const StationeryDarkTheme: Theme = {
|
||||||
|
id: 'stationery-dark-theme',
|
||||||
|
kind: ThemeKind.Dark,
|
||||||
|
classNames: ['stationery-dark-theme', 'stationery', stationeryDarkTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
|
};
|
||||||
|
|
||||||
export const useThemes = (): Theme[] => {
|
export const useThemes = (): Theme[] => {
|
||||||
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
|
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
|
||||||
@@ -76,7 +91,7 @@ export const useThemes = (): Theme[] => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const builtInThemes: Theme[] = useMemo(() => [
|
const builtInThemes: Theme[] = useMemo(() => [
|
||||||
LightTheme, SilverTheme, DarkTheme, ButterTheme,
|
LightTheme, SilverTheme, StationeryTheme, StationeryDarkTheme, DarkTheme, ButterTheme,
|
||||||
DiscordTheme, DiscordDarkerTheme, TwilightTheme,
|
DiscordTheme, DiscordDarkerTheme, TwilightTheme,
|
||||||
MochaTheme, CatppuccinMochaTheme
|
MochaTheme, CatppuccinMochaTheme
|
||||||
], []);
|
], []);
|
||||||
@@ -112,6 +127,8 @@ export const useThemeNames = (): Record<string, string> => {
|
|||||||
const builtInNames = {
|
const builtInNames = {
|
||||||
[LightTheme.id]: 'Light',
|
[LightTheme.id]: 'Light',
|
||||||
[SilverTheme.id]: 'Silver',
|
[SilverTheme.id]: 'Silver',
|
||||||
|
[StationeryTheme.id]: 'Stationery',
|
||||||
|
[StationeryDarkTheme.id]: 'Stationery Dark',
|
||||||
[DarkTheme.id]: 'Dark',
|
[DarkTheme.id]: 'Dark',
|
||||||
[ButterTheme.id]: 'Butter',
|
[ButterTheme.id]: 'Butter',
|
||||||
[DiscordTheme.id]: 'Discord',
|
[DiscordTheme.id]: 'Discord',
|
||||||
|
|||||||
@@ -24,23 +24,30 @@ const getUserPresence = (user: User): UserPresence => ({
|
|||||||
|
|
||||||
export const useUserPresence = (userId: string): UserPresence | undefined => {
|
export const useUserPresence = (userId: string): UserPresence | undefined => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const user = mx.getUser(userId);
|
const user = userId ? mx.getUser(userId) : null;
|
||||||
|
|
||||||
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
|
if (!user) {
|
||||||
if (u.userId === user?.userId) {
|
setPresence(undefined);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPresence(getUserPresence(user));
|
||||||
|
|
||||||
|
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => {
|
||||||
|
if (u.userId === user.userId) {
|
||||||
setPresence(getUserPresence(user));
|
setPresence(getUserPresence(user));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
user?.on(UserEvent.Presence, updatePresence);
|
user.on(UserEvent.Presence, updatePresence);
|
||||||
user?.on(UserEvent.CurrentlyActive, updatePresence);
|
user.on(UserEvent.CurrentlyActive, updatePresence);
|
||||||
user?.on(UserEvent.LastPresenceTs, updatePresence);
|
user.on(UserEvent.LastPresenceTs, updatePresence);
|
||||||
return () => {
|
return () => {
|
||||||
user?.removeListener(UserEvent.Presence, updatePresence);
|
user.removeListener(UserEvent.Presence, updatePresence);
|
||||||
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
user.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
||||||
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
user.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
||||||
};
|
};
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
|
||||||
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
|
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
|
||||||
|
import { useCompactNav } from '../hooks/useCompactNav';
|
||||||
|
|
||||||
type MobileFriendlyClientNavProps = {
|
type MobileFriendlyClientNavProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
|
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
|
||||||
const screenSize = useScreenSizeContext();
|
const compact = useCompactNav();
|
||||||
const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
|
const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
|
||||||
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
|
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true });
|
||||||
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
|
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
|
||||||
const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
|
const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
|
||||||
const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true });
|
const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true });
|
||||||
|
|
||||||
if (
|
if (compact && !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)) {
|
||||||
screenSize === ScreenSize.Mobile &&
|
|
||||||
!(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,14 +26,14 @@ type MobileFriendlyPageNavProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavProps) {
|
export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavProps) {
|
||||||
const screenSize = useScreenSizeContext();
|
const compact = useCompactNav();
|
||||||
const exactPath = useMatch({
|
const exactPath = useMatch({
|
||||||
path,
|
path,
|
||||||
caseSensitive: true,
|
caseSensitive: true,
|
||||||
end: true,
|
end: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (screenSize === ScreenSize.Mobile && !exactPath) {
|
if (compact && !exactPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
} from './pathUtils';
|
} from './pathUtils';
|
||||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||||
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
import { Direct, DirectCreate, DirectRouteRoomProvider, DirectSearch } from './client/direct';
|
||||||
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
|
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
|
||||||
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
||||||
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
|
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
|
||||||
@@ -223,6 +223,7 @@ 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={_SEARCH_PATH} element={<DirectSearch />} />
|
||||||
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
||||||
<Route path={_INVITES_PATH} element={<Invites />} />
|
<Route path={_INVITES_PATH} element={<Invites />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -378,6 +378,14 @@ function MessageNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
|
||||||
|
const notificationType = getNotificationType(mx, room.roomId);
|
||||||
|
const isDm = mDirects.has(room.roomId);
|
||||||
|
const mentionsOnly =
|
||||||
|
notificationType === NotificationType.MentionsAndKeywords ||
|
||||||
|
(notificationType === NotificationType.Default && !isDm);
|
||||||
|
if (mentionsOnly && unreadInfo.highlight === 0) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
showNotifications &&
|
showNotifications &&
|
||||||
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||||
@@ -399,7 +407,6 @@ function MessageNotifications() {
|
|||||||
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
|
|
||||||
notify({
|
notify({
|
||||||
roomName: room.name ?? 'Unknown',
|
roomName: room.name ?? 'Unknown',
|
||||||
roomAvatar: avatarMxc
|
roomAvatar: avatarMxc
|
||||||
@@ -430,6 +437,7 @@ function MessageNotifications() {
|
|||||||
notify,
|
notify,
|
||||||
selectedRoomId,
|
selectedRoomId,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
mDirects,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -29,7 +29,20 @@ export function SidebarNav() {
|
|||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
scrollable={
|
scrollable={
|
||||||
<Scroll ref={scrollRef} variant="Background" size="0">
|
<Scroll
|
||||||
|
ref={scrollRef}
|
||||||
|
variant="Background"
|
||||||
|
size="0"
|
||||||
|
data-sidebar-scroll=""
|
||||||
|
style={{
|
||||||
|
marginRight: -25,
|
||||||
|
paddingRight: 25,
|
||||||
|
width: 'calc(100% + 25px)',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
{!homeHidden && <HomeTab />}
|
{!homeHidden && <HomeTab />}
|
||||||
<DirectTab />
|
<DirectTab />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user