19 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
119 changed files with 7254 additions and 1449 deletions

3
.npmrc
View File

@@ -1,2 +1,3 @@
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
- Pusher registration races on fast login/logout
- 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
@@ -91,3 +92,4 @@ tauri.ts / electron main
- 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/`
- 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-hitbox": "2.0.0",
"@fontsource-variable/inter": "5.2.8",
"@fontsource/caveat": "5.3.0",
"@fontsource/inter": "5.2.8",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.101.2",
@@ -2368,6 +2369,15 @@
"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": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",

View File

@@ -8,7 +8,7 @@
"node": ">=16.0.0"
},
"scripts": {
"start": "vite --open false",
"start": "vite",
"build": "vite build",
"lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*",
@@ -24,6 +24,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
"@fontsource-variable/inter": "5.2.8",
"@fontsource/caveat": "5.3.0",
"@fontsource/inter": "5.2.8",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.101.2",
@@ -122,5 +123,9 @@
"vite-plugin-pwa": "1.3.0",
"vite-plugin-static-copy": "4.1.1",
"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';
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
* Forces remount on route change by using location as key
* Room routes are `:roomIdOrAlias/:eventId?/`. Jumping to an event (or clearing it)
* 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() {
const location = useLocation();
const transitionKey = useMemo(
() => getOutletTransitionKey(location.pathname),
[location.pathname]
);
return (
<div
key={location.pathname}
key={transitionKey}
data-route-transition="true"
style={{
flex: 1,

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 = () => ({
type: StateEvent.RoomPowerLevels,
state_key: '',

View File

@@ -16,6 +16,8 @@ export const EditorOptions = style([
DefaultReset,
{
padding: config.space.S200,
display: 'flex',
alignItems: 'center',
},
]);
@@ -26,7 +28,7 @@ export const EditorTextarea = style([
{
flexGrow: 1,
height: '100%',
padding: `${toRem(13)} ${toRem(1)}`,
padding: `${toRem(10)} ${toRem(1)}`,
selectors: {
[`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13),
@@ -54,7 +56,7 @@ export const EditorPlaceholderTextVisual = style([
DefaultReset,
{
display: 'block',
paddingTop: toRem(13),
paddingTop: toRem(10),
paddingLeft: toRem(1),
},
]);

View File

@@ -6,11 +6,9 @@ import React, {
forwardRef,
useCallback,
useState,
useEffect,
useRef,
} from 'react';
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 {
Slate,
Editable,
@@ -172,48 +170,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[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(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
@@ -243,7 +199,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box
alignItems="Start"
alignItems={fillHeight ? 'Start' : 'Center'}
grow={fillHeight ? 'Yes' : 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}
variant="SurfaceVariant"
style={scrollStyle}
size="300"
size="0"
visibility="Hover"
hideTrack
ref={editableRef}
>
<Editable
data-editable-name={editableName}

View File

@@ -82,13 +82,21 @@ function RenderEmoticonElement({
const selected = useSelected();
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 (
<span className={css.EmoticonBase} {...attributes}>
<span
{...attributes}
contentEditable={false}
className={css.EmoticonBase}
style={{ userSelect: 'none' }}
>
<span
className={css.Emoticon({
focus: selected && focused,
})}
contentEditable={false}
>
{element.key.startsWith('mxc://') ? (
<img
@@ -99,8 +107,8 @@ function RenderEmoticonElement({
) : (
element.key
)}
{children}
</span>
{children}
</span>
);
}

View File

@@ -64,7 +64,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
radii="300"
disabled={disableInline}
>
<Icon size="200" src={icon} />
<Icon size="100" src={icon} />
</IconButton>
)}
</TooltipProvider>
@@ -95,7 +95,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
size="400"
radii="300"
>
<Icon size="200" src={icon} />
<Icon size="100" src={icon} />
</IconButton>
)}
</TooltipProvider>
@@ -152,7 +152,7 @@ export function HeadingBlockButton() {
size="400"
radii="300"
>
<Icon size="200" src={Icons.Heading1} />
<Icon size="100" src={Icons.Heading1} />
</IconButton>
)}
</TooltipProvider>
@@ -167,7 +167,7 @@ export function HeadingBlockButton() {
size="400"
radii="300"
>
<Icon size="200" src={Icons.Heading2} />
<Icon size="100" src={Icons.Heading2} />
</IconButton>
)}
</TooltipProvider>
@@ -182,7 +182,7 @@ export function HeadingBlockButton() {
size="400"
radii="300"
>
<Icon size="200" src={Icons.Heading3} />
<Icon size="100" src={Icons.Heading3} />
</IconButton>
)}
</TooltipProvider>
@@ -199,8 +199,8 @@ export function HeadingBlockButton() {
size="400"
radii="300"
>
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
<Icon size="100" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
<Icon size="100" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
</IconButton>
</PopOut>
);
@@ -336,7 +336,7 @@ export function Toolbar() {
radii="300"
disabled={disableInline || !!isAnyMarkActive(editor)}
>
<Icon size="200" src={Icons.Markdown} filled={isMarkdown} />
<Icon size="100" src={Icons.Markdown} filled={isMarkdown} />
</IconButton>
)}
</TooltipProvider>

View File

@@ -1,4 +1,5 @@
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { BlockType, MarkType } from './types';
import {
CommandElement,
@@ -206,6 +207,29 @@ export const moveCursor = (editor: Editor, withSpace?: boolean) => {
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 {
match: (char: string) => boolean;
reverse?: boolean;

View File

@@ -29,8 +29,10 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
? { width: pixelSize, height: pixelSize, ...style }
: style
}
strokeWidth={2}
fill={fill ?? (filled ? 'currentColor' : 'none')}
// Lucide icons are stroke-based; solid fill turns faces (e.g. Smile) into blobs.
// 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}
{...props}
/>

View File

@@ -1,12 +1,13 @@
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> = {
'50': 10,
'100': 12,
'200': 16,
'300': 20,
'400': 24,
'500': 28,
'600': 32,
'100': 13,
'200': 17,
'300': 21,
'400': 25,
'500': 29,
'600': 34,
Inherit: undefined,
};

View File

@@ -3,9 +3,8 @@ import { config } from 'folds';
export const Icon = recipe({
base: {
display: 'inline-block',
display: 'block',
flexShrink: 0,
verticalAlign: 'middle',
},
variants: {
size: {

View File

@@ -26,8 +26,10 @@ import {
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
@@ -199,6 +201,20 @@ type MImageProps = {
renderImageContent: (props: RenderImageContentProps) => ReactNode;
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) {
const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
@@ -206,9 +222,24 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
return <BrokenContent />;
}
const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h);
const gridPad = snapStationeryGridPad(height);
return (
<Attachment outlined={outlined} transparent>
<AttachmentBox>
<StationeryMedia
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({
body: content.body || 'Image',
info: imgInfo,
@@ -219,7 +250,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>
</StationeryMedia>
);
}
@@ -251,9 +282,20 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
}
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 (
<Attachment outlined={outlined} transparent>
<StationeryMedia
outlined={outlined}
transparent
tiltSeed={mxcUrl}
gridPad={gridPad}
>
<AttachmentHeader>
<FileHeader
body={filename}
@@ -268,7 +310,13 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
}
/>
</AttachmentHeader>
<AttachmentBox>
<AttachmentBox
style={{
width: toRem(width),
height: toRem(height),
}}
data-paarrot-media-height={height}
>
{renderVideoContent({
body: content.body || 'Video',
info: videoInfo,
@@ -279,7 +327,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>
</StationeryMedia>
);
}
@@ -418,12 +466,16 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
return <MessageBrokenContent />;
}
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
const tapeAlt = hashStationerySeed(mxcUrl) % 2 === 1;
return (
<AttachmentBox
data-stationery-media=""
data-tape-alt={tapeAlt ? '' : undefined}
style={{
height: toRem(height < 48 ? 48 : height),
width: toRem(152),
['--media-rot' as string]: stationeryMediaRot(mxcUrl),
}}
>
{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([
DefaultReset,
{

View File

@@ -6,6 +6,9 @@ import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
const MAX_STICKER_STACK = 4;
export const Reaction = as<
'button',
@@ -15,35 +18,101 @@ export const Reaction = as<
reaction: string;
useAuthentication?: boolean;
}
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{reaction.startsWith('mxc://') ? (
<img
className={css.ReactionImg}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
}
alt={reaction}
/>
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
const theme = useTheme();
const stationery = isStationeryTheme(theme);
const isCustomEmoji = reaction.startsWith('mxc://');
const customSrc = isCustomEmoji
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
: undefined;
const emoji = isCustomEmoji ? (
<img className={css.ReactionImg} src={customSrc} alt={reaction} />
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
);
// Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
if (!stationery) {
return (
<Box
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 as="span" size="T300">
{count}
</Text>
</Box>
));
</Box>
);
});
type ReactionTooltipMsgProps = {
room: Room;

View File

@@ -14,29 +14,54 @@ import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
import { STATIONERY_NAME_INK } from '../../utils/paperSafeInk';
type ReplyLayoutProps = {
userColor?: string;
username?: ReactNode;
};
export const ReplyLayout = as<'div', ReplyLayoutProps>(
({ username, userColor, className, children, ...props }, ref) => (
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
<Icon size="100" src={Icons.ReplyArrow} />
{username}
({ username, userColor, className, children, ...props }, ref) => {
const theme = useTheme();
const isStationery = isStationeryTheme(theme);
return (
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
gap="100"
{...props}
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 grow="Yes" className={css.ReplyContent}>
{children}
</Box>
</Box>
)
);
}
);
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 { Icon, Icons } from '../../icons';
import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash';
import { Blurhash } from 'react-blurhash';
import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
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 { validBlurHash } from '../../../utils/blurHash';
import { getCurrentAccessToken } from '../../../utils/auth';
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
/**
* Fetches media with authentication headers and returns a blob URL.
@@ -68,7 +69,7 @@ type RenderImageProps = {
alt: string;
title: string;
src: string;
onLoad: () => void;
onLoad: (event?: React.SyntheticEvent<HTMLImageElement>) => void;
onError: () => void;
onClick: () => void;
tabIndex: number;
@@ -105,12 +106,23 @@ export const ImageContent = as<'div', ImageContentProps>(
) => {
const mx = useMatrixClient();
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 [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [showPlaceholder, setShowPlaceholder] = useState(true);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
@@ -139,6 +151,7 @@ export const ImageContent = as<'div', ImageContentProps>(
const handleError = () => {
setLoad(false);
setError(true);
setShowPlaceholder(true);
};
const handleRetry = () => {
@@ -150,8 +163,34 @@ export const ImageContent = as<'div', ImageContentProps>(
if (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 (
<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 && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -177,15 +216,6 @@ export const ImageContent = as<'div', ImageContentProps>(
</OverlayCenter>
</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 && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button
@@ -201,12 +231,26 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box>
)}
{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({
alt: body,
title: body,
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,
onClick: () => setViewer(true),
tabIndex: 0,
@@ -248,7 +292,8 @@ export const ImageContent = as<'div', ImageContentProps>(
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load &&
!blurred && (
!blurred &&
typeof blurHash !== 'string' && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" />
</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 { Icon, Icons } from '../../icons';
import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash';
import { Blurhash } from 'react-blurhash';
import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import {
@@ -25,6 +25,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
import { ModalWide } from '../../../styles/Modal.css';
import { stopPropagation } from '../../../utils/keyboard';
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
type RenderViewerProps = {
src: string;
@@ -34,7 +35,7 @@ type RenderViewerProps = {
type RenderVideoProps = {
title: string;
src: string;
onLoadedMetadata: () => void;
onLoadedMetadata: (event?: React.SyntheticEvent<HTMLVideoElement>) => void;
onError: () => void;
autoPlay: boolean;
controls: boolean;
@@ -74,12 +75,23 @@ export const VideoContent = as<'div', VideoContentProps>(
) => {
const mx = useMatrixClient();
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 [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [showPlaceholder, setShowPlaceholder] = useState(true);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
@@ -95,12 +107,17 @@ export const VideoContent = as<'div', VideoContentProps>(
}, [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);
};
const handleError = () => {
setLoad(false);
setError(true);
setShowPlaceholder(true);
};
const handleRetry = () => {
@@ -112,8 +129,33 @@ export const VideoContent = as<'div', VideoContentProps>(
if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]);
useEffect(() => {
if (!load) {
setShowPlaceholder(true);
return undefined;
}
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
return () => window.clearTimeout(timer);
}, [load]);
return (
<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 && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -136,15 +178,6 @@ export const VideoContent = as<'div', VideoContentProps>(
</OverlayCenter>
</Overlay>
)}
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
width={32}
height={32}
hash={blurHash}
punch={1}
/>
)}
{renderThumbnail && !load && (
<Box
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
@@ -169,11 +202,20 @@ export const VideoContent = as<'div', VideoContentProps>(
</Box>
)}
{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({
title: body,
src: srcState.data,
onLoadedMetadata: handleLoad,
onLoadedMetadata: (e?: React.SyntheticEvent<HTMLVideoElement>) => {
handleLoad(e?.currentTarget);
},
onError: handleError,
autoPlay: true,
controls: true,
@@ -213,7 +255,8 @@ export const VideoContent = as<'div', VideoContentProps>(
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load &&
!blurred && (
!blurred &&
typeof blurHash !== 'string' && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" />
</Box>

View File

@@ -1,11 +1,13 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
export const RelativeBase = style([
DefaultReset,
{
position: 'relative',
display: 'flex',
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: '7px',
@@ -16,9 +18,13 @@ export const RelativeBase = style([
export const AbsoluteContainer = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 'inherit',
@@ -34,6 +40,7 @@ export const AbsoluteFooter = style([
bottom: config.space.S100,
left: config.space.S100,
right: config.space.S100,
zIndex: 1,
},
]);
@@ -43,3 +50,67 @@ export const Blur = style([
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 classNames from 'classnames';
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>(
({ 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 }),
className
)}
data-message-collapsed={collapse ? '' : undefined}
{...props}
ref={ref}
/>
@@ -17,12 +20,45 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
);
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) => (
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
));
export const Username = as<'span'>(({ as: AsUsername = 'span', className, style, ...props }, 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) => (
<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) => (
<Text
as={asComp}
size="T400"
size={jumboEmoji ? 'Inherit' : 'T400'}
priority={notice ? '300' : '400'}
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
data-allow-text-selection="true"
data-message-body=""
data-jumbo-emoji={jumboEmoji ? '' : undefined}
{...props}
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 { DefaultReset, color, config, toRem } from 'folds';
@@ -117,6 +118,7 @@ export const MessageBase = recipe({
collapse: {
true: {
marginTop: 0,
paddingTop: config.space.S0,
},
},
autoCollapse: {
@@ -202,6 +204,14 @@ export const Username = style({
'button&:hover, button&:focus-visible': {
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,
});
const jumboEmojiSize = toRem(70);
export const MessageTextBody = recipe({
base: {
wordBreak: 'break-word',
@@ -226,8 +238,11 @@ export const MessageTextBody = recipe({
},
jumboEmoji: {
true: {
fontSize: '1.504em',
lineHeight: '1.4962em',
fontSize: jumboEmojiSize,
lineHeight: 1,
overflow: 'visible',
overflowY: 'visible',
paddingBottom: config.space.S200,
},
},
emote: {
@@ -240,3 +255,29 @@ export const MessageTextBody = recipe({
});
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;
};
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
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
data-nav-item=""
{...props}
ref={ref}
>

View File

@@ -6,6 +6,7 @@ import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
type PageRootProps = {
nav: ReactNode;
@@ -43,6 +44,7 @@ export function PageNav({
grow={isMobile ? 'Yes' : undefined}
className={classNames(css.PageNav({ size }), className)}
shrink={isMobile ? 'Yes' : 'No'}
data-page-nav=""
>
<Box grow="Yes" direction="Column">
{children}
@@ -58,6 +60,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
className={classNames(css.PageNavHeader({ outlined }), className)}
variant="Background"
size="600"
data-folder-tab="nav"
{...props}
ref={ref}
/>
@@ -73,13 +76,16 @@ export function PageNavContent({
scrollRef?: MutableRefObject<HTMLDivElement | null>;
scrollProps?: React.ComponentProps<typeof Scroll>;
}) {
const theme = useTheme();
const hideScrollbar = isStationeryTheme(theme);
return (
<Box grow="Yes" direction="Column">
<Scroll
ref={scrollRef}
variant="Background"
direction="Vertical"
size="300"
size={hideScrollbar ? '0' : '300'}
hideTrack
visibility="Hover"
{...scrollProps}
@@ -95,6 +101,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
grow="Yes"
direction="Column"
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
data-page=""
{...props}
ref={ref}
/>
@@ -106,6 +113,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
as="header"
size="600"
className={classNames(css.PageHeader({ balance, outlined }), className)}
data-folder-tab-bar=""
{...props}
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 * as css from './styles.css';
import { config } from 'folds';
import { Presence } from '../../hooks/useUserPresence';
type PresenceAvatarProps = {
/** The presence state to display */
presence?: Presence;
/** The avatar element to wrap */
children: ReactNode;
/** Additional className */
children: ReactElement;
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.
* Shows green for online, yellow/orange for unavailable/away, grey for offline.
* Presence strip as a real sibling (not ::before).
* Folds Avatar uses overflow:hidden, which clips outside ::before bars.
*/
export function PresenceAvatar({ presence, children, className }: PresenceAvatarProps) {
if (!presence) {
return <>{children}</>;
}
export function PresenceAvatar({
presence = Presence.Offline,
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 (
<div
className={classNames(css.PresenceAvatarContainer, css.PresenceIndicator, className, {
[css.PresenceOnline]: presence === Presence.Online,
[css.PresenceUnavailable]: presence === Presence.Unavailable,
[css.PresenceOffline]: presence === Presence.Offline,
})}
>
{children}
<div className={className} style={rowStyle} data-presence={presence}>
<span
aria-hidden
style={{
display: 'block',
width: 4,
minWidth: 4,
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>
);
}

View File

@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css';
import { config, color } from 'folds';
import { config } from 'folds';
export const AvatarPresence = style({
display: 'flex',
@@ -20,41 +20,3 @@ export const AvatarPresenceBadge = style({
borderRadius: config.radii.Pill,
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 { AvatarFallback, AvatarImage, color } from 'folds';
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 { joinRuleToIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
import {
getCachedAuthenticatedMediaUrl,
isAuthenticatedMediaUrl,
} from '../../utils/authenticatedMediaCache';
import {
getMediaBlurHash,
getMediaDimensions,
rememberMediaBlurHash,
} from '../../state/mediaDimensionCache';
type RoomAvatarProps = {
roomId: string;
@@ -17,19 +27,36 @@ type RoomAvatarProps = {
};
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
const [error, setError] = useState(false);
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
const useAuthentication = useMediaAuthentication();
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) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
setLoaded(true);
cacheAvatar(src);
pruneAvatarCache();
rememberMediaBlurHash(src ?? '', evt.currentTarget);
};
// No src or error - show fallback only
if (!authenticatedSrc || error) {
if (!src || error) {
return (
<AvatarFallback
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 (loaded) {
if (authenticatedSrc && loaded) {
return (
<AvatarImage
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 (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<AvatarFallback
style={{
backgroundColor: colorMXID(roomId ?? ''),
style={{
backgroundColor: colorMXID(roomId ?? ''),
color: color.Surface.Container,
position: 'absolute',
inset: 0,
@@ -69,15 +100,39 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
>
{renderFallback()}
</AvatarFallback>
<AvatarImage
className={css.RoomAvatar}
style={{ position: 'relative', zIndex: 1 }}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
{blurHash && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
borderRadius: 'inherit',
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>
);
}

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 classNames from 'classnames';
import { BreakWord } from '../../styles/Text.css';
import { settingAnchorId, useSettingsFocus } from './SettingsFocus';
import { SettingTileFocus } from './SettingTile.css';
type SettingTileProps = {
title?: ReactNode;
@@ -8,10 +11,65 @@ type SettingTileProps = {
before?: ReactNode;
after?: 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 (
<Box alignItems="Center" gap="300">
<Box
ref={ref}
id={anchorId}
alignItems="Center"
gap="300"
className={classNames(highlighted && SettingTileFocus)}
>
{before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100">
{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 './SettingsFocus';

View File

@@ -4,5 +4,10 @@ import React from 'react';
import * as css from './Sidebar.css';
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) {
return (
<>
<Box direction="Column" grow="Yes">
<Box direction="Column" grow="Yes" data-sidebar-scroll-region="" style={{ overflow: 'visible', minWidth: 0 }}>
{scrollable}
</Box>
<Box direction="Column" shrink="No">
<Box direction="Column" shrink="No" data-sidebar-sticky="" style={{ overflow: 'visible' }}>
{sticky}
</Box>
</>

View File

@@ -7,6 +7,7 @@ export const SidebarItem = as<'div', css.SidebarItemVariants>(
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
<AsSidebarAvatarBox
className={classNames(css.SidebarItem({ active }), className)}
data-sidebar-item=""
{...props}
ref={ref}
/>
@@ -54,6 +55,7 @@ export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProp
<Avatar
className={classNames(css.SidebarAvatar({ size, outlined }), className)}
radii={radii}
data-sidebar-avatar=""
{...props}
ref={ref}
/>
@@ -64,6 +66,7 @@ export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
<AsSidebarFolder
className={classNames(css.SidebarFolder({ state }), className)}
data-sidebar-folder=""
{...props}
ref={ref}
/>

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';
export const CheckButtonContainer = style({
const indeterminate = keyframes({
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(250%)' },
});
export const IdleSlot = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '32px',
width: '32px',
height: '100%',
WebkitAppRegion: 'no-drag',
flexShrink: 0,
});
export const CheckButton = style({
export const GhostCheck = style({
all: 'unset',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
width: toRem(28),
height: toRem(28),
borderRadius: config.radii.R300,
cursor: 'pointer',
color: color.Secondary.Main,
backgroundColor: 'transparent',
transition: 'opacity 0.2s, background-color 0.15s',
height: '32px',
width: '32px',
color: color.Surface.OnContainer,
opacity: 0,
WebkitAppRegion: 'no-drag',
flexShrink: 0,
cursor: 'pointer',
transition: 'opacity 0.15s ease, background-color 0.15s ease',
selectors: {
'&[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({
all: 'unset',
export const Bar = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
gap: config.space.S200,
height: toRem(22),
minWidth: toRem(120),
maxWidth: toRem(200),
padding: `0 ${config.space.S200}`,
borderRadius: config.radii.R300,
cursor: 'pointer',
color: color.Success.Main,
backgroundColor: 'transparent',
transition: 'background-color 0.15s',
height: '32px',
width: '32px',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
WebkitAppRegion: 'no-drag',
flexShrink: 0,
boxSizing: 'border-box',
});
':hover': {
backgroundColor: color.Surface.ContainerHover,
},
export const BarTrack = style({
position: 'relative',
flex: 1,
height: toRem(4),
borderRadius: toRem(2),
overflow: 'hidden',
backgroundColor: color.Surface.ContainerLine,
minWidth: toRem(64),
});
':active': {
backgroundColor: color.Surface.ContainerActive,
},
export const BarFill = style({
position: 'absolute',
inset: '0 auto 0 0',
height: '100%',
borderRadius: 'inherit',
backgroundColor: color.Success.Main,
transition: 'width 120ms linear',
});
':focus-visible': {
outline: `2px solid ${color.Success.Main}`,
outlineOffset: '2px',
export const BarIndeterminate = style({
position: 'absolute',
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({
minWidth: toRem(280),
maxWidth: toRem(320),
backgroundColor: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: config.shadow.E400,
border: `1px solid ${color.Surface.ContainerLine}`,
});
export const ProgressText = style({
color: color.Success.Main,
fontWeight: 500,
minWidth: toRem(35),
textAlign: 'center',
export const ChipDismiss = style({
all: 'unset',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(18),
height: toRem(18),
borderRadius: config.radii.R300,
cursor: 'pointer',
opacity: 0.7,
fontSize: toRem(14),
lineHeight: 1,
selectors: {
'&:hover': {
opacity: 1,
backgroundColor: 'rgba(0,0,0,0.08)',
},
},
});

View File

@@ -1,138 +1,136 @@
import React, { useState, useEffect } from 'react';
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'folds';
import * as css from './UpdateNotification.css';
interface UpdateInfo {
version: string;
releaseNotes?: string;
releaseDate?: string;
mock?: boolean;
}
interface DownloadProgress {
percent: number;
transferred: number;
total: number;
mock?: boolean;
}
type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'ready';
export function UpdateNotification() {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [phase, setPhase] = useState<UpdaterPhase>('idle');
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [updateReady, setUpdateReady] = useState(false);
const [checking, setChecking] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
const [isMock, setIsMock] = useState(false);
const [hovered, setHovered] = useState(false);
useEffect(() => {
// Check if we're in Electron environment
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
return;
const electron = (window as any).electron;
if (!electron?.updater) return undefined;
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;
// 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
return undefined;
}, []);
const handleCheckForUpdates = async () => {
setChecking(true);
const handleCheck = useCallback(async () => {
setPhase('checking');
try {
const result = await (window as any).electron.updater.checkForUpdates();
console.log('Update check result:', result);
// Handle error response (including dev mode error)
if (!result.success) {
console.warn('Update check failed:', result.error);
setChecking(false);
return;
if (!result?.success) {
setPhase('idle');
}
// If no update found, show feedback briefly
setTimeout(() => {
if (!updateAvailable) {
setChecking(false);
}
}, 2000);
} catch (error) {
console.error('Failed to check for updates:', error);
setChecking(false);
// available event will advance phase; if nothing comes, fall back
window.setTimeout(() => {
setPhase((current) => (current === 'checking' ? 'idle' : current));
}, 4000);
} catch {
setPhase('idle');
}
};
}, []);
const handleDownload = async () => {
setDownloading(true);
const handleDownload = useCallback(async () => {
setPhase('downloading');
setDownloadProgress(0);
try {
await (window as any).electron.updater.downloadUpdate();
} catch (error) {
console.error('Failed to download update:', error);
setDownloading(false);
} catch {
setPhase('available');
}
setMenuAnchor(undefined);
};
}, []);
const handleInstall = async () => {
const handleInstall = useCallback(async () => {
try {
await (window as any).electron.updater.installUpdate();
} catch (error) {
console.error('Failed to install update:', error);
if (isMock) {
setPhase('idle');
setUpdateInfo(null);
setDownloadProgress(0);
}
} catch {
// keep ready state
}
};
}, [isMock]);
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
if (menuAnchor) {
setMenuAnchor(undefined);
} else {
const rect = event.currentTarget.getBoundingClientRect();
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
}
};
const handleDismiss = useCallback(() => {
setPhase('idle');
setUpdateInfo(null);
setDownloadProgress(0);
}, []);
// Don't render anything if not in Electron
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
return null;
}
// Show check button if no update status
if (!updateAvailable && !updateReady && !checking) {
if (phase === 'idle') {
return (
<div
className={css.CheckButtonContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={css.IdleSlot}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<button
className={css.CheckButton}
data-visible={isHovered}
onClick={handleCheckForUpdates}
aria-label="Check for updates"
title="Check for updates"
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
d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor"
@@ -140,109 +138,75 @@ export function UpdateNotification() {
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
);
}
// Show checking state
if (checking) {
if (phase === 'checking') {
return (
<button className={css.UpdateButton} disabled type="button">
<Spinner variant="Secondary" size="50" />
</button>
<div className={css.Bar} title="Checking for updates…">
<div className={css.BarTrack}>
<div className={css.BarIndeterminate} />
</div>
<Text className={css.BarLabel} size="L400">
Checking
</Text>
</div>
);
}
// Show update available/ready state
if (!updateAvailable && !updateReady) {
return null;
if (phase === 'downloading') {
return (
<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 (
<>
<button
className={css.UpdateButton}
onClick={handleMenuToggle}
aria-label={updateReady ? 'Update ready' : 'Update available'}
title={updateReady ? 'Update downloaded - click to install' : 'New version available'}
type="button"
>
{downloading ? (
<Spinner variant="Secondary" size="50" />
) : (
<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>
if (phase === 'ready') {
return (
<Box className={css.Chip} alignItems="Center" gap="100">
<Text className={css.ChipText} size="L400" truncate>
{isMock ? 'Mock ready' : 'Update ready'}
{updateInfo ? ` · ${updateInfo.version}` : ''}
</Text>
<button type="button" className={css.ChipAction} onClick={handleInstall}>
Restart
</button>
</Box>
);
}
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={8}
content={
<Menu className={css.UpdateMenu}>
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H5" priority="400">
{updateReady ? 'Update Ready' : 'Update Available'}
</Text>
{updateInfo && (
<Text size="T200" priority="300">
Version {updateInfo.version}
</Text>
)}
</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>
}
// available
return (
<Box className={css.Chip} alignItems="Center" gap="100">
<Text className={css.ChipText} size="L400" truncate>
{isMock ? 'Mock update' : 'Update'}
{updateInfo ? ` ${updateInfo.version}` : ''}
</Text>
<button type="button" className={css.ChipAction} onClick={handleDownload}>
Download
</button>
<button
type="button"
className={css.ChipDismiss}
onClick={handleDismiss}
aria-label="Dismiss update"
title="Dismiss"
>
{null}
</PopOut>
</>
×
</button>
</Box>
);
}

View File

@@ -1,18 +1,21 @@
import { AvatarFallback, AvatarImage, color } from 'folds';
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
import * as css from './UserAvatar.css';
import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
// Check if image is already in browser cache
function isImageInBrowserCache(url: string): boolean {
const img = new Image();
img.src = url;
return img.complete && img.naturalWidth > 0;
}
import {
getCachedAuthenticatedMediaUrl,
isAuthenticatedMediaUrl,
} from '../../utils/authenticatedMediaCache';
import {
getMediaBlurHash,
getMediaDimensions,
rememberMediaBlurHash,
} from '../../state/mediaDimensionCache';
type UserAvatarProps = {
className?: string;
@@ -25,20 +28,28 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
const [error, setError] = useState(false);
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
// Check both our cache and browser's native cache
const [loaded, setLoaded] = useState(() => {
if (isAvatarCached(src)) return true;
if (authenticatedSrc && isImageInBrowserCache(authenticatedSrc)) {
cacheAvatar(src);
return true;
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);
}
return false;
});
}, [authenticatedSrc, src, useAuthentication]);
const imgRef = useRef<HTMLImageElement>(null);
// Also check on mount if image is already complete (browser cached)
useEffect(() => {
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setLoaded(true);
@@ -51,10 +62,10 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
setLoaded(true);
cacheAvatar(src);
pruneAvatarCache();
rememberMediaBlurHash(src ?? '', evt.currentTarget);
};
// No src or error - show fallback only
if (!authenticatedSrc || error) {
if (!src || error) {
return (
<AvatarFallback
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 (loaded) {
if (authenticatedSrc && loaded) {
return (
<AvatarImage
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
// Image loads invisibly, then we show it once complete
const blurResolutionX = 32;
const blurResolutionY =
dims?.w && dims?.h && dims.w > 0
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
: 32;
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<AvatarFallback
style={{
backgroundColor: colorMXID(userId),
style={{
backgroundColor: colorMXID(userId),
color: color.Surface.Container,
position: 'absolute',
inset: 0,
@@ -95,16 +109,40 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
>
{renderFallback()}
</AvatarFallback>
<AvatarImage
ref={imgRef}
className={classNames(css.UserAvatar, className)}
style={{ position: 'relative', zIndex: 1 }}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
{blurHash && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
borderRadius: 'inherit',
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
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>
);
}

View File

@@ -11,7 +11,7 @@ export const VirtualTile = as<'div', VirtualTileProps>(
({ className, virtualItem, style, ...props }, ref) => (
<div
className={classNames(css.VirtualTile, className)}
style={{ top: virtualItem.start, ...style }}
style={{ top: virtualItem.start, zIndex: virtualItem.index, ...style }}
data-index={virtualItem.index}
{...props}
ref={ref}

View File

@@ -9,6 +9,7 @@ import {
ParticipantEvent,
Participant,
LocalTrackPublication,
LocalAudioTrack,
Track,
} from 'livekit-client';
import { MatrixClient } from 'matrix-js-sdk';
@@ -31,7 +32,7 @@ import {
getLiveKitHomeserverPriority,
} from './useRtcConfig';
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';
type CallEventListener = (event: CallEvent) => void;
@@ -102,6 +103,10 @@ export class CallService {
private wasMutedBeforeDeafen = false;
private readonly onAudioSettingsChanged = () => {
void this.applyAudioCaptureSettings();
};
/**
* Creates a new CallService instance
* @param config - Service configuration including homeserver and LiveKit URLs
@@ -110,6 +115,7 @@ export class CallService {
constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
this.config = config;
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
// 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({
adaptiveStream: true,
dynacast: true,
audioCaptureDefaults: audioCaptureOptions,
});
this.setupLiveKitEventHandlers();
@@ -400,19 +410,7 @@ export class CallService {
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
// Get user's audio device preferences
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);
console.log('Using audio capture options:', audioCaptureOptions);
// Step 5: Publish local tracks based on call type with selected devices and processing
if (callType === CallType.Video) {
@@ -881,7 +879,10 @@ export class CallService {
getCallSounds().playSound(CallSoundType.Undeafen);
}
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
this.livekitRoom.localParticipant.setMicrophoneEnabled(
!newMuteState,
getAudioCaptureOptions()
);
this.activeCall.isMuted = newMuteState;
// Play mute/unmute sound
@@ -929,7 +930,7 @@ export class CallService {
} else {
// Undeafening - only unmute if user wasn't muted before deafening
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
this.livekitRoom.localParticipant.setMicrophoneEnabled(true);
this.livekitRoom.localParticipant.setMicrophoneEnabled(true, getAudioCaptureOptions());
this.activeCall.isMuted = false;
}
this.wasMutedBeforeDeafen = false;
@@ -1129,10 +1130,39 @@ export class CallService {
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
*/
dispose(): void {
window.removeEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged);
this.stopMembershipRefresh();
this.endCall();
this.listeners.clear();

View File

@@ -10,7 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode';
import { millisecondsToMinutes } from '../../utils/common';
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../../components/create-room';
import { createRoomEncryptionState } from '../../components/create-room';
import { useAlive } from '../../hooks/useAlive';
import { getDirectRoomPath } from '../../pages/pathUtils';
@@ -28,9 +28,10 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>(
useCallback(
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[] = [];
initialState.push(createRoomPowerLevelsState());
if (encrypted) initialState.push(createRoomEncryptionState());
const result = await mx.createRoom({

View File

@@ -1,7 +1,6 @@
import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
import { isKeyHotkey } from 'is-hotkey';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { IconButton, Line, PopOut } from 'folds';
import { Icon, Icons } from '../../components/icons';
import {
@@ -13,6 +12,7 @@ import {
getAutocompleteQuery,
createEmoticonElement,
moveCursor,
safeFocusEditor,
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -84,7 +84,7 @@ export function ForumChatComposer({
const handleCloseAutocomplete = useCallback(() => {
setAutocompleteQuery(undefined);
ReactEditor.focus(editor);
safeFocusEditor(editor);
}, [editor]);
return (
@@ -114,7 +114,7 @@ export function ForumChatComposer({
onClick={() => setToolbar(!toolbar)}
aria-label="Formatting"
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} size="200" />
</IconButton>
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
@@ -136,15 +136,15 @@ export function ForumChatComposer({
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) safeFocusEditor(editor);
return undefined;
}
return t;
});
}}
/>
}
>
@@ -158,7 +158,11 @@ export function ForumChatComposer({
type="button"
aria-label="Emoji"
>
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
<Icon
src={Icons.Smile}
size="200"
filled={emojiBoardTab === EmojiBoardTab.Emoji}
/>
</IconButton>
</PopOut>
)}
@@ -173,7 +177,7 @@ export function ForumChatComposer({
radii="300"
aria-label="Send"
>
<Icon src={Icons.Send} />
<Icon src={Icons.Send} size="200" />
</IconButton>
)}
</>

View File

@@ -14,6 +14,7 @@ import * as css from './LobbyHeader.css';
import { IPowerLevels } from '../../hooks/usePowerLevels';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
import { stopPropagation } from '../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useBackRoute } from '../../hooks/useBackRoute';
@@ -48,6 +49,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const [invitePrompt, setInvitePrompt] = useState(false);
@@ -117,6 +119,34 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
</Box>
<Line variant="Surface" size="300" />
<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}>
{(promptLeave, setPromptLeave) => (
<>

View File

@@ -133,7 +133,11 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
}, [mx, roomItems]);
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
childItems = childItems?.filter((i) => {
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 { ContainerColor } from '../../styles/ContainerColor.css';
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 { mDirectAtom } from '../../state/mDirectList';
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
@@ -53,7 +53,13 @@ export function MessageSearch({
}: MessageSearchProps) {
const mx = useMatrixClient();
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 [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');

View File

@@ -4,6 +4,9 @@ import {
ISearchRequestBody,
ISearchResponse,
ISearchResult,
MatrixClient,
MatrixEvent,
Room,
SearchOrderBy,
} from 'matrix-js-sdk';
import { useCallback } from 'react';
@@ -26,6 +29,12 @@ export type SearchResult = {
groups: ResultGroup[];
};
const EMPTY_CONTEXT: IResultContext = {
events_before: [],
events_after: [],
profile_info: {},
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = [];
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const parseSearchResult = (result: ISearchResponse): SearchResult => {
const roomEvents = result.search_categories.room_events;
const searchResult: SearchResult = {
return {
nextToken: roomEvents?.next_batch,
highlights: roomEvents?.highlights ?? [],
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 = {
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
rooms?: string[];
senders?: string[];
};
export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient();
const { term, order, rooms, senders } = params;
const searchMessages = useCallback(
async (nextBatch?: string) => {
if (!term)
if (!term) {
return {
highlights: [],
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 = {
search_categories: {
room_events: {
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
},
};
const r = await mx.search({
body: requestBody,
next_batch: nextBatch === '' ? undefined : nextBatch,
});
return parseSearchResult(r);
try {
const r = await mx.search({
body: requestBody,
next_batch: nextBatch === '' ? undefined : nextBatch,
});
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]
);

View File

@@ -10,6 +10,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
className={classNames(css.CategoryButton, className)}
variant="Background"
radii="Pill"
data-nav-category-btn=""
before={
<Icon
className={css.CategoryButtonIcon}

View File

@@ -24,7 +24,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { TypingIndicator } from '../../components/typing-indicator';
import { stopPropagation } from '../../utils/keyboard';
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 { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
@@ -46,6 +46,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { StateEvent } from '../../../types/matrix/room';
import { mDirectAtom } from '../../state/mDirectList';
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
import { PresenceAvatar } from '../../components/presence';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
type RoomNavItemMenuProps = {
room: Room;
@@ -289,13 +291,17 @@ export function RoomNavItem({
const avatarUrl = direct
? getDirectRoomAvatarUrl(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 typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId()
);
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
// Get first participated thread for preview
const firstThreadPreview = useMemo(() => {
@@ -317,7 +323,7 @@ export function RoomNavItem({
// Get parent space for DMs
const parentSpaceInfo = (() => {
if (!direct || !mDirects.has(room.roomId)) return undefined;
if (!isDirect || !mDirects.has(room.roomId)) return undefined;
const orphanParents = getOrphanParents(roomToParents, room.roomId);
if (orphanParents.length === 0) return undefined;
@@ -383,29 +389,60 @@ export function RoomNavItem({
<NavLink to={linkPath} onClick={handleRoomClick}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
{shouldShowIcon && (
<Avatar size="200" radii="400">
{showAvatar || avatarUrl ? (
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
{shouldShowIcon &&
(isDirect ? (
<PresenceAvatar
presence={
dmUserId && dmUserId !== mx.getSafeUserId()
? dmPresence?.presence ?? Presence.Offline
: Presence.Offline
}
>
<Avatar size="200" radii="400">
{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
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
)}
</Avatar>
)}
<Avatar size="200" radii="400">
{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>
))}
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name}

View File

@@ -41,7 +41,13 @@ export function UnjoinedSubRoomItem({ roomId, depth, isLast = false }: UnjoinedS
return (
<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"
gap="200"
>

View File

@@ -3,9 +3,10 @@ import { Box, Line } from 'folds';
import { decodeRouteParam } from '../../pages/pathUtils';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
import { useSetAtom } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { MediaDrawer } from './room-media-menu';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -16,6 +17,7 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { activeRoomIdAtom } from '../../state/activeRoom';
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
import { isForum } from '../../utils/room';
import { ForumRoomView } from './ForumRoomView';
@@ -26,13 +28,18 @@ export function Room() {
const mx = useMatrixClient();
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [isPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [isMediaDrawer] = useAtom(isMediaDrawerAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const markAsRead = useMarkAsRead(mx);
const forumRoom = isForum(room);
const skinny = screenSize !== ScreenSize.Desktop;
const showMediaSolo = skinny && isMediaDrawer;
const showRightDrawer =
!skinny && (isPeopleDrawer || isMediaDrawer);
// Update titlebar with current room ID
useEffect(() => {
@@ -55,11 +62,21 @@ export function Room() {
return (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
{screenSize === ScreenSize.Desktop && isDrawer && (
{!showMediaSolo &&
(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" />
<MembersDrawer key={room.roomId} room={room} members={members} />
{isMediaDrawer ? (
<MediaDrawer room={room} />
) : (
<MembersDrawer key={room.roomId} room={room} members={members} />
)}
</>
)}
</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 { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
import { Box, Dialog, IconButton, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, color, config, toRem } from 'folds';
@@ -32,6 +31,7 @@ import {
EmoticonAutocomplete,
createEmoticonElement,
moveCursor,
safeFocusEditor,
resetEditorHistory,
customHtmlEqualsPlainText,
trimCustomHtml,
@@ -45,7 +45,6 @@ import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
import {
TUploadContent,
encryptFile,
getImageInfo,
getMxIdLocalPart,
mxcUrlToHttp,
@@ -54,6 +53,7 @@ import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
import { useFileDropZone } from '../../hooks/useFileDrop';
import { useRoomUploadFiles } from '../../hooks/useRoomUploadFiles';
import {
TUploadItem,
TUploadMetadata,
@@ -76,7 +76,6 @@ import {
createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -101,13 +100,25 @@ import colorMXID from '../../../util/colorMXID';
import { useIsDirectRoom } from '../../hooks/useRoom';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useTheme } from '../../hooks/useTheme';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { getMemberAvatarMxc } from '../../utils/room';
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 {
editor: Editor;
@@ -212,42 +223,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
}, [commands]);
const { handleFiles: enqueueFiles } = useRoomUploadFiles(room);
const handleFiles = useCallback(
async (files: File[]) => {
setUploadBoard(true);
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,
});
await enqueueFiles(files);
},
[setSelectedFiles, room]
[enqueueFiles]
);
const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles);
@@ -306,12 +288,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
};
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 fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload');
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')) {
return getVideoMsgContent(mx, fileItem, upload.mxc);
@@ -559,7 +563,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleCloseAutocomplete = useCallback(() => {
setAutocompleteQuery(undefined);
ReactEditor.focus(editor);
safeFocusEditor(editor);
}, [editor]);
const handleEmoticonSelect = (key: string, shortcode: string) => {
@@ -584,7 +588,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
};
return (
<div ref={ref}>
<div ref={ref} className={css.RoomInputWrap}>
{selectedFiles.length > 0 && (
<UploadBoard
header={
@@ -690,8 +694,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderBottom: 'none',
borderTopLeftRadius: config.radii.R400,
borderTopRightRadius: config.radii.R400,
borderRadius: isStationeryTheme(theme) ? 0 : undefined,
borderTopLeftRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
borderTopRightRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
marginBottom: config.space.S100,
}}
direction="Column"
@@ -764,7 +769,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
size="300"
radii="300"
>
<Icon src={Icons.PlusCircle} />
<Icon src={Icons.PlusCircle} size="200" />
</IconButton>
<PluginButtonSlot location="composer-actions" />
</>
@@ -777,7 +782,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
radii="300"
onClick={() => setToolbar(!toolbar)}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} size="200" />
</IconButton>
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
@@ -803,7 +808,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
if (!mobileOrTablet()) safeFocusEditor(editor);
return undefined;
}
return t;
@@ -822,6 +827,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
<Icon
src={Icons.Smile}
size="200"
filled={emojiBoardTab === EmojiBoardTab.Emoji}
/>
</IconButton>
@@ -830,7 +836,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</UseStateProvider>
<PluginButtonSlot location="text-composer-toolbar" />
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
<Icon src={Icons.Send} size="200" />
</IconButton>
</>
}

View File

@@ -5,6 +5,6 @@ export const RoomInputPlaceholder = style({
minHeight: toRem(48),
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R400,
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: 0,
});

View File

@@ -9,7 +9,8 @@ export const TimelineFloat = recipe({
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1,
// Above timeline media carousels / sticky message chrome
zIndex: 10,
minWidth: 'max-content',
},
],

View File

@@ -85,7 +85,6 @@ import {
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
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 { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import {
clearRoomReturnAnchor,
clearRoomScrollState,
getRoomReturnAnchor,
getRoomScrollState,
saveRoomScrollState,
setRoomReturnAnchor,
} from '../../state/roomScrollCache';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@@ -620,6 +627,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const location = useLocation();
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 [unreadInfo, setUnreadInfo] = useState(() =>
@@ -631,7 +642,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
const atBottomAnchorRef = useRef<HTMLElement>(null);
const [atBottom, setAtBottom] = useState<boolean>(true);
const [atBottom, setAtBottom] = useState<boolean>(
() => savedScrollRef.current?.atBottom ?? true
);
const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom;
@@ -639,16 +652,54 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
sender: string;
content: string;
} | 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 timelineContentRef = useRef<HTMLDivElement>(null);
const timelineContentHeightRef = useRef(0);
const restoringScrollRef = useRef(false);
const scrollToBottomRef = useRef({
count: 0,
smooth: true,
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<
| {
index: number;
@@ -680,9 +731,49 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
const parseMemberEvent = useMemberEventParser();
const [timeline, setTimeline] = useState<Timeline>(() =>
eventId ? getEmptyTimeline() : getInitialTimeline(room)
const [timeline, setTimeline] = useState<Timeline>(() => {
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 liveTimelineLinked =
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);
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(
mx,
timeline,
@@ -757,6 +862,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const isOwnMessage = mEvt.getSender() === mx.getUserId();
const isBottomPinnedEvent =
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
const wasAwayFromBottom = !atBottomRef.current;
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
// Always update timeline with new message
@@ -777,6 +883,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
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) {
// Show unread notification for other users' messages when unfocused
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();
if (!editorBaseEntry || !scrollElement) return;
if (restoringScrollRef.current) return;
if (isScrolledToBottom(scrollElement)) {
autoScrollToBottom(scrollElement, false, true);
}
@@ -882,6 +998,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const previousHeight = timelineContentHeightRef.current;
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)) {
return;
}
@@ -904,30 +1032,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, [room, hideActivity, markAsRead]);
const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) {
setAtBottom(false);
}
}, []),
{ wait: 1000 }
);
useIntersectionObserver(
useCallback(
(entries) => {
const target = atBottomAnchorRef.current;
if (!target) return;
const targetEntry = getIntersectionObserverEntry(target, entries);
if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true);
setLatestUnreadMessage(null);
if (document.hasFocus()) {
tryAutoMarkAsRead();
}
if (!targetEntry) return;
// Leave the live bottom immediately so Jump to Latest stays available.
// Only intersection clears atBottom here; !atLiveEnd is handled by the
// liveTimelineLinked/rangeAtEnd effect so we don't stick false when the
// 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(
() => ({
@@ -991,9 +1121,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, [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
useEffect(() => {
if (scrollToLatest) {
clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null);
setUnreadInfo(undefined);
setTimeline(getInitialTimeline(room));
@@ -1003,12 +1139,87 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, [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(() => {
const scrollEl = scrollRef.current;
if (scrollEl) {
if (!scrollEl) return undefined;
if (scrollToLatest) {
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
@@ -1052,7 +1263,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// scroll to bottom of timeline
const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => {
if (scrollToBottomCount > 0) {
if (scrollToBottomCount > 0 && !restoringScrollRef.current) {
const scrollEl = scrollRef.current;
if (scrollEl) {
autoScrollToBottom(
@@ -1060,6 +1271,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
scrollToBottomRef.current.smooth,
scrollToBottomRef.current.force
);
if (atLiveEndRef.current) {
setAtBottom(true);
}
}
}
}, [scrollToBottomCount]);
@@ -1110,16 +1324,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}, [room, hour24Clock]);
const handleJumpToLatest = () => {
const anchorEventId =
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
if (anchorEventId) {
setReturnToEventId(anchorEventId);
}
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
clearRoomScrollState(room.roomId);
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.smooth = false;
scrollToBottomRef.current.force = true;
};
const handleReturnToPrevious = () => {
if (!returnToEventId) return;
const targetId = returnToEventId;
setReturnToEventId(null);
handleOpenEvent(targetId);
};
const handleClearReturnToPrevious = () => {
setReturnToEventId(null);
};
const handleJumpToUnread = () => {
if (unreadInfo?.readUptoEventId) {
setTimeline(getEmptyTimeline());
@@ -2266,36 +2515,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return callSummaryJSX || eventJSX;
};
const showUnreadTop =
!!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
return (
<Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
<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">
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
<Box
ref={timelineContentRef}
direction="Column"
justifyContent="End"
data-chatroll=""
style={{
minHeight: '100%',
padding: `${config.space.S600} 0`,
@@ -2381,10 +2612,65 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</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>
</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">
<Box direction="Column" gap="200" alignItems="Center">
{latestUnreadMessage && (

View File

@@ -14,7 +14,8 @@ import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone';
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 { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown';
@@ -109,7 +110,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
<ThreadView room={room} threadRootId={activeThreadId} />
) : (
<>
<Box grow="Yes" direction="Column">
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
<RoomTimeline
key={roomId}
room={room}
@@ -117,40 +118,42 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
roomInputRef={roomInputRef}
editor={editor}
/>
<RoomViewTyping room={room} />
<div className={roomViewCss.RoomViewBottomFloat}>
<RoomViewTyping room={room} />
{!hideActivity && <RoomViewFollowing room={room} />}
</div>
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
{tombstoneEvent ? (
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</div>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</Box>
</>
)}

View File

@@ -2,6 +2,20 @@ import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
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([
DefaultReset,
{
@@ -15,10 +29,12 @@ export const RoomViewFollowing = recipe({
{
minHeight: toRem(28),
padding: `0 ${config.space.S400}`,
width: '100%',
backgroundColor: color.Surface.Container,
marginLeft: 'auto',
maxWidth: '50%',
backgroundColor: 'transparent',
color: color.Surface.OnContainer,
outline: 'none',
pointerEvents: 'auto',
},
],
variants: {

View File

@@ -35,6 +35,10 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
const eventId = latestEvent?.getId();
if (names.length === 0) {
return null;
}
return (
<>
{eventId && (
@@ -56,64 +60,58 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
</Overlay>
)}
<Box
as={names.length > 0 ? 'button' : 'div'}
onClick={names.length > 0 ? () => setOpen(true) : undefined}
className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
as="button"
onClick={() => setOpen(true)}
className={classNames(css.RoomViewFollowing({ clickable: true }), className)}
alignItems="Center"
justifyContent="End"
gap="200"
{...props}
ref={ref}
>
{names.length > 0 && (
<>
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
<Text size="T300" truncate>
{names.length === 1 && (
<b>{names[0]}</b>
)}
{names.length === 2 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[1]}</b>
</>
)}
{names.length === 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[2]}</b>
</>
)}
{names.length > 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names.length - 3} others</b>
</>
)}
</Text>
</>
)}
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
<Text size="T300" truncate>
{names.length === 1 && <b>{names[0]}</b>}
{names.length === 2 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[1]}</b>
</>
)}
{names.length === 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[2]}</b>
</>
)}
{names.length > 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names.length - 3} others</b>
</>
)}
</Text>
</Box>
</>
);

View File

@@ -7,19 +7,19 @@ import { JoinRule, Room } from 'matrix-js-sdk';
import { useAtom, useAtomValue } from 'jotai';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import { getHomeSearchPath, getSpaceSearchPath, getDirectSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import colorMXID from '../../../util/colorMXID';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { _SearchPathSearchParams } from '../../pages/paths';
import * as css from './RoomViewHeader.css';
import { useRoomUnread } from '../../state/hooks/unread';
@@ -41,6 +41,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import {
getRoomNotificationMode,
@@ -56,6 +57,8 @@ import { useRoomCall, useRoomCallMembers } from '../call';
import { CallState, CallType } from '../call/types';
import { UserAvatar } from '../../components/user-avatar';
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
import { useDirectSelected } from '../../hooks/router/useDirectSelected';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
type RoomMenuProps = {
room: Room;
@@ -419,7 +422,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
{isConnecting ? (
<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>
)}
@@ -445,7 +448,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
{isConnecting ? (
<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>
)}
@@ -469,18 +472,31 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const mDirects = useAtomValue(mDirectAtom);
const onDirectPath = useDirectSelected();
const theme = useTheme();
const stationery = isStationeryTheme(theme);
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const ecryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
const isDirect = mDirects.has(room.roomId);
const avatarMxc = useRoomAvatar(room, isDirect);
const name = useRoomName(room);
const topic = useRoomTopic(room);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? 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 [isMediaDrawer, setMediaDrawer] = useAtom(isMediaDrawerAtom);
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
const handleSearchClick = () => {
@@ -489,7 +505,9 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
};
const path = space
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
: getHomeSearchPath();
: isDirect || onDirectPath
? getDirectSearchPath()
: getHomeSearchPath();
navigate(withSearchParam(path, searchParams));
};
@@ -501,17 +519,50 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
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 (
<PageHeader balance={showInPageHeader}>
<Box grow="Yes" gap="300">
{showInPageHeader && (
<Box shrink="No" alignItems="Center">
<IconButton onClick={goBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
<Box grow="Yes" alignItems="Center" gap="300">
<Box
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">
<RoomAvatar
@@ -528,7 +579,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
/>
</Avatar>
)}
<Box direction="Column">
<Box direction="Column" style={{ minWidth: 0 }}>
<Box alignItems="Center" gap="100">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
@@ -578,26 +629,25 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
)}
</Box>
</Box>
<Box grow="Yes" />
<Box shrink="No" alignItems="Center" gap="100">
<CallIndicator roomId={room.roomId} />
<RoomCallButtons roomId={room.roomId} />
{!ecryptedRoom && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Search</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handleSearchClick}>
<Icon size="400" src={Icons.Search} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Search</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handleSearchClick}>
<Icon size="300" src={Icons.Search} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Bottom"
offset={4}
@@ -631,7 +681,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
</Text>
</Badge>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
<Icon size="300" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
@@ -665,8 +715,12 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
<Icon size="400" src={Icons.User} />
<IconButton
ref={triggerRef}
onClick={handleTogglePeopleDrawer}
aria-pressed={peopleDrawer}
>
<Icon size="300" src={Icons.User} filled={peopleDrawer} />
</IconButton>
)}
</TooltipProvider>
@@ -686,7 +740,27 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
onClick={() => setActiveThreadId(undefined)}
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>
)}
</TooltipProvider>
@@ -703,7 +777,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
>
{(triggerRef) => (
<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>
)}
</TooltipProvider>

View File

@@ -1,5 +1,5 @@
import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
import { DefaultReset, color, config, toRem } from 'folds';
const SlideUpAnime = keyframes({
from: {
@@ -13,15 +13,17 @@ const SlideUpAnime = keyframes({
export const RoomViewTyping = style([
DefaultReset,
{
padding: `0 ${config.space.S500}`,
width: '100%',
backgroundColor: color.Surface.Container,
minHeight: toRem(28),
padding: `0 ${config.space.S400}`,
minWidth: 0,
flex: 1,
backgroundColor: 'transparent',
color: color.Surface.OnContainer,
position: 'absolute',
bottom: 0,
pointerEvents: 'auto',
animation: `${SlideUpAnime} 100ms ease-in-out`,
},
]);
export const TypingText = style({
flexGrow: 1,
overflow: 'clip',

View File

@@ -45,78 +45,76 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
};
return (
<div style={{ position: 'relative' }}>
<Box
className={classNames(css.RoomViewTyping, className)}
alignItems="Center"
gap="400"
{...props}
ref={ref}
>
<TypingIndicator />
<Text className={css.TypingText} size="T300" truncate>
{typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is typing...'}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
</Text>
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
</div>
<Box
className={classNames(css.RoomViewTyping, className)}
alignItems="Center"
gap="400"
{...props}
ref={ref}
>
<TypingIndicator />
<Text className={css.TypingText} size="T300" truncate>
{typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is typing...'}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
</Text>
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
);
}
);

View File

@@ -60,7 +60,8 @@ import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { RoomInput } from './RoomInput';
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 { Reply } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -731,8 +732,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
</Box>
{/* Thread messages — grows to fill remaining space */}
<Box grow="Yes" direction="Column">
<Box grow="Yes" style={{ position: 'relative' }} ref={containerRef}>
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
<Box grow="Yes" style={{ position: 'relative', minHeight: 0 }} ref={containerRef}>
<Scroll ref={scrollRef} visibility="Hover">
<Box
direction="Column"
@@ -759,22 +760,22 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
</Box>
</Scroll>
</Box>
<RoomViewTyping room={room} />
<div className={roomViewCss.RoomViewBottomFloat}>
<RoomViewTyping room={room} />
{!hideActivity && <RoomViewFollowing room={room} />}
</div>
</Box>
{/* Thread input */}
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomInput
room={room}
editor={editor}
roomId={room.roomId}
fileDropContainerRef={containerRef}
threadRootId={threadRootId}
ref={inputRef}
/>
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
<RoomInput
room={room}
editor={editor}
roomId={room.roomId}
fileDropContainerRef={containerRef}
threadRootId={threadRootId}
ref={inputRef}
/>
</Box>
</>
);

View File

@@ -802,8 +802,9 @@ export const Message = as<'div', MessageProps>(
gap="300"
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween"
alignItems="Baseline"
alignItems="Center"
grow="Yes"
data-message-header=""
>
<Box alignItems="Center" gap="200">
<Username

View File

@@ -8,7 +8,6 @@ import React, {
import { Box, Chip, IconButton, Line, PopOut, RectCords, Spinner, Text, as, config } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import {
@@ -27,6 +26,7 @@ import {
htmlToEditorInput,
moveCursor,
plainToEditorInput,
safeFocusEditor,
toMatrixCustomHTML,
toPlainText,
trimCustomHtml,
@@ -185,7 +185,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
);
const handleCloseAutocomplete = useCallback(() => {
ReactEditor.focus(editor);
safeFocusEditor(editor);
setAutocompleteQuery(undefined);
}, [editor]);
@@ -208,7 +208,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
});
editor.insertFragment(initialValue);
if (!mobileOrTablet()) ReactEditor.focus(editor);
if (!mobileOrTablet()) safeFocusEditor(editor);
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
useEffect(() => {
@@ -282,7 +282,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
radii="300"
onClick={() => setToolbar(!toolbar)}
>
<Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
<Icon size="200" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<UseStateProvider initial={undefined}>
{(anchor: RectCords | undefined, setAnchor) => (
@@ -300,7 +300,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
requestClose={() => {
setAnchor((v) => {
if (v) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
if (!mobileOrTablet()) safeFocusEditor(editor);
return undefined;
}
return v;
@@ -321,7 +321,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
size="300"
radii="300"
>
<Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
<Icon size="200" src={Icons.Smile} filled={anchor !== undefined} />
</IconButton>
</PopOut>
)}

View File

@@ -55,6 +55,7 @@ export const Reactions = as<'div', ReactionsProps>(
className={classNames(css.ReactionsContainer, className)}
gap="200"
wrap="Wrap"
data-reactions=""
{...props}
ref={ref}
>

View File

@@ -4,6 +4,9 @@ import {
IThumbnailContent,
MATRIX_BLUR_HASH_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';
import {
getImageFileUrl,
@@ -18,6 +21,12 @@ import { TUploadItem } from '../../state/room/roomInputDrafts';
import { encodeBlurHash } from '../../utils/blurHash';
import { scaleYDimension } from '../../utils/common';
type CarouselMetadata = {
uuid: string;
index: number;
total: number;
};
const generateThumbnailContent = async (
mx: MatrixClient,
img: HTMLImageElement | HTMLVideoElement,
@@ -46,7 +55,8 @@ const generateThumbnailContent = async (
export const getImageMsgContent = async (
mx: MatrixClient,
item: TUploadItem,
mxc: string
mxc: string,
carouselMetadata?: CarouselMetadata
): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
@@ -59,10 +69,14 @@ export const getImageMsgContent = async (
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
};
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 = {
...getImageInfo(imgEl, file),
w: naturalW,
h: naturalH,
[MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
};
}
@@ -74,6 +88,11 @@ export const getImageMsgContent = async (
} else {
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;
};

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(
(roomId: string) => {
const roomName = getRoom(roomId)?.name ?? roomId;
const room = getRoom(roomId);
const roomName = room?.name ?? roomId;
if (mDirects.has(roomId)) {
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
if (targetUsername) return [roomName, targetUsername];
if (!targetUserId) return roomName;
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;
},

View File

@@ -1,5 +1,17 @@
import React, { useMemo, useState } from 'react';
import { Avatar, Box, Button, config, IconButton, MenuItem, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Avatar,
Box,
Button,
config,
IconButton,
Input,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { Icon, Icons, IconSrc } from '../../components/icons';
import FocusTrap from 'focus-trap-react';
import { General } from './general';
@@ -22,18 +34,17 @@ import { Plugins } from './plugins';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
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 {
GeneralPage,
AccountPage,
NotificationPage,
AudioPage,
DevicesPage,
EmojisStickersPage,
PluginsPage,
DeveloperToolsPage,
AboutPage,
}
export { SettingsPages };
type SettingsMenuItem = {
page: SettingsPages;
@@ -112,7 +123,37 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
});
const [searchQuery, setSearchQuery] = useState('');
const [focusAnchorId, setFocusAnchorId] = useState<string | undefined>();
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 = () => {
if (screenSize === ScreenSize.Mobile) {
@@ -122,120 +163,244 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
requestClose();
};
return (
<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">
<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>
)
const openSearchResult = useCallback(
(page: SettingsPages, title: string) => {
const pageName = pageNameByPage.get(page);
// Page-level entries just open the page; tile entries scroll into view.
if (title !== pageName) {
setFocusAnchorId(settingAnchorId(title));
} else {
setFocusAnchorId(undefined);
}
>
{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>
setActivePage(page);
setSearchQuery('');
},
[pageNameByPage]
);
// Fallback scroll if the page just mounted and tiles need a moment to render.
useEffect(() => {
if (!focusAnchorId) return;
let cancelled = false;
let attempts = 0;
const tryScroll = () => {
if (cancelled) return;
const el = document.getElementById(focusAnchorId);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
if (attempts < 40) {
attempts += 1;
window.requestAnimationFrame(tryScroll);
} else {
setFocusAnchorId(undefined);
}
};
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;
}
/** Dispatched on window after audio settings are persisted. */
export const AUDIO_SETTINGS_CHANGED_EVENT = 'paarrot-audio-settings-changed';
/**
* Saves audio settings to localStorage
* @param settings - The audio settings to save
@@ -90,6 +93,7 @@ function loadAudioSettings(): AudioSettings {
function saveAudioSettings(settings: AudioSettings): void {
try {
localStorage.setItem(AUDIO_SETTINGS_KEY, JSON.stringify(settings));
window.dispatchEvent(new CustomEvent(AUDIO_SETTINGS_CHANGED_EVENT));
} catch (e) {
console.error('Failed to save audio settings:', e);
}
@@ -103,6 +107,40 @@ export function getAudioSettings(): AudioSettings {
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 = {
devices: AudioDevice[];
selectedId: string | null;
@@ -635,22 +673,9 @@ export function Audio({ requestClose }: AudioProps) {
} else {
// Start monitoring
try {
const constraints: MediaStreamConstraints = {
audio: settings.microphoneId
? {
deviceId: { exact: settings.microphoneId },
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
}
: {
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
},
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const stream = await navigator.mediaDevices.getUserMedia({
audio: getMicTrackConstraints(settings),
});
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
@@ -685,7 +710,7 @@ export function Audio({ requestClose }: AudioProps) {
}
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
// Cleanup monitoring on unmount or when settings change
// Cleanup monitoring on unmount
useEffect(() => {
return () => {
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(() => {
if (micMonitoring) {
handleMicMonitoringToggle(); // Stop
setTimeout(() => handleMicMonitoringToggle(), 100); // Restart
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
if (!micMonitoring) return;
const track = micMonitorStreamRef.current?.getAudioTracks()[0];
if (!track) return;
track
.applyConstraints({
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 (
<Page>

View File

@@ -10,6 +10,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI';
import { stopPropagation } from '../../../utils/keyboard';
import { fuzzyFilter } from '../../../utils/fuzzy';
/**
* Renders settings UI for a plugin based on its settings schema
@@ -320,13 +321,6 @@ export function Plugins({ requestClose }: PluginsProps) {
[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 [imageError, setImageError] = useState(false);
@@ -365,22 +359,22 @@ export function Plugins({ requestClose }: PluginsProps) {
};
const filteredMarketplacePlugins = useMemo(() => {
if (!searchQuery) return marketplacePlugins;
return marketplacePlugins.filter((plugin) =>
fuzzySearch(searchQuery, plugin.name) ||
fuzzySearch(searchQuery, plugin.description) ||
fuzzySearch(searchQuery, plugin.author) ||
plugin.tags.some((tag) => fuzzySearch(searchQuery, tag))
);
if (!searchQuery.trim()) return marketplacePlugins;
return fuzzyFilter(marketplacePlugins, searchQuery, (plugin) => [
plugin.name,
plugin.description,
plugin.author,
...plugin.tags,
]).map(({ item }) => item);
}, [marketplacePlugins, searchQuery]);
const filteredInstalledPlugins = useMemo(() => {
if (!searchQuery) return installedPlugins;
return installedPlugins.filter((plugin) =>
fuzzySearch(searchQuery, plugin.name) ||
fuzzySearch(searchQuery, plugin.description) ||
fuzzySearch(searchQuery, plugin.author)
);
if (!searchQuery.trim()) return installedPlugins;
return fuzzyFilter(installedPlugins, searchQuery, (plugin) => [
plugin.name,
plugin.description,
plugin.author,
]).map(({ item }) => item);
}, [installedPlugins, searchQuery]);
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 { config } from 'folds';
import { color, config } from 'folds';
export const SequenceCardStyle = style({
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 { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { copyToClipboard } from '../../utils/dom';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
@@ -32,6 +33,7 @@ import { useNavigate } from 'react-router-dom';
import { getSpaceFeedPath, getSpaceLobbyPath } from '../../pages/pathUtils';
import { shouldShowForumLobby } from '../../utils/room';
import { useSpaceFeedSelected } from '../../hooks/router/useSelectedSpace';
import { StateEvent } from '../../../types/matrix/room';
type ForumAddOptions = {
item: HierarchyItem;
@@ -65,6 +67,7 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
@@ -304,6 +307,34 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
</Box>
<Line variant="Surface" size="300" />
<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}>
{(promptLeave, setPromptLeave) => (
<>

View File

@@ -1,5 +1,5 @@
import { useMatch } from 'react-router-dom';
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '../../pages/pathUtils';
export const useDirectSelected = (): boolean => {
const directMatch = useMatch({
@@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => {
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 { useMatrixClient } from './useMatrixClient';
import { getCurrentAccessToken } from '../utils/auth';
import {
getCachedAuthenticatedMediaUrl,
isAuthenticatedMediaUrl,
resolveAuthenticatedMediaUrl,
} from '../utils/authenticatedMediaCache';
/**
* Fetches media with authentication and returns a blob URL.
* This is needed because service workers may not work reliably in Tauri/WebKit environments,
* and cross-origin image requests cannot include Authorization headers.
* Resolves media URLs for Matrix authenticated media.
* Uses a shared blob-URL cache so avatars/thumbs are not re-fetched on every remount.
*/
export const useAuthenticatedMediaUrl = (
src: string | undefined,
useAuthentication: boolean
): string | undefined => {
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(() => {
if (!src) {
setBlobUrl(undefined);
return;
return undefined;
}
// If not using authentication, just return the original URL
if (!useAuthentication) {
if (!useAuthentication || !isAuthenticatedMediaUrl(src)) {
setBlobUrl(src);
return;
return undefined;
}
// Check if this is an authenticated media URL
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) {
setBlobUrl(src);
return;
const cached = getCachedAuthenticatedMediaUrl(src);
if (cached) {
setBlobUrl(cached);
return undefined;
}
let cancelled = false;
let objectUrl: string | undefined;
const fetchMedia = async () => {
try {
// Always use current session's token to avoid stale tokens during account switches
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('[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
resolveAuthenticatedMediaUrl(src, useAuthentication)
.then((url) => {
if (!cancelled) setBlobUrl(url);
})
.catch((error) => {
console.warn('[useAuthenticatedMediaUrl] Error fetching authenticated media:', error);
if (!cancelled) setBlobUrl(src);
}
};
fetchMedia();
});
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
// Shared cache owns blob URLs — do not revoke on unmount.
};
}, [src, useAuthentication, mx]);
@@ -98,42 +66,11 @@ export const useAuthenticatedMediaFetch = () => {
const mx = useMatrixClient();
return useCallback(
async (src: string): 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;
}
async (src: string, useAuthentication = true): Promise<string> => {
try {
// Always use current session's token to avoid stale tokens during account switches
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);
return await resolveAuthenticatedMediaUrl(src, useAuthentication);
} catch (error) {
console.warn('Error fetching authenticated media:', error);
console.warn('[useAuthenticatedMediaFetch] Error fetching authenticated media:', error);
return src;
}
},

View File

@@ -26,7 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room';
import { splitWithSpace } from '../utils/common';
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../components/create-room';
import { createRoomEncryptionState } from '../components/create-room';
export const SHRUG = '¯\\_(ツ)_/¯';
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
@@ -218,7 +218,8 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
invite: userIds,
visibility: Visibility.Private,
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]);
navigateRoom(result.room_id);

View File

@@ -1,12 +1,30 @@
import { useMemo } from 'react';
import { useClientConfig } from './useClientConfig';
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 => {
const { hashRouter } = useClientConfig();
const { origin } = window.location;
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);
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 { NavigateOptions, useNavigate } from 'react-router-dom';
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import {
@@ -7,21 +7,25 @@ import {
getHomeRoomPath,
getSpacePath,
getSpaceRoomPath,
withRoomEventId,
} from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient';
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { mDirectAtom } from '../state/mDirectList';
import { useSelectedRoom } from './router/useSelectedRoom';
import { useSelectedSpace } from './router/useSelectedSpace';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
export const useRoomNavigate = () => {
const navigate = useNavigate();
const location = useLocation();
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace();
const selectedRoomId = useSelectedRoom();
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const navigateSpace = useCallback(
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
const navigateRoom = useCallback(
(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 roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
@@ -44,6 +56,19 @@ export const useRoomNavigate = () => {
}
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) {
let parentSpace: string;
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
@@ -61,14 +86,18 @@ export const useRoomNavigate = () => {
return;
}
if (mDirects.has(roomId)) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return;
}
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
},
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
[
mx,
navigate,
location.pathname,
selectedRoomId,
spaceSelectedId,
roomToParents,
mDirects,
developerTools,
]
);
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 { createContext, useContext, useEffect, useMemo, useState } from 'react';
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 { useSetting } from '../state/hooks/settings';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
@@ -17,6 +17,11 @@ export type Theme = {
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 = {
id: 'light-theme',
kind: ThemeKind.Light,
@@ -63,6 +68,16 @@ export const CatppuccinMochaTheme: Theme = {
kind: ThemeKind.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[] => {
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
@@ -76,7 +91,7 @@ export const useThemes = (): Theme[] => {
}, []);
const builtInThemes: Theme[] = useMemo(() => [
LightTheme, SilverTheme, DarkTheme, ButterTheme,
LightTheme, SilverTheme, StationeryTheme, StationeryDarkTheme, DarkTheme, ButterTheme,
DiscordTheme, DiscordDarkerTheme, TwilightTheme,
MochaTheme, CatppuccinMochaTheme
], []);
@@ -112,6 +127,8 @@ export const useThemeNames = (): Record<string, string> => {
const builtInNames = {
[LightTheme.id]: 'Light',
[SilverTheme.id]: 'Silver',
[StationeryTheme.id]: 'Stationery',
[StationeryDarkTheme.id]: 'Stationery Dark',
[DarkTheme.id]: 'Dark',
[ButterTheme.id]: 'Butter',
[DiscordTheme.id]: 'Discord',

View File

@@ -24,23 +24,30 @@ const getUserPresence = (user: User): UserPresence => ({
export const useUserPresence = (userId: string): UserPresence | undefined => {
const mx = useMatrixClient();
const user = mx.getUser(userId);
const user = userId ? mx.getUser(userId) : null;
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => {
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
if (u.userId === user?.userId) {
if (!user) {
setPresence(undefined);
return undefined;
}
setPresence(getUserPresence(user));
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => {
if (u.userId === user.userId) {
setPresence(getUserPresence(user));
}
};
user?.on(UserEvent.Presence, updatePresence);
user?.on(UserEvent.CurrentlyActive, updatePresence);
user?.on(UserEvent.LastPresenceTs, updatePresence);
user.on(UserEvent.Presence, updatePresence);
user.on(UserEvent.CurrentlyActive, updatePresence);
user.on(UserEvent.LastPresenceTs, updatePresence);
return () => {
user?.removeListener(UserEvent.Presence, updatePresence);
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
user.removeListener(UserEvent.Presence, updatePresence);
user.removeListener(UserEvent.CurrentlyActive, updatePresence);
user.removeListener(UserEvent.LastPresenceTs, updatePresence);
};
}, [user]);

View File

@@ -41,7 +41,7 @@ import {
} from './pathUtils';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
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 { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
@@ -223,6 +223,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
>
{mobile ? null : <Route index element={<WelcomePage />} />}
<Route path={_CREATE_PATH} element={<DirectCreate />} />
<Route path={_SEARCH_PATH} element={<DirectSearch />} />
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
<Route path={_INVITES_PATH} element={<Invites />} />
<Route

View File

@@ -378,6 +378,14 @@ function MessageNotifications() {
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 (
showNotifications &&
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
@@ -399,7 +407,6 @@ function MessageNotifications() {
messageBody = typeof content.body === 'string' ? content.body : undefined;
}
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
@@ -430,6 +437,7 @@ function MessageNotifications() {
notify,
selectedRoomId,
useAuthentication,
mDirects,
]);
return null;

View File

@@ -29,7 +29,20 @@ export function SidebarNav() {
<Sidebar>
<SidebarContent
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>
{!homeHidden && <HomeTab />}
<DirectTab />

View File

@@ -17,7 +17,7 @@ import {
NavItemContent,
NavLink,
} from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath } from '../../pathUtils';
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath, getDirectSearchPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../../../components/virtualizer';
@@ -41,10 +41,9 @@ import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
import { useDirectCreateSelected, useDirectSearchSelected } from '../../../hooks/router/useDirectSelected';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { UnreadBadge } from '../../../components/unread-badge';
import { UnreadBadge } from '../../../components/unread-badge';
type DirectMenuProps = {
requestClose: () => void;
};
@@ -228,6 +227,7 @@ export function Direct() {
const navigate = useNavigate();
const createDirectSelected = useDirectCreateSelected();
const searchSelected = useDirectSearchSelected();
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
@@ -249,7 +249,7 @@ export function Direct() {
const virtualizer = useVirtualizer({
count: sortedDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
estimateSize: () => 36,
overscan: 10,
});
@@ -283,12 +283,28 @@ export function Direct() {
</NavButton>
</NavItem>
<PluginNavSlot location="direct-messages" />
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getDirectSearchPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory>
<NotificationsNavItem />
<InvitesNavItem />
</NavCategory>
<NavCategory>
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
@@ -298,13 +314,16 @@ export function Direct() {
Chats
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
ref={animationRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{sortedDirects.length > 0 && (
<div data-nav-rooms="">
<div
ref={animationRef}
data-nav-room-list=""
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const room = mx.getRoom(roomId);
@@ -332,7 +351,9 @@ export function Direct() {
</VirtualTile>
);
})}
</div>
</div>
)}
</NavCategory>
</Box>
</PageNavContent>

View File

@@ -1,15 +1,19 @@
import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { decodeRouteParam } from '../../pathUtils';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useDirectRooms } from './useDirectRooms';
import { mDirectAtom } from '../../../state/mDirectList';
import { Membership } from '../../../../types/matrix/room';
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
const rooms = useDirectRooms();
const mDirects = useAtomValue(mDirectAtom);
const { roomIdOrAlias: rawRoomIdOrAlias, eventId: rawEventId } = useParams();
const roomIdOrAlias = decodeRouteParam(rawRoomIdOrAlias);
@@ -17,7 +21,13 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
if (!room || !rooms.includes(room.roomId)) {
// useDirectRooms can lag m.direct / allRooms during catch-up; allow joined m.direct rooms.
const isJoinedDirect =
Boolean(room) &&
room!.getMyMembership() === Membership.Join &&
(rooms.includes(room!.roomId) || mDirects.has(room!.roomId));
if (!room || !isJoinedDirect) {
return (
<JoinBeforeNavigate
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}

View File

@@ -0,0 +1,55 @@
import React, { useRef } from 'react';
import { Box, Text, Scroll, IconButton } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { MessageSearch } from '../../../features/message-search';
import { useDirectRooms } from './useDirectRooms';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
export function DirectSearch() {
const scrollRef = useRef<HTMLDivElement>(null);
const rooms = useDirectRooms();
const screenSize = useScreenSizeContext();
return (
<Page>
<PageHeader balance>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Search} />}
<Text size="H3" truncate>
Message Search
</Text>
</Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<MessageSearch
defaultRoomsFilterName="Direct Messages"
allowGlobal
rooms={rooms}
scrollRef={scrollRef}
/>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

@@ -1,3 +1,4 @@
export * from './Direct';
export * from './RoomProvider';
export * from './DirectCreate';
export * from './Search';

View File

@@ -309,7 +309,7 @@ export function Home() {
const virtualizer = useVirtualizer({
count: listItems.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
estimateSize: () => 36,
overscan: 10,
});
@@ -399,7 +399,7 @@ export function Home() {
</NavItem>
<PluginNavSlot location="channel-list" />
</NavCategory>
<NavCategory>
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
@@ -410,12 +410,15 @@ export function Home() {
</RoomNavCategoryButton>
</NavCategoryHeader>
<PluginNavSlot location="home-section" />
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{listItems.length > 0 && (
<div data-nav-rooms="">
<div
data-nav-room-list=""
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const item = listItems[vItem.index];
if (!item) return null;
@@ -446,7 +449,16 @@ export function Home() {
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: item.depth > 0 ? '1.5rem' : undefined }}>
<div
data-nav-depth={item.depth}
style={{
paddingLeft,
display: 'flex',
alignItems: 'center',
minHeight: item.depth > 0 ? '1.5rem' : undefined,
position: 'relative',
}}
>
{treeIcon && (
<span style={{
paddingRight: '2px',
@@ -475,7 +487,9 @@ export function Home() {
</VirtualTile>
);
})}
</div>
</div>
)}
</NavCategory>
</Box>
</PageNavContent>

View File

@@ -154,7 +154,7 @@ export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
if (threads.length === 0) return null;
return (
<NavCategory>
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(THREADS_CATEGORY_ID)}
@@ -164,15 +164,18 @@ export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
My Threads
</RoomNavCategoryButton>
</NavCategoryHeader>
{!closedCategories.has(THREADS_CATEGORY_ID) &&
threads.map(({ room, thread }) => (
<ThreadNavItem
key={`${room.roomId}:${thread.id}`}
room={room}
thread={thread}
selected={false}
/>
))}
{!closedCategories.has(THREADS_CATEGORY_ID) && (
<div data-nav-rooms="">
{threads.map(({ room, thread }) => (
<ThreadNavItem
key={`${room.roomId}:${thread.id}`}
room={room}
thread={thread}
selected={false}
/>
))}
</div>
)}
</NavCategory>
);
}

View File

@@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useAtom, useAtomValue } from 'jotai';
import { decodeRouteParam } from '../../pathUtils';
@@ -30,7 +30,31 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
if (!room || !allRooms.includes(room.roomId)) {
const isJoined = Boolean(room && allRooms.includes(room.roomId));
const isSpaceChild = Boolean(room && getSpaceChildren(space).includes(room.roomId));
const hasParentMapping = Boolean(
room && getAllParents(roomToParents, room.roomId).has(space.roomId)
);
// roomToParents can lag behind live space state during catch-up sync.
// If the room is clearly a child of this space, backfill the map and open it.
useEffect(() => {
if (!room || !isJoined || hasParentMapping || !isSpaceChild) return;
setRoomToParents({
type: 'PUT',
parent: space.roomId,
children: [room.roomId],
});
}, [
room,
isJoined,
hasParentMapping,
isSpaceChild,
space.roomId,
setRoomToParents,
]);
if (!room || !isJoined) {
// room is not joined
return (
<JoinBeforeNavigate
@@ -50,16 +74,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
);
}
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
if (getSpaceChildren(space).includes(room.roomId)) {
// fill missing roomToParent mapping
setRoomToParents({
type: 'PUT',
parent: space.roomId,
children: [room.roomId],
});
}
if (!hasParentMapping && !isSpaceChild) {
return (
<JoinBeforeNavigate
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}

Some files were not shown because too many files have changed in this diff Show More