20 Commits

Author SHA1 Message Date
99a294791e Open DMs during sync catch-up instead of blocking on parent maps.
Prefer the direct route for m.direct rooms and allow joined space children before roomToParents finishes backfilling.
2026-07-24 16:52:34 +10:00
f62200ecd1 Fix Jump to Latest sticking visible while scrolled to the live bottom. 2026-07-24 16:00:59 +10:00
b52926f7d8 Fix system notifications ignoring mention-only and default room settings.
Skip non-highlight events for Mentions/Default non-DM rooms so public chats stay quiet unless they should ping.
2026-07-24 00:30:25 +10:00
d338e1c35e Show update progress as a title-bar bar instead of a popout. 2026-07-23 15:18:24 +10:00
9589680a81 Use paarrot://desktop SSO redirect so consent identifies the app. 2026-07-23 15:13:53 +10:00
27f1357fc0 Remove editor beforeinput autocorrect workaround. 2026-07-23 14:57:57 +10:00
9509a9705e Add Shared Media drawer and harden same-room navigation.
Widen the media panel, support skinny full-page mode, keep jump-to-latest and return-to-previous reliable, and stop same-room event jumps from remounting the outlet or yanking the sidebar.
2026-07-23 14:52:01 +10:00
61d41900cc Add Stationery and Stationery Dark themes with notebook chrome.
Manila folders, ruled chat, post-it nav, and folder tabs; dark variant uses Catppuccin Mocha colors with muted stickies and soft glows.
2026-07-23 00:24:10 +10:00
e7777f42d8 Fix jumbo emoji detection broken by production minifier.
Replace surrogate-pair emoji regex with Unicode property escapes so Rolldown no longer corrupts matching in production builds.
2026-07-22 20:28:59 +10:00
ae70eae0fc Fix jumbo emoji sizing in production builds.
Set jumbo font-size on the message body and inherit Text size so T400 no longer keeps emoji-only messages small.
2026-07-22 20:14:09 +10:00
c286501be8 Improve timeline scroll, media layout, avatars, and emoji coverage.
Remember room scroll position across switches, reserve image/video heights from Matrix dimensions, cache authenticated media blobs for avatars, restore jumbo emoji-only messages, and extend emoji font unicode-range for Unicode 15 glyphs.
2026-07-22 20:00:14 +10:00
154f4dfdb0 fix: stop emoji fonts claiming spaces and Latin text
Use Fontsource's real family name ('Inter Variable') so text no longer
falls through to Noto Color Emoji / Twemoji. Scope emoji @font-face to
emoji unicode-range so space metrics stay normal on Linux and elsewhere.
2026-07-15 03:19:54 +10:00
c7067021ba Fix white timestamps when Twemoji is selected
InterVariable did not match Fontsource's Inter Variable name, so digits
fell through to Twemoji COLR keycaps. Scope Twemoji off ASCII digits.
2026-07-13 02:40:03 +10:00
687f139719 Fix Slate crash after inserting emoji from picker/autocomplete 2026-07-13 02:40:02 +10:00
b711646b58 Fix mic processing toggles after LiveKit voiceIsolation default 2026-07-13 02:25:04 +10:00
bd6d753f47 update 2026-07-12 18:15:03 +10:00
3de3058ca9 Allow git deps in npm for Node/npm compatibility. 2026-07-12 08:18:57 +10:00
02d96a9758 hidden shit, and online status bars, search settings 2026-07-12 07:49:17 +10:00
5374c10c61 room shit 2026-07-10 21:35:38 +10:00
9c7f71896c fix: rework compact nav and centralized back routing
Use master-detail layout on skinny windows so server and channel lists share one screen, with back returning to the space list instead of redirect loops. Add useBackRoute for title-bar and page-header back buttons with view transitions.
2026-07-09 04:22:07 +10:00
127 changed files with 7500 additions and 1564 deletions

3
.npmrc
View File

@@ -1,2 +1,3 @@
legacy-peer-deps=true legacy-peer-deps=true
save-exact=true save-exact=true
allow-git=all

View File

@@ -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
View File

@@ -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",

View File

@@ -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
} }
} }

View File

@@ -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,

View File

@@ -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);
} }

View File

@@ -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: '',

View File

@@ -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),
}, },
]); ]);

View File

@@ -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}

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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;

View File

@@ -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}
/> />

View File

@@ -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,
}; };

View File

@@ -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: {

View File

@@ -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({

View File

@@ -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,
{ {

View File

@@ -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;

View File

@@ -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) => (

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
});

View File

@@ -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}
/> />

View File

@@ -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',
});

View File

@@ -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}
/>
)); ));

View File

@@ -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}
> >

View File

@@ -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}
/> />

View File

@@ -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>
); );
} }

View File

@@ -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,
},
});

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export * from './RemoveInaccessiblePrompt';

View File

@@ -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>
); );
} }

View 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`,
});

View File

@@ -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 && (

View 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'}`;
}

View File

@@ -1 +1,2 @@
export * from './SettingTile'; export * from './SettingTile';
export * from './SettingsFocus';

View File

@@ -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}
/>
)); ));

View File

@@ -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>
</> </>

View File

@@ -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}
/> />

View File

@@ -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%',

View File

@@ -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>

View File

@@ -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)',
},
},
}); });

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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}

View File

@@ -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();

View File

@@ -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({

View File

@@ -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>
)} )}
</> </>

View File

@@ -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 && (

View File

@@ -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;

View File

@@ -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');

View File

@@ -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]
); );

View File

@@ -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}

View File

@@ -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}

View File

@@ -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"
> >

View File

@@ -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>

View 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',
});

View File

@@ -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>
</> </>
} }

View File

@@ -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,
}); });

View File

@@ -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',
}, },
], ],

View File

@@ -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 && (

View File

@@ -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>
</> </>
)} )}

View File

@@ -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: {

View File

@@ -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>
</> </>
); );

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>
); );
} }
); );

View File

@@ -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>
</> </>
); );

View File

@@ -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

View File

@@ -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>
)} )}

View File

@@ -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}
> >

View File

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

View 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',
});

View 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>
);
});

View File

@@ -0,0 +1 @@
export * from './RoomMediaMenu';

View File

@@ -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;
}, },

View File

@@ -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>
); );
} }

View File

@@ -0,0 +1,11 @@
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
AudioPage,
DevicesPage,
EmojisStickersPage,
PluginsPage,
DeveloperToolsPage,
AboutPage,
}

View File

@@ -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>

View File

@@ -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 (

View 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'],
},
];

View File

@@ -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,
},
},
});

View File

@@ -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) => (
<> <>

View File

@@ -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;
};

View File

@@ -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;
} }
}, },

View 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),
};
};

View File

@@ -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);

View 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;
};

View File

@@ -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);
}

View File

@@ -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 ?? '')}`;

View 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 };
};

View File

@@ -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 {

View 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 };
};

View File

@@ -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',

View File

@@ -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]);

View File

@@ -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;
} }

View File

@@ -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

View File

@@ -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;

View File

@@ -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