Compare commits

9 Commits

Author SHA1 Message Date
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
81 changed files with 5693 additions and 1079 deletions

View File

@@ -76,6 +76,7 @@ tauri.ts / electron main
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic - Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
- Pusher registration races on fast login/logout - Pusher registration races on fast login/logout
- Android small icon: `ic_stat_paarrot` must exist in Android resources - Android small icon: `ic_stat_paarrot` must exist in Android resources
- Background wake (`MatrixSyncService`) must filter by push rules / unread_notifications; a push for one room used to notify for every new message in the sync batch
## Future work ## Future work
@@ -91,3 +92,4 @@ tauri.ts / electron main
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts` - Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/` - Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
- Android notify filter: mute / mentions / default-room behavior lives in `MatrixSyncService.resolveRoomNotifyMode`

10
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
"@fontsource-variable/inter": "5.2.8", "@fontsource-variable/inter": "5.2.8",
"@fontsource/caveat": "5.3.0",
"@fontsource/inter": "5.2.8", "@fontsource/inter": "5.2.8",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git", "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.101.2", "@tanstack/react-query": "5.101.2",
@@ -2368,6 +2369,15 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource/caveat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.3.0.tgz",
"integrity": "sha512-eHTfzQxFSW9ABERRLNruHoNJmKed2J7pqTUc5lp4a4POMgXGKpxmWj3d9Hz10kmAg6iuTTLe6UagbA4bnsmfQw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": { "node_modules/@fontsource/inter": {
"version": "5.2.8", "version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",

View File

@@ -8,7 +8,7 @@
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
"scripts": { "scripts": {
"start": "vite --open false", "start": "vite",
"build": "vite build", "build": "vite build",
"lint": "yarn check:eslint && yarn check:prettier", "lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*", "check:eslint": "eslint src/*",
@@ -24,6 +24,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
"@fontsource-variable/inter": "5.2.8", "@fontsource-variable/inter": "5.2.8",
"@fontsource/caveat": "5.3.0",
"@fontsource/inter": "5.2.8", "@fontsource/inter": "5.2.8",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git", "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.101.2", "@tanstack/react-query": "5.101.2",

View File

@@ -1,16 +1,53 @@
import React from 'react'; import React, { useMemo } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
const decodeSegment = (segment: string): string => {
let decoded = segment;
try {
let next = decodeURIComponent(decoded);
while (next !== decoded) {
decoded = next;
next = decodeURIComponent(decoded);
}
} catch {
// keep partially decoded value
}
return decoded;
};
/** /**
* Wrapper for Outlet that adds route-based animation * Room routes are `:roomIdOrAlias/:eventId?/`. Jumping to an event (or clearing it)
* Forces remount on route change by using location as key * changes the pathname but not the room — keep the outlet mounted so we don't replay
* the route enter animation or remount drawers like Shared Media.
*
* Path segments are decoded so `!room:server` and `%21room%3Aserver` share a key.
*/
const getOutletTransitionKey = (pathname: string): string => {
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
if (segments.length === 0) return pathname;
// Matrix event IDs start with `$`
if (segments[segments.length - 1].startsWith('$')) {
segments.pop();
}
return `/${segments.join('/')}/`;
};
/**
* Wrapper for Outlet that adds route-based animation.
* Remounts (and animates) when leaving a room / switching rooms, not on same-room event hops.
*/ */
export function AnimatedOutlet() { export function AnimatedOutlet() {
const location = useLocation(); const location = useLocation();
const transitionKey = useMemo(
() => getOutletTransitionKey(location.pathname),
[location.pathname]
);
return ( return (
<div <div
key={location.pathname} key={transitionKey}
data-route-transition="true" data-route-transition="true"
style={{ style={{
flex: 1, flex: 1,

View File

@@ -6,11 +6,9 @@ import React, {
forwardRef, forwardRef,
useCallback, useCallback,
useState, useState,
useEffect,
useRef,
} from 'react'; } from 'react';
import { Box, Scroll, Text } from 'folds'; import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate'; import { Descendant, Editor, createEditor, Element as SlateElement, Text as SlateText } from 'slate';
import { import {
Slate, Slate,
Editable, Editable,
@@ -172,48 +170,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[editor, onKeyDown] [editor, onKeyDown]
); );
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle autocorrect replacement that causes text duplication
if (inputEvent.inputType === 'insertReplacementText' ||
inputEvent.inputType === 'insertFromComposition') {
const { selection } = editor;
if (!selection) return;
// Get the data being inserted
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (data) {
event.preventDefault();
// If there's selected text, delete it first
if (selection && !Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
}
// Insert the replacement text
editor.insertText(data);
}
}
},
[editor]
);
const editableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
if (!editableElement) return;
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
return () => {
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
};
}, [handleBeforeInput]);
const renderPlaceholder = useCallback( const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => ( ({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}> <span {...attributes} className={css.EditorPlaceholderContainer}>
@@ -256,10 +212,9 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
className={css.EditorTextareaScroll} className={css.EditorTextareaScroll}
variant="SurfaceVariant" variant="SurfaceVariant"
style={scrollStyle} style={scrollStyle}
size="300" size="0"
visibility="Hover" visibility="Hover"
hideTrack hideTrack
ref={editableRef}
> >
<Editable <Editable
data-editable-name={editableName} data-editable-name={editableName}

View File

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

View File

@@ -64,6 +64,33 @@ export const ReactionText = style([
}, },
]); ]);
export const ReactionStack = style([
DefaultReset,
{
position: 'relative',
display: 'inline-grid',
placeItems: 'center',
gridTemplateColumns: '1fr',
gridTemplateRows: '1fr',
lineHeight: toRem(20),
minWidth: 0,
},
]);
export const ReactionSticker = style([
DefaultReset,
{
minWidth: 0,
maxWidth: toRem(150),
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: toRem(20),
gridArea: '1 / 1',
transformOrigin: 'center center',
},
]);
export const ReactionImg = style([ export const ReactionImg = style([
DefaultReset, DefaultReset,
{ {

View File

@@ -6,6 +6,9 @@ import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji'; import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room'; import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
const MAX_STICKER_STACK = 4;
export const Reaction = as< export const Reaction = as<
'button', 'button',
@@ -15,35 +18,101 @@ export const Reaction = as<
reaction: string; reaction: string;
useAuthentication?: boolean; useAuthentication?: boolean;
} }
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => ( >(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
<Box const theme = useTheme();
as="button" const stationery = isStationeryTheme(theme);
className={classNames(css.Reaction, className)} const isCustomEmoji = reaction.startsWith('mxc://');
alignItems="Center" const customSrc = isCustomEmoji
shrink="No" ? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
gap="200" : undefined;
{...props}
ref={ref} const emoji = isCustomEmoji ? (
> <img className={css.ReactionImg} src={customSrc} alt={reaction} />
<Text className={css.ReactionText} as="span" size="T400"> ) : (
{reaction.startsWith('mxc://') ? ( <Text as="span" size="Inherit" truncate>
<img {reaction}
className={css.ReactionImg} </Text>
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction );
}
alt={reaction} // Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
/> if (!stationery) {
) : ( return (
<Text as="span" size="Inherit" truncate> <Box
{reaction} as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
data-reaction=""
aria-label={`${reaction}, ${count}`}
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{emoji}
</Text>
<Text as="span" size="T300" data-reaction-count="">
{count}
</Text>
</Box>
);
}
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
const showCount = count > 2;
return (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
data-reaction=""
data-reaction-stack={stackCount}
aria-label={`${reaction}, ${count}`}
{...props}
ref={ref}
>
<span className={css.ReactionStack} data-reaction-stack-layer="">
{Array.from({ length: stackCount }, (_, i) => {
const layer = stackCount - 1 - i;
return (
<Text
key={layer}
className={css.ReactionSticker}
as="span"
size="T400"
data-reaction-sticker=""
style={{
['--sticker-i' as string]: layer,
zIndex: i + 1,
}}
aria-hidden={i < stackCount - 1 ? true : undefined}
>
{isCustomEmoji ? (
<img
className={css.ReactionImg}
src={customSrc}
alt={i === stackCount - 1 ? reaction : ''}
/>
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
)}
</Text>
);
})}
</span>
{showCount && (
<Text as="span" size="T300" data-reaction-count="">
{count}
</Text> </Text>
)} )}
</Text> </Box>
<Text as="span" size="T300"> );
{count} });
</Text>
</Box>
));
type ReactionTooltipMsgProps = { type ReactionTooltipMsgProps = {
room: Room; room: Room;

View File

@@ -14,29 +14,54 @@ import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useOtherUserColor } from '../../hooks/useUserColor'; import { useOtherUserColor } from '../../hooks/useUserColor';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
import { STATIONERY_NAME_INK } from '../../utils/paperSafeInk';
type ReplyLayoutProps = { type ReplyLayoutProps = {
userColor?: string; userColor?: string;
username?: ReactNode; username?: ReactNode;
}; };
export const ReplyLayout = as<'div', ReplyLayoutProps>( export const ReplyLayout = as<'div', ReplyLayoutProps>(
({ username, userColor, className, children, ...props }, ref) => ( ({ username, userColor, className, children, ...props }, ref) => {
<Box const theme = useTheme();
className={classNames(css.Reply, className)} const isStationery = isStationeryTheme(theme);
alignItems="Center"
gap="100" return (
{...props} <Box
ref={ref} className={classNames(css.Reply, className)}
> alignItems="Center"
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No"> gap="100"
<Icon size="100" src={Icons.ReplyArrow} /> {...props}
{username} ref={ref}
>
<Box
style={
isStationery
? {
color: STATIONERY_NAME_INK,
maxWidth: toRem(200),
...(userColor
? {
['--username-accent' as string]: userColor,
textDecorationColor: userColor,
}
: null),
}
: { color: userColor, maxWidth: toRem(200) }
}
data-username-accent={isStationery && userColor ? '' : undefined}
alignItems="Center"
shrink="No"
>
<Icon size="100" src={Icons.ReplyArrow} />
{username}
</Box>
<Box grow="Yes" className={css.ReplyContent}>
{children}
</Box>
</Box> </Box>
<Box grow="Yes" className={css.ReplyContent}> );
{children} }
</Box>
</Box>
)
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (

View File

@@ -0,0 +1,293 @@
import React, {
ComponentProps,
CSSProperties,
MouseEventHandler,
ReactNode,
useCallback,
useMemo,
useRef,
} from 'react';
import { Attachment } from './attachment';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
type Corner = 'tl' | 'tr' | 'bl' | 'br';
type StationeryMediaProps = ComponentProps<typeof Attachment> & {
children: ReactNode;
/** Stable id (e.g. mxc URL) so tilt/tape don't reshuffle on timeline re-renders */
tiltSeed?: string;
/** Invisible bottom spacer (px) so following text stays on the ruled grid */
gridPad?: number;
};
const CORNERS: { id: Corner; x: 0 | 1; y: 0 | 1 }[] = [
{ id: 'tl', x: 0, y: 0 },
{ id: 'tr', x: 1, y: 0 },
{ id: 'bl', x: 0, y: 1 },
{ id: 'br', x: 1, y: 1 },
];
const MEDIA_ROTS = [-0.9, 0.7, -0.5, 1.0, -1.1, 0.8, -0.4, 0.9] as const;
export function hashStationerySeed(seed: string): number {
let h = 2166136261;
for (let i = 0; i < seed.length; i += 1) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
export function stationeryMediaRot(seed: string): string {
return `${MEDIA_ROTS[hashStationerySeed(seed) % MEDIA_ROTS.length]}deg`;
}
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
/**
* Fold sits halfway between corner and cursor; flap hinges on that fold.
* `keep` = remaining photo; `flap` = lifted triangle.
*/
function cornerPeelPaths(
id: Corner,
w: number,
h: number,
cx: number,
cy: number,
ux: number,
uy: number,
size: number
): {
keep: string;
flap: string;
origin: string;
axisX: number;
axisY: number;
hingeSign: number;
} {
const eps = 0.0001;
// Fold line through the midpoint on corner→cursor, ⊥ to that ray
const px = cx + ux * size;
const py = cy + uy * size;
let ax = 0;
let ay = 0;
let bx = 0;
let by = 0;
if (id === 'tr') {
ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w);
ay = 0;
bx = w;
by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h);
} else if (id === 'tl') {
ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w);
ay = 0;
bx = 0;
by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h);
} else if (id === 'br') {
ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w);
ay = h;
bx = w;
by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h);
} else {
ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w);
ay = h;
bx = 0;
by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h);
}
let keep: string;
if (id === 'tr') {
keep = `polygon(0 0, ${ax}px 0, ${w}px ${by}px, ${w}px ${h}px, 0 ${h}px)`;
} else if (id === 'tl') {
keep = `polygon(${ax}px 0, ${w}px 0, ${w}px ${h}px, 0 ${h}px, 0 ${by}px)`;
} else if (id === 'br') {
keep = `polygon(0 0, ${w}px 0, ${w}px ${by}px, ${ax}px ${h}px, 0 ${h}px)`;
} else {
keep = `polygon(0 0, ${w}px 0, ${w}px ${h}px, ${ax}px ${h}px, 0 ${by}px)`;
}
const flap = `polygon(${cx}px ${cy}px, ${ax}px ${ay}px, ${bx}px ${by}px)`;
// Hinge on the fold (midpoint), axis along the fold edge
const midX = (ax + bx) / 2;
const midY = (ay + by) / 2;
const fdx = bx - ax;
const fdy = by - ay;
const flen = Math.hypot(fdx, fdy) || 1;
const axisX = fdx / flen;
const axisY = fdy / flen;
// Sign so the corner swings up over the fold (right-hand rule)
const cross = fdx * (cy - midY) - fdy * (cx - midX);
const hingeSign = cross >= 0 ? 1 : -1;
return {
keep,
flap,
origin: `${midX}px ${midY}px`,
axisX,
axisY,
hingeSign,
};
}
/**
* Stationery-theme image chrome: clear corner tape + a mouse-driven peel
* that shows a duplicated slice of the photo lifting toward the cursor.
*/
export function StationeryMedia({
children,
tiltSeed = 'stationery',
gridPad = 0,
style,
...attachmentProps
}: StationeryMediaProps) {
const theme = useTheme();
const isStationery = isStationeryTheme(theme);
const peelRef = useRef<HTMLSpanElement>(null);
const tiltStyle = useMemo((): CSSProperties => {
return {
...(style as CSSProperties | undefined),
// Margin sits outside the paper chrome — snaps the grid without a white bar
...(isStationery && gridPad > 0 ? { marginBottom: `${gridPad}px` } : null),
['--media-rot' as string]: stationeryMediaRot(tiltSeed),
};
}, [tiltSeed, style, isStationery, gridPad]);
const tapeAlt = useMemo(() => hashStationerySeed(tiltSeed) % 2 === 1, [tiltSeed]);
// Default tape covers TL; alt covers TR — peel only the free top corner
const peelCorner = useMemo(
() => (tapeAlt ? CORNERS.find((c) => c.id === 'tl')! : CORNERS.find((c) => c.id === 'tr')!),
[tapeAlt]
);
const clearPeel = useCallback((host: HTMLElement) => {
const peel = peelRef.current;
const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null;
if (peel) {
// Hide flap first (no opacity tween) so restoring the photo can't double-draw
peel.style.transition = 'none';
peel.style.opacity = '0';
peel.style.setProperty('--peel-hinge', '0deg');
peel.style.clipPath = '';
}
if (target) target.style.clipPath = '';
}, []);
const syncPeel = useCallback(
(host: HTMLElement, clientX: number, clientY: number) => {
const peel = peelRef.current;
if (!peel) return;
const rect = host.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const w = rect.width;
const h = rect.height;
const best = peelCorner;
const cx = best.x * w;
const cy = best.y * h;
const dist = Math.hypot(cx - x, cy - y);
const maxReach = Math.min(w, h) * 0.55;
// Fold halfway between corner and cursor; fade when leaving the corner zone
let size = 0;
let amount = 0;
if (dist > 8 && dist <= maxReach) {
size = dist * 0.5;
amount = Math.min(1, size / (Math.min(w, h) * 0.22));
} else if (dist > maxReach && dist < maxReach * 1.35) {
const fade = 1 - (dist - maxReach) / (maxReach * 0.35);
size = maxReach * 0.5 * Math.max(0, fade);
amount = Math.max(0, fade);
}
const img = host.querySelector('img');
const url = img?.currentSrc || img?.src || '';
// Unit vector from corner → cursor (triangle aims this way)
let ux = x - cx;
let uy = y - cy;
if (best.id === 'tr') {
ux = Math.min(-0.05, ux);
uy = Math.max(0.05, uy);
} else {
ux = Math.max(0.05, ux);
uy = Math.max(0.05, uy);
}
const len = Math.hypot(ux, uy) || 1;
ux /= len;
uy /= len;
peel.dataset.corner = best.id;
if (url) {
peel.style.setProperty('--peel-img', `url(${JSON.stringify(url)})`);
}
const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null;
if (target && amount > 0.06 && size > 6) {
const { keep, flap, origin, axisX, axisY, hingeSign } = cornerPeelPaths(
best.id,
w,
h,
cx,
cy,
ux,
uy,
size
);
target.style.clipPath = keep;
peel.style.clipPath = flap;
peel.style.transformOrigin = origin;
peel.style.setProperty('--peel-axis-x', String(axisX));
peel.style.setProperty('--peel-axis-y', String(axisY));
peel.style.setProperty('--peel-hinge', `${hingeSign * amount * 52}deg`);
peel.style.setProperty('--peel-tip-x', `${cx}px`);
peel.style.setProperty('--peel-tip-y', `${cy}px`);
peel.style.setProperty('--peel-grad-r', `${Math.max(size * 1.15, 12)}px`);
peel.style.opacity = '1';
} else {
clearPeel(host);
}
},
[peelCorner, clearPeel]
);
const handleMove: MouseEventHandler<HTMLElement> = (evt) => {
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
};
const handleEnter: MouseEventHandler<HTMLElement> = (evt) => {
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
};
const handleLeave: MouseEventHandler<HTMLElement> = (evt) => {
clearPeel(evt.currentTarget);
};
if (!isStationery) {
return (
<Attachment style={style} {...attachmentProps}>
{children}
</Attachment>
);
}
return (
<Attachment
{...attachmentProps}
style={tiltStyle}
data-stationery-media=""
data-tape-alt={tapeAlt ? '' : undefined}
onMouseEnter={handleEnter}
onMouseMove={handleMove}
onMouseLeave={handleLeave}
>
<span ref={peelRef} data-stationery-peel="" aria-hidden="true" />
{children}
</Attachment>
);
}

View File

@@ -2,7 +2,7 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons'; import { Icon, Icons } from '../../icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash'; import { Blurhash } from 'react-blurhash';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common'; import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
@@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css'; import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash'; import { validBlurHash } from '../../../utils/blurHash';
import { getCurrentAccessToken } from '../../../utils/auth'; import { getCurrentAccessToken } from '../../../utils/auth';
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
/** /**
* Fetches media with authentication headers and returns a blob URL. * Fetches media with authentication headers and returns a blob URL.
@@ -68,7 +69,7 @@ type RenderImageProps = {
alt: string; alt: string;
title: string; title: string;
src: string; src: string;
onLoad: () => void; onLoad: (event?: React.SyntheticEvent<HTMLImageElement>) => void;
onError: () => void; onError: () => void;
onClick: () => void; onClick: () => void;
tabIndex: number; tabIndex: number;
@@ -105,12 +106,23 @@ export const ImageContent = as<'div', ImageContentProps>(
) => { ) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const eventBlurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const cachedBlurHash = getMediaBlurHash(url);
const blurHash = eventBlurHash ?? cachedBlurHash;
const cachedDims = getMediaDimensions(url);
const blurResolutionX = 32;
const ratioW = info?.w || cachedDims?.w;
const ratioH = info?.h || cachedDims?.h;
const blurResolutionY =
ratioW && ratioH && ratioW > 0
? Math.max(1, Math.round(blurResolutionX * (ratioH / ratioW)))
: 32;
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false); const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [showPlaceholder, setShowPlaceholder] = useState(true);
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -139,6 +151,7 @@ export const ImageContent = as<'div', ImageContentProps>(
const handleError = () => { const handleError = () => {
setLoad(false); setLoad(false);
setError(true); setError(true);
setShowPlaceholder(true);
}; };
const handleRetry = () => { const handleRetry = () => {
@@ -150,8 +163,34 @@ export const ImageContent = as<'div', ImageContentProps>(
if (autoPlay) loadSrc(); if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]); }, [autoPlay, loadSrc]);
// Keep blurhash under the fade, then remove it once the image is fully opaque.
useEffect(() => {
if (!load) {
setShowPlaceholder(true);
return undefined;
}
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
return () => window.clearTimeout(timer);
}, [load]);
return ( return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}> <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
{showPlaceholder &&
(typeof blurHash === 'string' ? (
<div className={css.BlurhashPlaceholder}>
<Blurhash
hash={blurHash}
width="100%"
height="100%"
resolutionX={blurResolutionX}
resolutionY={blurResolutionY}
punch={1.2}
style={{ display: 'block' }}
/>
</div>
) : (
<div className={css.MediaSkeleton} />
))}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}> <Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter> <OverlayCenter>
@@ -177,15 +216,6 @@ export const ImageContent = as<'div', ImageContentProps>(
</OverlayCenter> </OverlayCenter>
</Overlay> </Overlay>
)} )}
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
width={32}
height={32}
hash={blurHash}
punch={1}
/>
)}
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && ( {!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button <Button
@@ -201,12 +231,26 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box> </Box>
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}> <Box
className={classNames(
css.AbsoluteContainer,
css.MediaFadeIn,
load && css.MediaFadeInLoaded,
blurred && css.Blur
)}
>
{renderImage({ {renderImage({
alt: body, alt: body,
title: body, title: body,
src: srcState.data, src: srcState.data,
onLoad: handleLoad, onLoad: (e?: React.SyntheticEvent<HTMLImageElement>) => {
const img = e?.currentTarget;
if (img?.naturalWidth && img?.naturalHeight) {
setMediaDimensions(url, img.naturalWidth, img.naturalHeight);
}
if (img) rememberMediaBlurHash(url, img);
handleLoad();
},
onError: handleError, onError: handleError,
onClick: () => setViewer(true), onClick: () => setViewer(true),
tabIndex: 0, tabIndex: 0,
@@ -248,7 +292,8 @@ export const ImageContent = as<'div', ImageContentProps>(
)} )}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && !load &&
!blurred && ( !blurred &&
typeof blurHash !== 'string' && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" /> <Spinner variant="Secondary" />
</Box> </Box>

View File

@@ -2,7 +2,7 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons'; import { Icon, Icons } from '../../icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash'; import { Blurhash } from 'react-blurhash';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { import {
@@ -25,6 +25,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash'; import { validBlurHash } from '../../../utils/blurHash';
import { ModalWide } from '../../../styles/Modal.css'; import { ModalWide } from '../../../styles/Modal.css';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
type RenderViewerProps = { type RenderViewerProps = {
src: string; src: string;
@@ -34,7 +35,7 @@ type RenderViewerProps = {
type RenderVideoProps = { type RenderVideoProps = {
title: string; title: string;
src: string; src: string;
onLoadedMetadata: () => void; onLoadedMetadata: (event?: React.SyntheticEvent<HTMLVideoElement>) => void;
onError: () => void; onError: () => void;
autoPlay: boolean; autoPlay: boolean;
controls: boolean; controls: boolean;
@@ -74,12 +75,23 @@ export const VideoContent = as<'div', VideoContentProps>(
) => { ) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const eventBlurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const cachedBlurHash = getMediaBlurHash(url);
const blurHash = eventBlurHash ?? cachedBlurHash;
const cachedDims = getMediaDimensions(url);
const blurResolutionX = 32;
const blurW = info.w ?? info.thumbnail_info?.w ?? cachedDims?.w;
const blurH = info.h ?? info.thumbnail_info?.h ?? cachedDims?.h;
const blurResolutionY =
blurW && blurH && blurW > 0
? Math.max(1, Math.round(blurResolutionX * (blurH / blurW)))
: 32;
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false); const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [showPlaceholder, setShowPlaceholder] = useState(true);
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -95,12 +107,17 @@ export const VideoContent = as<'div', VideoContentProps>(
}, [mx, url, useAuthentication, mimeType, encInfo]) }, [mx, url, useAuthentication, mimeType, encInfo])
); );
const handleLoad = () => { const handleLoad = (video?: HTMLVideoElement) => {
if (video?.videoWidth && video?.videoHeight) {
setMediaDimensions(url, video.videoWidth, video.videoHeight);
}
if (video) rememberMediaBlurHash(url, video);
setLoad(true); setLoad(true);
}; };
const handleError = () => { const handleError = () => {
setLoad(false); setLoad(false);
setError(true); setError(true);
setShowPlaceholder(true);
}; };
const handleRetry = () => { const handleRetry = () => {
@@ -112,8 +129,33 @@ export const VideoContent = as<'div', VideoContentProps>(
if (autoPlay) loadSrc(); if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]); }, [autoPlay, loadSrc]);
useEffect(() => {
if (!load) {
setShowPlaceholder(true);
return undefined;
}
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
return () => window.clearTimeout(timer);
}, [load]);
return ( return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}> <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
{showPlaceholder &&
(typeof blurHash === 'string' ? (
<div className={css.BlurhashPlaceholder}>
<Blurhash
hash={blurHash}
width="100%"
height="100%"
resolutionX={blurResolutionX}
resolutionY={blurResolutionY}
punch={1.2}
style={{ display: 'block' }}
/>
</div>
) : (
<div className={css.MediaSkeleton} />
))}
{renderViewer && srcState.status === AsyncStatus.Success && ( {renderViewer && srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}> <Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter> <OverlayCenter>
@@ -136,15 +178,6 @@ export const VideoContent = as<'div', VideoContentProps>(
</OverlayCenter> </OverlayCenter>
</Overlay> </Overlay>
)} )}
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
width={32}
height={32}
hash={blurHash}
punch={1}
/>
)}
{renderThumbnail && !load && ( {renderThumbnail && !load && (
<Box <Box
className={classNames(css.AbsoluteContainer, blurred && css.Blur)} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
@@ -169,11 +202,20 @@ export const VideoContent = as<'div', VideoContentProps>(
</Box> </Box>
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}> <Box
className={classNames(
css.AbsoluteContainer,
css.MediaFadeIn,
load && css.MediaFadeInLoaded,
blurred && css.Blur
)}
>
{renderVideo({ {renderVideo({
title: body, title: body,
src: srcState.data, src: srcState.data,
onLoadedMetadata: handleLoad, onLoadedMetadata: (e?: React.SyntheticEvent<HTMLVideoElement>) => {
handleLoad(e?.currentTarget);
},
onError: handleError, onError: handleError,
autoPlay: true, autoPlay: true,
controls: true, controls: true,
@@ -213,7 +255,8 @@ export const VideoContent = as<'div', VideoContentProps>(
)} )}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && !load &&
!blurred && ( !blurred &&
typeof blurHash !== 'string' && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" /> <Spinner variant="Secondary" />
</Box> </Box>

View File

@@ -1,11 +1,13 @@
import { style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds'; import { DefaultReset, color, config } from 'folds';
export const RelativeBase = style([ export const RelativeBase = style([
DefaultReset, DefaultReset,
{ {
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
width: '100%',
height: '100%',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
borderRadius: '7px', borderRadius: '7px',
@@ -16,9 +18,13 @@ export const RelativeBase = style([
export const AbsoluteContainer = style([ export const AbsoluteContainer = style([
DefaultReset, DefaultReset,
{ {
position: 'absolute',
inset: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
width: '100%',
height: '100%',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
borderRadius: 'inherit', borderRadius: 'inherit',
@@ -34,6 +40,7 @@ export const AbsoluteFooter = style([
bottom: config.space.S100, bottom: config.space.S100,
left: config.space.S100, left: config.space.S100,
right: config.space.S100, right: config.space.S100,
zIndex: 1,
}, },
]); ]);
@@ -43,3 +50,67 @@ export const Blur = style([
filter: 'blur(44px)', filter: 'blur(44px)',
}, },
]); ]);
const shimmer = keyframes({
'0%': {
backgroundPosition: '200% 0',
},
'100%': {
backgroundPosition: '-200% 0',
},
});
/** Soft fallback when no blurhash is available. */
export const MediaSkeleton = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
backgroundImage: `linear-gradient(
90deg,
${color.SurfaceVariant.Container} 0%,
${color.SurfaceVariant.ContainerHover} 40%,
${color.SurfaceVariant.Container} 80%
)`,
backgroundSize: '200% 100%',
animation: `${shimmer} 1.4s ease-in-out infinite`,
},
]);
const blurhashFadeIn = keyframes({
from: {
opacity: 0,
},
to: {
opacity: 1,
},
});
/** Soft blurhash layer that fills the reserved media box. */
export const BlurhashPlaceholder = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
overflow: 'hidden',
borderRadius: 'inherit',
zIndex: 0,
// Soften the decode so it reads as color blobs; slight scale hides blur edges.
filter: 'blur(14px)',
transform: 'scale(1.06)',
opacity: 0,
animation: `${blurhashFadeIn} 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards`,
},
]);
/** Media starts invisible and fades in once loaded over the placeholder. */
export const MediaFadeIn = style({
zIndex: 1,
opacity: 0,
transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)',
});
export const MediaFadeInLoaded = style({
opacity: 1,
});

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React, { CSSProperties, useMemo } from 'react';
import { Text, as } from 'folds'; import { Text, as } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import * as css from './layout.css'; import * as css from './layout.css';
import { isStationeryTheme, useTheme } from '../../../hooks/useTheme';
import { STATIONERY_NAME_INK } from '../../../utils/paperSafeInk';
export const MessageBase = as<'div', css.MessageBaseVariants>( export const MessageBase = as<'div', css.MessageBaseVariants>(
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => ( ({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
@@ -10,6 +12,7 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }), css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
className className
)} )}
data-message-collapsed={collapse ? '' : undefined}
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -17,12 +20,45 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
); );
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => ( export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
<span className={classNames(css.AvatarBase, className)} {...props} ref={ref} /> <span
className={classNames(css.AvatarBase, className)}
data-message-avatar=""
{...props}
ref={ref}
/>
)); ));
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => ( export const Username = as<'span'>(({ as: AsUsername = 'span', className, style, ...props }, ref) => {
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} /> const theme = useTheme();
)); const isStationery = isStationeryTheme(theme);
const userColor =
typeof (style as CSSProperties | undefined)?.color === 'string'
? ((style as CSSProperties).color as string)
: undefined;
const safeStyle = useMemo((): CSSProperties | undefined => {
if (!isStationery) return style as CSSProperties | undefined;
return {
...(style as CSSProperties | undefined),
// Name in ink; keep their color as an accent underline
color: STATIONERY_NAME_INK,
...(userColor
? { ['--username-accent' as string]: userColor, textDecorationColor: userColor }
: null),
};
}, [style, isStationery, userColor]);
return (
<AsUsername
className={classNames(css.Username, className)}
data-message-username=""
data-username-accent={isStationery && userColor ? '' : undefined}
style={safeStyle}
{...props}
ref={ref}
/>
);
});
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => ( export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} /> <AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
@@ -32,10 +68,12 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => ( ({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
<Text <Text
as={asComp} as={asComp}
size="T400" size={jumboEmoji ? 'Inherit' : 'T400'}
priority={notice ? '300' : '400'} priority={notice ? '300' : '400'}
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)} className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
data-allow-text-selection="true" data-allow-text-selection="true"
data-message-body=""
data-jumbo-emoji={jumboEmoji ? '' : undefined}
{...props} {...props}
ref={ref} ref={ref}
/> />

View File

@@ -1,4 +1,5 @@
import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
import * as htmlCss from '../../../styles/CustomHtml.css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
@@ -117,6 +118,7 @@ export const MessageBase = recipe({
collapse: { collapse: {
true: { true: {
marginTop: 0, marginTop: 0,
paddingTop: config.space.S0,
}, },
}, },
autoCollapse: { autoCollapse: {
@@ -202,6 +204,14 @@ export const Username = style({
'button&:hover, button&:focus-visible': { 'button&:hover, button&:focus-visible': {
textDecoration: 'underline', textDecoration: 'underline',
}, },
// Stationery uses a colored border-bottom accent instead
'html.stationery &, body.stationery &': {
overflow: 'visible',
overflowY: 'visible',
},
'body.stationery button&:hover, body.stationery button&:focus-visible': {
textDecoration: 'none',
},
}, },
}); });
@@ -209,6 +219,8 @@ export const UsernameBold = style({
fontWeight: 550, fontWeight: 550,
}); });
const jumboEmojiSize = toRem(70);
export const MessageTextBody = recipe({ export const MessageTextBody = recipe({
base: { base: {
wordBreak: 'break-word', wordBreak: 'break-word',
@@ -226,8 +238,11 @@ export const MessageTextBody = recipe({
}, },
jumboEmoji: { jumboEmoji: {
true: { true: {
fontSize: '1.504em', fontSize: jumboEmojiSize,
lineHeight: '1.4962em', lineHeight: 1,
overflow: 'visible',
overflowY: 'visible',
paddingBottom: config.space.S200,
}, },
}, },
emote: { emote: {
@@ -240,3 +255,29 @@ export const MessageTextBody = recipe({
}); });
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>; export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
const jumboEmojiClass = MessageTextBody.classNames.variants.jumboEmoji.true;
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
height: '1em',
padding: 0,
overflow: 'visible',
verticalAlign: 'middle',
});
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
fontSize: '1em',
height: '1em',
minWidth: '1em',
lineHeight: 1,
position: 'static',
top: 0,
overflow: 'visible',
});
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonImg}`, {
height: '1em',
width: '1em',
maxHeight: 'none',
objectFit: 'contain',
});

View File

@@ -7,5 +7,10 @@ type NavCategoryProps = {
children: ReactNode; children: ReactNode;
}; };
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => ( export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
<div className={classNames(css.NavCategory, className)} {...props} ref={ref} /> <div
className={classNames(css.NavCategory, className)}
data-nav-category=""
{...props}
ref={ref}
/>
)); ));

View File

@@ -13,6 +13,7 @@ export const NavItem = as<
<AsNavItem <AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)} className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight} data-highlight={highlight}
data-nav-item=""
{...props} {...props}
ref={ref} ref={ref}
> >

View File

@@ -6,6 +6,7 @@ import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel'; import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
import { useShowCompactMasterView } from '../../hooks/useCompactNav'; import { useShowCompactMasterView } from '../../hooks/useCompactNav';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
type PageRootProps = { type PageRootProps = {
nav: ReactNode; nav: ReactNode;
@@ -43,6 +44,7 @@ export function PageNav({
grow={isMobile ? 'Yes' : undefined} grow={isMobile ? 'Yes' : undefined}
className={classNames(css.PageNav({ size }), className)} className={classNames(css.PageNav({ size }), className)}
shrink={isMobile ? 'Yes' : 'No'} shrink={isMobile ? 'Yes' : 'No'}
data-page-nav=""
> >
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
{children} {children}
@@ -58,6 +60,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
className={classNames(css.PageNavHeader({ outlined }), className)} className={classNames(css.PageNavHeader({ outlined }), className)}
variant="Background" variant="Background"
size="600" size="600"
data-folder-tab="nav"
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -73,13 +76,16 @@ export function PageNavContent({
scrollRef?: MutableRefObject<HTMLDivElement | null>; scrollRef?: MutableRefObject<HTMLDivElement | null>;
scrollProps?: React.ComponentProps<typeof Scroll>; scrollProps?: React.ComponentProps<typeof Scroll>;
}) { }) {
const theme = useTheme();
const hideScrollbar = isStationeryTheme(theme);
return ( return (
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Scroll <Scroll
ref={scrollRef} ref={scrollRef}
variant="Background" variant="Background"
direction="Vertical" direction="Vertical"
size="300" size={hideScrollbar ? '0' : '300'}
hideTrack hideTrack
visibility="Hover" visibility="Hover"
{...scrollProps} {...scrollProps}
@@ -95,6 +101,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
grow="Yes" grow="Yes"
direction="Column" direction="Column"
className={classNames(ContainerColor({ variant: 'Surface' }), className)} className={classNames(ContainerColor({ variant: 'Surface' }), className)}
data-page=""
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -106,6 +113,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
as="header" as="header"
size="600" size="600"
className={classNames(css.PageHeader({ balance, outlined }), className)} className={classNames(css.PageHeader({ balance, outlined }), className)}
data-folder-tab-bar=""
{...props} {...props}
ref={ref} ref={ref}
/> />

View File

@@ -1,13 +1,23 @@
import { JoinRule } from 'matrix-js-sdk'; import { JoinRule } from 'matrix-js-sdk';
import { AvatarFallback, AvatarImage, color } from 'folds'; import { AvatarFallback, AvatarImage, color } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useEffect, useState } from 'react';
import { Blurhash } from 'react-blurhash';
import * as css from './RoomAvatar.css'; import * as css from './RoomAvatar.css';
import { joinRuleToIconSrc } from '../../utils/room'; import { joinRuleToIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl'; import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache'; import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
import {
getCachedAuthenticatedMediaUrl,
isAuthenticatedMediaUrl,
} from '../../utils/authenticatedMediaCache';
import {
getMediaBlurHash,
getMediaDimensions,
rememberMediaBlurHash,
} from '../../state/mediaDimensionCache';
type RoomAvatarProps = { type RoomAvatarProps = {
roomId: string; roomId: string;
@@ -17,19 +27,36 @@ type RoomAvatarProps = {
}; };
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) { export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication); const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
const blurHash = getMediaBlurHash(src ?? '');
const dims = getMediaDimensions(src ?? '');
const blobCached =
!!src &&
(!useAuthentication ||
!isAuthenticatedMediaUrl(src) ||
!!getCachedAuthenticatedMediaUrl(src));
const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
useEffect(() => {
if (
authenticatedSrc &&
(isAvatarCached(src) ||
(src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
) {
setLoaded(true);
}
}, [authenticatedSrc, src, useAuthentication]);
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => { const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true'); evt.currentTarget.setAttribute('data-image-loaded', 'true');
setLoaded(true); setLoaded(true);
cacheAvatar(src); cacheAvatar(src);
pruneAvatarCache(); pruneAvatarCache();
rememberMediaBlurHash(src ?? '', evt.currentTarget);
}; };
// No src or error - show fallback only if (!src || error) {
if (!authenticatedSrc || error) {
return ( return (
<AvatarFallback <AvatarFallback
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }} style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
@@ -40,8 +67,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
); );
} }
// If already cached, show image directly without fallback flash if (authenticatedSrc && loaded) {
if (loaded) {
return ( return (
<AvatarImage <AvatarImage
className={css.RoomAvatar} className={css.RoomAvatar}
@@ -55,12 +81,17 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
); );
} }
// Loading state - render fallback with image overlay const blurResolutionX = 32;
const blurResolutionY =
dims?.w && dims?.h && dims.w > 0
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
: 32;
return ( return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}> <div style={{ position: 'relative', width: '100%', height: '100%' }}>
<AvatarFallback <AvatarFallback
style={{ style={{
backgroundColor: colorMXID(roomId ?? ''), backgroundColor: colorMXID(roomId ?? ''),
color: color.Surface.Container, color: color.Surface.Container,
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
@@ -69,15 +100,39 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
> >
{renderFallback()} {renderFallback()}
</AvatarFallback> </AvatarFallback>
<AvatarImage {blurHash && (
className={css.RoomAvatar} <div
style={{ position: 'relative', zIndex: 1 }} style={{
src={authenticatedSrc} position: 'absolute',
alt={alt} inset: 0,
onError={() => setError(true)} overflow: 'hidden',
onLoad={handleLoad} borderRadius: 'inherit',
draggable={false} filter: 'blur(6px)',
/> transform: 'scale(1.08)',
}}
>
<Blurhash
hash={blurHash}
width="100%"
height="100%"
resolutionX={blurResolutionX}
resolutionY={blurResolutionY}
punch={1.1}
style={{ display: 'block' }}
/>
</div>
)}
{authenticatedSrc && (
<AvatarImage
className={css.RoomAvatar}
style={{ position: 'relative', zIndex: 1, opacity: loaded ? 1 : 0 }}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
)}
</div> </div>
); );
} }

View File

@@ -4,5 +4,10 @@ import React from 'react';
import * as css from './Sidebar.css'; import * as css from './Sidebar.css';
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => ( export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} /> <AsSidebar
className={classNames(css.Sidebar, className)}
data-sidebar=""
{...props}
ref={ref}
/>
)); ));

View File

@@ -8,10 +8,10 @@ type SidebarContentProps = {
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) { export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
return ( return (
<> <>
<Box direction="Column" grow="Yes"> <Box direction="Column" grow="Yes" data-sidebar-scroll-region="" style={{ overflow: 'visible', minWidth: 0 }}>
{scrollable} {scrollable}
</Box> </Box>
<Box direction="Column" shrink="No"> <Box direction="Column" shrink="No" data-sidebar-sticky="" style={{ overflow: 'visible' }}>
{sticky} {sticky}
</Box> </Box>
</> </>

View File

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

View File

@@ -1,95 +1,149 @@
import { style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
export const CheckButtonContainer = style({ const indeterminate = keyframes({
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(250%)' },
});
export const IdleSlot = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: '32px', height: '100%',
width: '32px', WebkitAppRegion: 'no-drag',
flexShrink: 0,
}); });
export const CheckButton = style({ export const GhostCheck = style({
all: 'unset', all: 'unset',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 0, width: toRem(28),
height: toRem(28),
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
cursor: 'pointer', color: color.Surface.OnContainer,
color: color.Secondary.Main,
backgroundColor: 'transparent',
transition: 'opacity 0.2s, background-color 0.15s',
height: '32px',
width: '32px',
opacity: 0, opacity: 0,
WebkitAppRegion: 'no-drag', cursor: 'pointer',
flexShrink: 0, transition: 'opacity 0.15s ease, background-color 0.15s ease',
selectors: { selectors: {
'&[data-visible="true"]': { '&[data-visible="true"]': {
opacity: 0.7, opacity: 0.65,
},
'&:hover': {
opacity: 1,
backgroundColor: color.Surface.ContainerHover,
}, },
}, },
':hover': {
backgroundColor: color.Surface.ContainerHover,
opacity: 1,
},
':active': {
backgroundColor: color.Surface.ContainerActive,
},
':focus-visible': {
outline: `2px solid ${color.Secondary.Main}`,
outlineOffset: '2px',
opacity: 1,
},
}); });
export const UpdateButton = style({ export const Bar = style({
all: 'unset',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: config.space.S200,
padding: 0, height: toRem(22),
minWidth: toRem(120),
maxWidth: toRem(200),
padding: `0 ${config.space.S200}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
cursor: 'pointer', backgroundColor: color.SurfaceVariant.Container,
color: color.Success.Main, color: color.SurfaceVariant.OnContainer,
backgroundColor: 'transparent',
transition: 'background-color 0.15s',
height: '32px',
width: '32px',
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',
flexShrink: 0, flexShrink: 0,
boxSizing: 'border-box',
});
':hover': { export const BarTrack = style({
backgroundColor: color.Surface.ContainerHover, position: 'relative',
}, flex: 1,
height: toRem(4),
borderRadius: toRem(2),
overflow: 'hidden',
backgroundColor: color.Surface.ContainerLine,
minWidth: toRem(64),
});
':active': { export const BarFill = style({
backgroundColor: color.Surface.ContainerActive, position: 'absolute',
}, inset: '0 auto 0 0',
height: '100%',
borderRadius: 'inherit',
backgroundColor: color.Success.Main,
transition: 'width 120ms linear',
});
':focus-visible': { export const BarIndeterminate = style({
outline: `2px solid ${color.Success.Main}`, position: 'absolute',
outlineOffset: '2px', top: 0,
bottom: 0,
left: 0,
width: '40%',
borderRadius: 'inherit',
backgroundColor: color.Secondary.Main,
animation: `${indeterminate} 1s ease-in-out infinite`,
});
export const BarLabel = style({
flexShrink: 0,
fontVariantNumeric: 'tabular-nums',
opacity: 0.9,
minWidth: toRem(28),
textAlign: 'right',
});
export const Chip = style({
height: toRem(22),
maxWidth: toRem(260),
padding: `0 ${config.space.S100} 0 ${config.space.S200}`,
borderRadius: config.radii.R300,
backgroundColor: color.Success.Container,
color: color.Success.OnContainer,
WebkitAppRegion: 'no-drag',
flexShrink: 0,
boxSizing: 'border-box',
});
export const ChipText = style({
maxWidth: toRem(140),
});
export const ChipAction = style({
all: 'unset',
display: 'inline-flex',
alignItems: 'center',
height: toRem(18),
padding: `0 ${config.space.S200}`,
borderRadius: config.radii.R300,
backgroundColor: color.Success.Main,
color: color.Success.OnMain,
fontSize: toRem(11),
fontWeight: 600,
cursor: 'pointer',
whiteSpace: 'nowrap',
selectors: {
'&:hover': {
filter: 'brightness(1.05)',
},
}, },
}); });
export const UpdateMenu = style({ export const ChipDismiss = style({
minWidth: toRem(280), all: 'unset',
maxWidth: toRem(320), display: 'inline-flex',
backgroundColor: color.Surface.Container, alignItems: 'center',
borderRadius: config.radii.R400, justifyContent: 'center',
boxShadow: config.shadow.E400, width: toRem(18),
border: `1px solid ${color.Surface.ContainerLine}`, height: toRem(18),
}); borderRadius: config.radii.R300,
cursor: 'pointer',
export const ProgressText = style({ opacity: 0.7,
color: color.Success.Main, fontSize: toRem(14),
fontWeight: 500, lineHeight: 1,
minWidth: toRem(35), selectors: {
textAlign: 'center', '&:hover': {
opacity: 1,
backgroundColor: 'rgba(0,0,0,0.08)',
},
},
}); });

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ScrollTopContainer } from '../../components/scroll-top-container'; import { ScrollTopContainer } from '../../components/scroll-top-container';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils'; import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
import { useRooms } from '../../state/hooks/roomList'; import { useRooms, useDirects } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { MessageSearchParams, useMessageSearch } from './useMessageSearch'; import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
@@ -53,7 +53,13 @@ export function MessageSearch({
}: MessageSearchProps) { }: MessageSearchProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const allRooms = useRooms(mx, allRoomsAtom, mDirects); const nonDirectRooms = useRooms(mx, allRoomsAtom, mDirects);
const directRooms = useDirects(mx, allRoomsAtom, mDirects);
// Include DMs — previously useRooms alone stripped them and broke DM search
const allRooms = useMemo(
() => [...nonDirectRooms, ...directRooms],
[nonDirectRooms, directRooms]
);
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');

View File

@@ -4,6 +4,9 @@ import {
ISearchRequestBody, ISearchRequestBody,
ISearchResponse, ISearchResponse,
ISearchResult, ISearchResult,
MatrixClient,
MatrixEvent,
Room,
SearchOrderBy, SearchOrderBy,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { useCallback } from 'react'; import { useCallback } from 'react';
@@ -26,6 +29,12 @@ export type SearchResult = {
groups: ResultGroup[]; groups: ResultGroup[];
}; };
const EMPTY_CONTEXT: IResultContext = {
events_before: [],
events_after: [],
profile_info: {},
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const parseSearchResult = (result: ISearchResponse): SearchResult => { const parseSearchResult = (result: ISearchResponse): SearchResult => {
const roomEvents = result.search_categories.room_events; const roomEvents = result.search_categories.room_events;
const searchResult: SearchResult = { return {
nextToken: roomEvents?.next_batch, nextToken: roomEvents?.next_batch,
highlights: roomEvents?.highlights ?? [], highlights: roomEvents?.highlights ?? [],
groups: groupSearchResult(roomEvents?.results ?? []), groups: groupSearchResult(roomEvents?.results ?? []),
}; };
};
return searchResult; const eventToSearchEvent = (event: MatrixEvent, roomId: string): IEventWithRoomId | undefined => {
const eventId = event.getId();
if (!eventId) return undefined;
const content = event.getClearContent() ?? event.getContent();
return {
event_id: eventId,
type: event.getWireType() === 'm.room.encrypted' ? 'm.room.message' : event.getType(),
sender: event.getSender() ?? '',
origin_server_ts: event.getTs(),
content,
room_id: roomId,
unsigned: event.getUnsigned(),
};
};
const getSearchableBody = (event: MatrixEvent): string | undefined => {
if (event.isRedacted()) return undefined;
// After decryption, clear content is available even if wire type was encrypted
const content = event.getClearContent() ?? event.getContent();
if (!content || typeof content !== 'object') return undefined;
// Skip still-encrypted payloads
if (event.isEncrypted() && !event.isDecryptionFailure() && !event.getClearContent()) {
return undefined;
}
const msgType = content.msgtype;
if (msgType && msgType !== 'm.text' && msgType !== 'm.notice' && msgType !== 'm.emote') {
// Still allow filename / body on media
}
const body = typeof content.body === 'string' ? content.body : undefined;
const formatted =
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
return [body, formatted].filter(Boolean).join('\n') || undefined;
};
const highlightsFromTerm = (term: string): string[] =>
term
.split(/\s+/)
.map((part) => part.trim())
.filter((part) => part.length > 1);
/**
* Search decrypted timeline events already loaded for a room.
* Server-side search cannot match E2EE message bodies.
*/
const searchLocalRoomTimeline = (room: Room, term: string): ResultItem[] => {
const needle = term.trim().toLowerCase();
if (!needle) return [];
const events = room.getLiveTimeline().getEvents();
const matches: ResultItem[] = [];
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i];
const haystack = getSearchableBody(event);
if (!haystack || !haystack.toLowerCase().includes(needle)) continue;
const searchEvent = eventToSearchEvent(event, room.roomId);
if (!searchEvent) continue;
matches.push({
rank: 1,
event: searchEvent,
context: EMPTY_CONTEXT,
});
}
return matches;
};
const isEncryptedRoom = (mx: MatrixClient, roomId: string): boolean => {
const room = mx.getRoom(roomId);
return !!room?.hasEncryptionStateEvent();
};
const searchLocalRooms = (mx: MatrixClient, roomIds: string[], term: string): SearchResult => {
const groups: ResultGroup[] = [];
roomIds.forEach((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return;
const items = searchLocalRoomTimeline(room, term);
if (items.length > 0) {
groups.push({ roomId, items });
}
});
return {
highlights: highlightsFromTerm(term),
groups,
};
}; };
export type MessageSearchParams = { export type MessageSearchParams = {
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
rooms?: string[]; rooms?: string[];
senders?: string[]; senders?: string[];
}; };
export const useMessageSearch = (params: MessageSearchParams) => { export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { term, order, rooms, senders } = params; const { term, order, rooms, senders } = params;
const searchMessages = useCallback( const searchMessages = useCallback(
async (nextBatch?: string) => { async (nextBatch?: string) => {
if (!term) if (!term) {
return { return {
highlights: [], highlights: [],
groups: [], groups: [],
}; };
const limit = 20; }
const scopedRooms = rooms?.filter(Boolean) ?? [];
const encryptedScoped =
scopedRooms.length > 0 && scopedRooms.every((roomId) => isEncryptedRoom(mx, roomId));
// Encrypted-only scope: server cannot see bodies — search loaded timeline locally
if (encryptedScoped && !nextBatch) {
return searchLocalRooms(mx, scopedRooms, term);
}
const limit = 20;
const requestBody: ISearchRequestBody = { const requestBody: ISearchRequestBody = {
search_categories: { search_categories: {
room_events: { room_events: {
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
}, },
}; };
const r = await mx.search({ try {
body: requestBody, const r = await mx.search({
next_batch: nextBatch === '' ? undefined : nextBatch, body: requestBody,
}); next_batch: nextBatch === '' ? undefined : nextBatch,
return parseSearchResult(r); });
const parsed = parseSearchResult(r);
if (
parsed.groups.length === 0 &&
!nextBatch &&
scopedRooms.length > 0 &&
scopedRooms.some((roomId) => isEncryptedRoom(mx, roomId))
) {
return searchLocalRooms(
mx,
scopedRooms.filter((id) => isEncryptedRoom(mx, id)),
term
);
}
return parsed;
} catch {
if (!nextBatch && scopedRooms.length > 0) {
return searchLocalRooms(mx, scopedRooms, term);
}
throw new Error('Message search failed');
}
}, },
[mx, term, order, rooms, senders] [mx, term, order, rooms, senders]
); );

View File

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

View File

@@ -41,7 +41,13 @@ export function UnjoinedSubRoomItem({ roomId, depth, isLast = false }: UnjoinedS
return ( return (
<Box <Box
style={{ paddingLeft, padding: `${config.space.S100} ${config.space.S200}`, minHeight: '1.5rem' }} data-nav-depth={depth}
style={{
paddingLeft,
padding: `${config.space.S100} ${config.space.S200}`,
minHeight: '1.5rem',
position: 'relative',
}}
alignItems="Center" alignItems="Center"
gap="200" gap="200"
> >

View File

@@ -3,9 +3,10 @@ import { Box, Line } from 'folds';
import { decodeRouteParam } from '../../pages/pathUtils'; import { decodeRouteParam } from '../../pages/pathUtils';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { useSetAtom } from 'jotai'; import { useAtom, useSetAtom } from 'jotai';
import { RoomView } from './RoomView'; import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer'; import { MembersDrawer } from './MembersDrawer';
import { MediaDrawer } from './room-media-menu';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -16,6 +17,7 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { activeRoomIdAtom } from '../../state/activeRoom'; import { activeRoomIdAtom } from '../../state/activeRoom';
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
import { isForum } from '../../utils/room'; import { isForum } from '../../utils/room';
import { ForumRoomView } from './ForumRoomView'; import { ForumRoomView } from './ForumRoomView';
@@ -26,13 +28,18 @@ export function Room() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const setActiveRoomId = useSetAtom(activeRoomIdAtom); const setActiveRoomId = useSetAtom(activeRoomIdAtom);
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [isPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [isMediaDrawer] = useAtom(isMediaDrawerAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId); const members = useRoomMembers(mx, room.roomId);
const markAsRead = useMarkAsRead(mx); const markAsRead = useMarkAsRead(mx);
const forumRoom = isForum(room); const forumRoom = isForum(room);
const skinny = screenSize !== ScreenSize.Desktop;
const showMediaSolo = skinny && isMediaDrawer;
const showRightDrawer =
!skinny && (isPeopleDrawer || isMediaDrawer);
// Update titlebar with current room ID // Update titlebar with current room ID
useEffect(() => { useEffect(() => {
@@ -55,11 +62,21 @@ export function Room() {
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes"> <Box grow="Yes">
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />} {!showMediaSolo &&
{screenSize === ScreenSize.Desktop && isDrawer && ( (forumRoom ? (
<ForumRoomView room={room} eventId={eventId} />
) : (
<RoomView room={room} eventId={eventId} />
))}
{showMediaSolo && <MediaDrawer room={room} solo />}
{!showMediaSolo && showRightDrawer && (
<> <>
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} /> {isMediaDrawer ? (
<MediaDrawer room={room} />
) : (
<MembersDrawer key={room.roomId} room={room} members={members} />
)}
</> </>
)} )}
</Box> </Box>

View File

@@ -0,0 +1,17 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config } from 'folds';
import * as editorCss from '../../components/editor/Editor.css';
export const RoomInputWrap = style({
width: '100%',
});
/* Notebook-flat composer chrome — Stationery only (other themes keep Editor inset border) */
globalStyle(`.stationery ${RoomInputWrap} .${editorCss.Editor}`, {
borderRadius: 0,
boxShadow: 'none',
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderLeft: 'none',
borderRight: 'none',
borderBottom: 'none',
});

View File

@@ -45,7 +45,6 @@ import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { import {
TUploadContent, TUploadContent,
encryptFile,
getImageInfo, getImageInfo,
getMxIdLocalPart, getMxIdLocalPart,
mxcUrlToHttp, mxcUrlToHttp,
@@ -54,6 +53,7 @@ import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker'; import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler'; import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
import { useFileDropZone } from '../../hooks/useFileDrop'; import { useFileDropZone } from '../../hooks/useFileDrop';
import { useRoomUploadFiles } from '../../hooks/useRoomUploadFiles';
import { import {
TUploadItem, TUploadItem,
TUploadMetadata, TUploadMetadata,
@@ -76,7 +76,6 @@ import {
createUploadFamilyObserverAtom, createUploadFamilyObserverAtom,
} from '../../state/upload'; } from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom'; import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common'; import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -101,13 +100,25 @@ import colorMXID from '../../../util/colorMXID';
import { useIsDirectRoom } from '../../hooks/useRoom'; import { useIsDirectRoom } from '../../hooks/useRoom';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useTheme } from '../../hooks/useTheme'; import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck'; import { useComposingCheck } from '../../hooks/useComposingCheck';
import { useOtherUserColor } from '../../hooks/useUserColor'; import { useOtherUserColor } from '../../hooks/useUserColor';
import { getMemberAvatarMxc } from '../../utils/room'; import { getMemberAvatarMxc } from '../../utils/room';
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter'; import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
import * as css from './RoomInput.css';
/**
* Creates a UUID used to group image events into one carousel.
*/
const createCarouselUuid = (): string => {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
@@ -212,42 +223,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}); });
}, [commands]); }, [commands]);
const { handleFiles: enqueueFiles } = useRoomUploadFiles(room);
const handleFiles = useCallback( const handleFiles = useCallback(
async (files: File[]) => { async (files: File[]) => {
setUploadBoard(true); setUploadBoard(true);
const safeFiles = files.map(safeFile); await enqueueFiles(files);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
},
})
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
})
);
}
setSelectedFiles({
type: 'PUT',
item: fileItems,
});
}, },
[setSelectedFiles, room] [enqueueFiles]
); );
const pickFile = useFilePicker(handleFiles, true); const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles); const handlePaste = useFilePasteHandler(handleFiles);
@@ -306,12 +288,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}; };
const handleSendUpload = async (uploads: UploadSuccess[]) => { const handleSendUpload = async (uploads: UploadSuccess[]) => {
const imageUploads = uploads.filter((upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
return !!fileItem && fileItem.file.type.startsWith('image');
});
const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined;
const imageUploadIndexByFile = new Map(
imageUploads.map((upload, index) => [upload.file, index] as const)
);
const contentsPromises = uploads.map(async (upload) => { const contentsPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file); const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload'); if (!fileItem) throw new Error('Broken upload');
if (fileItem.file.type.startsWith('image')) { if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, upload.mxc); const imageIndex = imageUploadIndexByFile.get(upload.file);
return getImageMsgContent(
mx,
fileItem,
upload.mxc,
carouselUuid !== undefined && imageIndex !== undefined
? {
uuid: carouselUuid,
index: imageIndex,
total: imageUploads.length,
}
: undefined
);
} }
if (fileItem.file.type.startsWith('video')) { if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, upload.mxc); return getVideoMsgContent(mx, fileItem, upload.mxc);
@@ -584,7 +588,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}; };
return ( return (
<div ref={ref}> <div ref={ref} className={css.RoomInputWrap}>
{selectedFiles.length > 0 && ( {selectedFiles.length > 0 && (
<UploadBoard <UploadBoard
header={ header={
@@ -690,8 +694,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderBottom: 'none', borderBottom: 'none',
borderTopLeftRadius: config.radii.R400, borderRadius: isStationeryTheme(theme) ? 0 : undefined,
borderTopRightRadius: config.radii.R400, borderTopLeftRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
borderTopRightRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
marginBottom: config.space.S100, marginBottom: config.space.S100,
}} }}
direction="Column" direction="Column"

View File

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

View File

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

View File

@@ -85,7 +85,6 @@ import {
useIntersectionObserver, useIntersectionObserver,
} from '../../hooks/useIntersectionObserver'; } from '../../hooks/useIntersectionObserver';
import { useMarkAsRead } from '../../hooks/useMarkAsRead'; import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver'; import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css'; import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
@@ -103,6 +102,14 @@ import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalSc
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import {
clearRoomReturnAnchor,
clearRoomScrollState,
getRoomReturnAnchor,
getRoomScrollState,
saveRoomScrollState,
setRoomReturnAnchor,
} from '../../state/roomScrollCache';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@@ -620,6 +627,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const location = useLocation(); const location = useLocation();
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest; const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
const savedScrollRef = useRef(
eventId || scrollToLatest ? undefined : getRoomScrollState(room.roomId)
);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [unreadInfo, setUnreadInfo] = useState(() => const [unreadInfo, setUnreadInfo] = useState(() =>
@@ -631,7 +642,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
} }
const atBottomAnchorRef = useRef<HTMLElement>(null); const atBottomAnchorRef = useRef<HTMLElement>(null);
const [atBottom, setAtBottom] = useState<boolean>(true); const [atBottom, setAtBottom] = useState<boolean>(
() => savedScrollRef.current?.atBottom ?? true
);
const atBottomRef = useRef(atBottom); const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom; atBottomRef.current = atBottom;
@@ -639,16 +652,54 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
sender: string; sender: string;
content: string; content: string;
} | null>(null); } | null>(null);
const [returnToEventId, setReturnToEventIdState] = useState<string | null>(
() => getRoomReturnAnchor(room.roomId) ?? null
);
const setReturnToEventId = useCallback(
(eventId: string | null) => {
if (eventId) setRoomReturnAnchor(room.roomId, eventId);
else clearRoomReturnAnchor(room.roomId);
setReturnToEventIdState(eventId);
},
[room.roomId]
);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const timelineContentRef = useRef<HTMLDivElement>(null); const timelineContentRef = useRef<HTMLDivElement>(null);
const timelineContentHeightRef = useRef(0); const timelineContentHeightRef = useRef(0);
const restoringScrollRef = useRef(false);
const scrollToBottomRef = useRef({ const scrollToBottomRef = useRef({
count: 0, count: 0,
smooth: true, smooth: true,
force: false, force: false,
}); });
const getViewportAnchorEventId = useCallback((): string | undefined => {
const scrollEl = scrollRef.current;
if (!scrollEl) return undefined;
const scrollRect = scrollEl.getBoundingClientRect();
const centerY = scrollRect.top + scrollRect.height / 2;
const items = scrollEl.querySelectorAll<HTMLElement>('[data-message-id]');
let bestId: string | undefined;
let bestDist = Infinity;
items.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) return;
const mid = (rect.top + rect.bottom) / 2;
const dist = Math.abs(mid - centerY);
if (dist < bestDist) {
bestDist = dist;
bestId = el.getAttribute('data-message-id') ?? undefined;
}
});
return bestId;
}, []);
const [focusItem, setFocusItem] = useState< const [focusItem, setFocusItem] = useState<
| { | {
index: number; index: number;
@@ -680,9 +731,49 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
); );
const parseMemberEvent = useMemberEventParser(); const parseMemberEvent = useMemberEventParser();
const [timeline, setTimeline] = useState<Timeline>(() => const [timeline, setTimeline] = useState<Timeline>(() => {
eventId ? getEmptyTimeline() : getInitialTimeline(room) if (eventId) return getEmptyTimeline();
const initial = getInitialTimeline(room);
const saved = savedScrollRef.current;
if (!saved?.range) return initial;
const evLength = getTimelinesEventsCount(initial.linkedTimelines);
if (evLength === 0) return initial;
const start = Math.min(saved.range.start, Math.max(0, evLength - 1));
const end = Math.min(Math.max(start + 1, saved.range.end), evLength);
return { ...initial, range: { start, end } };
});
const timelineRef = useRef(timeline);
timelineRef.current = timeline;
const getRangeCenterEventId = useCallback((): string | undefined => {
const { linkedTimelines, range } = timelineRef.current;
if (range.end <= range.start) return undefined;
const mid = Math.floor((range.start + range.end - 1) / 2);
const [tm, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, mid);
if (!tm) return undefined;
return getTimelineEvent(tm, getTimelineRelativeIndex(mid, baseIndex))?.getId();
}, []);
const persistTimelineScroll = useCallback(
(roomId: string) => {
if (eventId) return;
const scrollEl = scrollRef.current;
if (!scrollEl) return;
const distanceFromBottom =
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
saveRoomScrollState(roomId, {
top: scrollEl.scrollTop,
atBottom: distanceFromBottom <= 50,
range: timelineRef.current.range,
});
},
[eventId]
); );
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
const liveTimelineLinked = const liveTimelineLinked =
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room); timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
@@ -693,6 +784,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd); const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd; atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
// Historical / non-live windows are never "at bottom" of the room.
useEffect(() => {
if (!liveTimelineLinked || !rangeAtEnd) {
setAtBottom(false);
}
}, [liveTimelineLinked, rangeAtEnd]);
const handleTimelinePagination = useTimelinePagination( const handleTimelinePagination = useTimelinePagination(
mx, mx,
timeline, timeline,
@@ -757,6 +855,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const isOwnMessage = mEvt.getSender() === mx.getUserId(); const isOwnMessage = mEvt.getSender() === mx.getUserId();
const isBottomPinnedEvent = const isBottomPinnedEvent =
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker; mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
const wasAwayFromBottom = !atBottomRef.current;
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent); const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
// Always update timeline with new message // Always update timeline with new message
@@ -777,6 +876,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
requestAnimationFrame(() => markAsRead(roomId, hideActivity)); requestAnimationFrame(() => markAsRead(roomId, hideActivity));
} }
} }
// Sending while scrolled up jumps to latest — offer return to previous spot
if (wasAwayFromBottom && isBottomPinnedEvent) {
const anchorEventId =
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
if (anchorEventId) {
setReturnToEventId(anchorEventId);
}
}
} else if (!document.hasFocus() && !unreadInfo) { } else if (!document.hasFocus() && !unreadInfo) {
// Show unread notification for other users' messages when unfocused // Show unread notification for other users' messages when unfocused
setUnreadInfo(getRoomUnreadInfo(room)); setUnreadInfo(getRoomUnreadInfo(room));
@@ -803,7 +910,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 +968,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const scrollElement = getScrollElement(); const scrollElement = getScrollElement();
if (!editorBaseEntry || !scrollElement) return; if (!editorBaseEntry || !scrollElement) return;
if (restoringScrollRef.current) return;
if (isScrolledToBottom(scrollElement)) { if (isScrolledToBottom(scrollElement)) {
autoScrollToBottom(scrollElement, false, true); autoScrollToBottom(scrollElement, false, true);
} }
@@ -882,6 +991,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const previousHeight = timelineContentHeightRef.current; const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight; timelineContentHeightRef.current = nextHeight;
if (restoringScrollRef.current) {
const saved = savedScrollRef.current;
if (saved) {
if (saved.atBottom) {
scrollElement.scrollTop = scrollElement.scrollHeight;
} else {
scrollElement.scrollTop = saved.top;
}
}
return;
}
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) { if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
return; return;
} }
@@ -904,30 +1025,27 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
} }
}, [room, hideActivity, markAsRead]); }, [room, hideActivity, markAsRead]);
const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) {
setAtBottom(false);
}
}, []),
{ wait: 1000 }
);
useIntersectionObserver( useIntersectionObserver(
useCallback( useCallback(
(entries) => { (entries) => {
const target = atBottomAnchorRef.current; const target = atBottomAnchorRef.current;
if (!target) return; if (!target) return;
const targetEntry = getIntersectionObserverEntry(target, entries); const targetEntry = getIntersectionObserverEntry(target, entries);
if (targetEntry) debounceSetAtBottom(targetEntry); if (!targetEntry) return;
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true); // Leave the live bottom immediately so Jump to Latest stays available.
setLatestUnreadMessage(null); if (!targetEntry.isIntersecting || !atLiveEndRef.current) {
if (document.hasFocus()) { setAtBottom(false);
tryAutoMarkAsRead(); return;
} }
setAtBottom(true);
setLatestUnreadMessage(null);
if (document.hasFocus()) {
tryAutoMarkAsRead();
} }
}, },
[debounceSetAtBottom, tryAutoMarkAsRead] [tryAutoMarkAsRead]
), ),
useCallback( useCallback(
() => ({ () => ({
@@ -991,9 +1109,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
} }
}, [eventId, loadEventTimeline]); }, [eventId, loadEventTimeline]);
// Restore return-to-previous when switching rooms (survives remount via cache).
useEffect(() => {
setReturnToEventIdState(getRoomReturnAnchor(room.roomId) ?? null);
}, [room.roomId]);
// Scroll to latest when clicking on already-selected room // Scroll to latest when clicking on already-selected room
useEffect(() => { useEffect(() => {
if (scrollToLatest) { if (scrollToLatest) {
clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null); setLatestUnreadMessage(null);
setUnreadInfo(undefined); setUnreadInfo(undefined);
setTimeline(getInitialTimeline(room)); setTimeline(getInitialTimeline(room));
@@ -1003,12 +1127,78 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
} }
}, [scrollToLatest, room]); }, [scrollToLatest, room]);
// Scroll to bottom on initial timeline load // Remember scroll position when leaving a room (switch DM, nav away, etc.).
useLayoutEffect(() => {
const roomId = room.roomId;
return () => {
persistTimelineScroll(roomId);
};
}, [room.roomId, persistTimelineScroll]);
// Keep scroll cache updated while reading (useEffect cleanup runs after DOM teardown).
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl || eventId) return undefined;
const roomId = room.roomId;
let raf = 0;
const onScroll = () => {
if (restoringScrollRef.current) return;
window.cancelAnimationFrame(raf);
raf = window.requestAnimationFrame(() => persistTimelineScroll(roomId));
};
scrollEl.addEventListener('scroll', onScroll, { passive: true });
return () => {
window.cancelAnimationFrame(raf);
scrollEl.removeEventListener('scroll', onScroll);
};
}, [room.roomId, eventId, persistTimelineScroll]);
// Restore saved scroll or default to bottom on first open.
useLayoutEffect(() => { useLayoutEffect(() => {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
if (scrollEl) { if (!scrollEl) return undefined;
if (scrollToLatest) {
autoScrollToBottom(scrollEl, false, true); autoScrollToBottom(scrollEl, false, true);
return undefined;
} }
const saved = savedScrollRef.current;
if (!saved) {
autoScrollToBottom(scrollEl, false, true);
return undefined;
}
restoringScrollRef.current = true;
setAtBottom(saved.atBottom);
const restore = () => {
if (saved.atBottom) {
scrollEl.scrollTop = scrollEl.scrollHeight;
} else {
scrollEl.scrollTop = saved.top;
}
};
restore();
const raf1 = window.requestAnimationFrame(() => {
restore();
window.requestAnimationFrame(() => {
restore();
window.setTimeout(() => {
restore();
window.setTimeout(() => {
restoringScrollRef.current = false;
}, 150);
}, 50);
});
});
return () => {
window.cancelAnimationFrame(raf1);
restoringScrollRef.current = false;
};
}, []); }, []);
// if live timeline is linked and unreadInfo change // if live timeline is linked and unreadInfo change
@@ -1052,7 +1242,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// scroll to bottom of timeline // scroll to bottom of timeline
const scrollToBottomCount = scrollToBottomRef.current.count; const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => { useLayoutEffect(() => {
if (scrollToBottomCount > 0) { if (scrollToBottomCount > 0 && !restoringScrollRef.current) {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
if (scrollEl) { if (scrollEl) {
autoScrollToBottom( autoScrollToBottom(
@@ -1110,16 +1300,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}, [room, hour24Clock]); }, [room, hour24Clock]);
const handleJumpToLatest = () => { const handleJumpToLatest = () => {
const anchorEventId =
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
if (anchorEventId) {
setReturnToEventId(anchorEventId);
}
if (eventId) { if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true }); navigateRoom(room.roomId, undefined, { replace: true });
} }
clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null); setLatestUnreadMessage(null);
setTimeline(getInitialTimeline(room));
// Already on the live timeline: only snap the virtual window to the end.
// A full getInitialTimeline() rebuild feels like a chat reload when far back.
if (!eventId && liveTimelineLinked) {
setTimeline((ct) => {
const evLength = getTimelinesEventsCount(ct.linkedTimelines);
return {
linkedTimelines: ct.linkedTimelines,
range: {
start: Math.max(evLength - PAGINATION_LIMIT, 0),
end: evLength,
},
};
});
} else {
setTimeline(getInitialTimeline(room));
}
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false; scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true; scrollToBottomRef.current.force = true;
}; };
const handleReturnToPrevious = () => {
if (!returnToEventId) return;
const targetId = returnToEventId;
setReturnToEventId(null);
handleOpenEvent(targetId);
};
const handleClearReturnToPrevious = () => {
setReturnToEventId(null);
};
const handleJumpToUnread = () => { const handleJumpToUnread = () => {
if (unreadInfo?.readUptoEventId) { if (unreadInfo?.readUptoEventId) {
setTimeline(getEmptyTimeline()); setTimeline(getEmptyTimeline());
@@ -2266,36 +2491,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return callSummaryJSX || eventJSX; return callSummaryJSX || eventJSX;
}; };
const showUnreadTop =
!!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
return ( return (
<Box grow="Yes" style={{ position: 'relative' }}> <Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && ( <Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
<TimelineFloat position="Top">
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<Text size="L400">Jump to Unread</Text>
</Chip>
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">Mark as Read</Text>
</Chip>
</TimelineFloat>
)}
<Scroll ref={scrollRef} visibility="Hover">
<Box <Box
ref={timelineContentRef} ref={timelineContentRef}
direction="Column" direction="Column"
justifyContent="End" justifyContent="End"
data-chatroll=""
style={{ style={{
minHeight: '100%', minHeight: '100%',
padding: `${config.space.S600} 0`, padding: `${config.space.S600} 0`,
@@ -2384,7 +2591,57 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<span ref={atBottomAnchorRef} /> <span ref={atBottomAnchorRef} />
</Box> </Box>
</Scroll> </Scroll>
{!atBottom && ( {(showUnreadTop || returnToEventId) && (
<TimelineFloat position="Top">
{returnToEventId && (
<>
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowTop} />}
onClick={handleReturnToPrevious}
>
<Text size="L400">Return to Previous</Text>
</Chip>
<IconButton
title="Dismiss"
aria-label="Dismiss return to previous"
size="300"
radii="Pill"
variant="SurfaceVariant"
onClick={handleClearReturnToPrevious}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</>
)}
{showUnreadTop && (
<>
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<Text size="L400">Jump to Unread</Text>
</Chip>
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">Mark as Read</Text>
</Chip>
</>
)}
</TimelineFloat>
)}
{!atLiveBottom && (
<TimelineFloat position="Bottom"> <TimelineFloat position="Bottom">
<Box direction="Column" gap="200" alignItems="Center"> <Box direction="Column" gap="200" alignItems="Center">
{latestUnreadMessage && ( {latestUnreadMessage && (

View File

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

View File

@@ -2,6 +2,20 @@ import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes'; import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
/** Floats typing + read receipts over the bottom of the timeline. */
export const RoomViewBottomFloat = style({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
zIndex: 1,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: config.space.S200,
pointerEvents: 'none',
});
export const RoomViewFollowingPlaceholder = style([ export const RoomViewFollowingPlaceholder = style([
DefaultReset, DefaultReset,
{ {
@@ -15,10 +29,12 @@ export const RoomViewFollowing = recipe({
{ {
minHeight: toRem(28), minHeight: toRem(28),
padding: `0 ${config.space.S400}`, padding: `0 ${config.space.S400}`,
width: '100%', marginLeft: 'auto',
backgroundColor: color.Surface.Container, maxWidth: '50%',
backgroundColor: 'transparent',
color: color.Surface.OnContainer, color: color.Surface.OnContainer,
outline: 'none', outline: 'none',
pointerEvents: 'auto',
}, },
], ],
variants: { variants: {

View File

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

View File

@@ -7,19 +7,19 @@ import { JoinRule, Room } from 'matrix-js-sdk';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page'; import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; import { getHomeSearchPath, getSpaceSearchPath, getDirectSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import colorMXID from '../../../util/colorMXID';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { _SearchPathSearchParams } from '../../pages/paths'; import { _SearchPathSearchParams } from '../../pages/paths';
import * as css from './RoomViewHeader.css'; import * as css from './RoomViewHeader.css';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
@@ -41,6 +41,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents'; import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu'; import { RoomPinMenu } from './room-pin-menu';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { import {
getRoomNotificationMode, getRoomNotificationMode,
@@ -56,6 +57,8 @@ import { useRoomCall, useRoomCallMembers } from '../call';
import { CallState, CallType } from '../call/types'; import { CallState, CallType } from '../call/types';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room'; import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
import { useDirectSelected } from '../../hooks/router/useDirectSelected';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@@ -469,18 +472,31 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>(); const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const onDirectPath = useDirectSelected();
const theme = useTheme();
const stationery = isStationeryTheme(theme);
const pinnedEvents = useRoomPinnedEvents(room); const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const isDirect = mDirects.has(room.roomId);
const ecryptedRoom = !!encryptionEvent; const avatarMxc = useRoomAvatar(room, isDirect);
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
const name = useRoomName(room); const name = useRoomName(room);
const topic = useRoomTopic(room); const topic = useRoomTopic(room);
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined;
const dmAvatarMxc =
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
(isDirect ? avatarMxc : undefined);
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
const dmFolderTabColor =
stationery && isDirect
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [isMediaDrawer, setMediaDrawer] = useAtom(isMediaDrawerAtom);
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId)); const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
const handleSearchClick = () => { const handleSearchClick = () => {
@@ -489,7 +505,9 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
}; };
const path = space const path = space
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId)) ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
: getHomeSearchPath(); : isDirect || onDirectPath
? getDirectSearchPath()
: getHomeSearchPath();
navigate(withSearchParam(path, searchParams)); navigate(withSearchParam(path, searchParams));
}; };
@@ -501,6 +519,22 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
const handleTogglePeopleDrawer = () => {
setPeopleDrawer((drawer) => {
const next = !drawer;
if (next) setMediaDrawer(false);
return next;
});
};
const handleToggleMediaDrawer = () => {
setMediaDrawer((open) => {
const next = !open;
if (next) setPeopleDrawer(false);
return next;
});
};
return ( return (
<PageHeader balance={showInPageHeader}> <PageHeader balance={showInPageHeader}>
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
@@ -511,7 +545,24 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
</IconButton> </IconButton>
</Box> </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 && ( {!showInPageHeader && (
<Avatar size="300"> <Avatar size="300">
<RoomAvatar <RoomAvatar
@@ -528,7 +579,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
/> />
</Avatar> </Avatar>
)} )}
<Box direction="Column"> <Box direction="Column" style={{ minWidth: 0 }}>
<Box alignItems="Center" gap="100"> <Box alignItems="Center" gap="100">
<Text size={topic ? 'H5' : 'H3'} truncate> <Text size={topic ? 'H5' : 'H3'} truncate>
{name} {name}
@@ -578,26 +629,25 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
)} )}
</Box> </Box>
</Box> </Box>
<Box grow="Yes" />
<Box shrink="No" alignItems="Center" gap="100"> <Box shrink="No" alignItems="Center" gap="100">
<CallIndicator roomId={room.roomId} /> <CallIndicator roomId={room.roomId} />
<RoomCallButtons roomId={room.roomId} /> <RoomCallButtons roomId={room.roomId} />
{!ecryptedRoom && ( <TooltipProvider
<TooltipProvider position="Bottom"
position="Bottom" offset={4}
offset={4} tooltip={
tooltip={ <Tooltip>
<Tooltip> <Text>Search</Text>
<Text>Search</Text> </Tooltip>
</Tooltip> }
} >
> {(triggerRef) => (
{(triggerRef) => ( <IconButton ref={triggerRef} onClick={handleSearchClick}>
<IconButton ref={triggerRef} onClick={handleSearchClick}> <Icon size="300" src={Icons.Search} />
<Icon size="300" src={Icons.Search} /> </IconButton>
</IconButton> )}
)} </TooltipProvider>
</TooltipProvider>
)}
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
offset={4} offset={4}
@@ -665,8 +715,12 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}> <IconButton
<Icon size="300" src={Icons.User} /> ref={triggerRef}
onClick={handleTogglePeopleDrawer}
aria-pressed={peopleDrawer}
>
<Icon size="300" src={Icons.User} filled={peopleDrawer} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -690,6 +744,26 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
</IconButton> </IconButton>
)} )}
</TooltipProvider> </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>
<PluginButtonSlot location="room-header" /> <PluginButtonSlot location="room-header" />
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,149 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const MediaDrawer = style({
width: toRem(399),
});
export const MediaDrawerSolo = style({
width: '100%',
flexGrow: 1,
});
export const MediaDrawerHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const MediaDrawerContentBase = style({
position: 'relative',
overflow: 'hidden',
});
export const MediaDrawerContent = style({
padding: `${config.space.S100} ${config.space.S200} ${config.space.S200}`,
});
export const MediaMonthSection = style({
marginBottom: config.space.S300,
});
export const MediaMonthHeader = style({
position: 'sticky',
top: 0,
zIndex: 2,
margin: '5px 0',
padding: `0 ${config.space.S100}`,
lineHeight: 1.2,
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
});
export const MediaDaySection = style({
marginBottom: config.space.S300,
});
export const MediaDayHeader = style({
padding: `${config.space.S100} ${config.space.S100} ${config.space.S200}`,
color: color.Background.OnContainer,
});
export const MediaGrid = style({
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: config.space.S200,
});
export const MediaTile = style({
position: 'relative',
aspectRatio: '1',
borderRadius: config.radii.R400,
overflow: 'hidden',
border: 'none',
padding: 0,
cursor: 'pointer',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
});
export const MediaTileImg = style({
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
});
export const MediaTileBadge = style({
position: 'absolute',
right: config.space.S100,
bottom: config.space.S100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(24),
height: toRem(24),
borderRadius: config.radii.R400,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
color: '#fff',
});
export const MediaEmpty = style({
padding: config.space.S400,
textAlign: 'center',
});
export const MediaFooter = style({
padding: config.space.S200,
display: 'flex',
justifyContent: 'center',
});
export const MediaUserFiltersScroll = style({
flexShrink: 0,
flexGrow: 0,
overflowX: 'auto',
overflowY: 'hidden',
scrollbarWidth: 'thin',
scrollbarColor: `${color.SurfaceVariant.ContainerLine} ${color.SurfaceVariant.Container}`,
selectors: {
'&::-webkit-scrollbar': {
height: toRem(8),
},
'&::-webkit-scrollbar-track': {
backgroundColor: color.SurfaceVariant.Container,
borderRadius: config.radii.R400,
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: color.SurfaceVariant.ContainerLine,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.Container}`,
backgroundClip: 'padding-box',
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: color.Surface.ContainerLine,
},
},
});
export const MediaUserFilters = style({
display: 'flex',
flexWrap: 'nowrap',
alignItems: 'center',
gap: config.space.S500,
padding: `${config.space.S200} ${config.space.S200}`,
width: 'max-content',
minWidth: '100%',
boxSizing: 'border-box',
});
export const MediaUserChip = style({
flexShrink: 0,
});
export const MediaUserChipLabel = style({
maxWidth: toRem(72),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});

View File

@@ -0,0 +1,524 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Header,
IconButton,
Scroll,
Spinner,
Text,
as,
config,
Chip,
Avatar,
} from 'folds';
import { MsgType, Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import classNames from 'classnames';
import { Icon, Icons } from '../../../components/icons';
import * as css from './RoomMediaMenu.css';
import { RoomMediaItem, useRoomMediaEvents } from '../../../hooks/useRoomMediaEvents';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import {
decryptFile,
downloadEncryptedMedia,
getMxIdLocalPart,
isAuthenticatedMediaUrl,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { Image } from '../../../components/media';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getCurrentAccessToken } from '../../../utils/auth';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { VideoFrameThumbnail } from '../../../components/message/content/VideoFrameThumbnail';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
import { UserAvatar } from '../../../components/user-avatar';
import {
getMediaDrawerScrollTop,
isMediaDrawerAtom,
setMediaDrawerScrollTop,
} from '../../../state/mediaDrawer';
import { ContainerColor } from '../../../styles/ContainerColor.css';
const fetchAuthenticatedMedia = async (
url: string,
accessToken: string | null
): Promise<string> => {
try {
let response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok && response.status === 401 && accessToken) {
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) return url;
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch {
return url;
}
};
type MediaTileThumbProps = {
item: RoomMediaItem;
};
function MediaTileImageThumb({ item }: { item: RoomMediaItem & { thumbMxc: string } }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const accessToken = getCurrentAccessToken();
if (item.thumbEncInfo) {
const mediaUrl = mxcUrlToHttp(mx, item.thumbMxc, useAuthentication) ?? item.thumbMxc;
const fileContent = await downloadEncryptedMedia(
mediaUrl,
(encBuf) =>
decryptFile(encBuf, item.thumbMimeType ?? FALLBACK_MIMETYPE, item.thumbEncInfo!),
accessToken
);
return URL.createObjectURL(fileContent);
}
const mediaUrl =
mxcUrlToHttp(mx, item.thumbMxc, useAuthentication, 240, 240, 'crop') ?? item.thumbMxc;
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
return fetchAuthenticatedMedia(mediaUrl, accessToken);
}
return mediaUrl;
}, [mx, item.thumbMxc, item.thumbEncInfo, item.thumbMimeType, useAuthentication])
);
useEffect(() => {
loadSrc();
}, [loadSrc]);
useEffect(() => {
if (srcState.status !== AsyncStatus.Success) return undefined;
const src = srcState.data;
if (!src.startsWith('blob:')) return undefined;
return () => URL.revokeObjectURL(src);
}, [srcState]);
if (srcState.status === AsyncStatus.Success) {
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
}
return (
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
{srcState.status === AsyncStatus.Loading ? (
<Spinner size="200" />
) : (
<Icon src={Icons.Photo} size="400" />
)}
</Box>
);
}
function MediaTileFullImageThumb({ item }: { item: RoomMediaItem }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const accessToken = getCurrentAccessToken();
const mediaUrl = mxcUrlToHttp(mx, item.fullMxc, useAuthentication) ?? item.fullMxc;
if (item.fullEncInfo) {
const fileContent = await downloadEncryptedMedia(
mediaUrl,
(encBuf) =>
decryptFile(encBuf, item.fullMimeType ?? FALLBACK_MIMETYPE, item.fullEncInfo!),
accessToken
);
return URL.createObjectURL(fileContent);
}
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
return fetchAuthenticatedMedia(mediaUrl, accessToken);
}
return mediaUrl;
}, [mx, item.fullMxc, item.fullEncInfo, item.fullMimeType, useAuthentication])
);
useEffect(() => {
loadSrc();
}, [loadSrc]);
useEffect(() => {
if (srcState.status !== AsyncStatus.Success) return undefined;
const src = srcState.data;
if (!src.startsWith('blob:')) return undefined;
return () => URL.revokeObjectURL(src);
}, [srcState]);
if (srcState.status === AsyncStatus.Success) {
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
}
return (
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
{srcState.status === AsyncStatus.Loading ? (
<Spinner size="200" />
) : (
<Icon src={Icons.Photo} size="400" />
)}
</Box>
);
}
function MediaTileThumb({ item }: MediaTileThumbProps) {
if (item.thumbMxc) {
return <MediaTileImageThumb item={{ ...item, thumbMxc: item.thumbMxc }} />;
}
if (item.msgtype === MsgType.Video) {
return (
<Box style={{ width: '100%', height: '100%', position: 'relative' }}>
<Box
alignItems="Center"
justifyContent="Center"
style={{ position: 'absolute', inset: 0 }}
>
<Spinner size="200" />
</Box>
<VideoFrameThumbnail
mimeType={item.fullMimeType ?? 'video/mp4'}
url={item.fullMxc}
encInfo={item.fullEncInfo}
renderImage={(src) => (
<Image className={css.MediaTileImg} src={src} alt={item.body} loading="lazy" />
)}
/>
</Box>
);
}
return <MediaTileFullImageThumb item={item} />;
}
type MediaDayGroup = {
dayKey: string;
dayLabel: string;
items: RoomMediaItem[];
};
type MediaMonthGroup = {
monthKey: string;
monthLabel: string;
days: MediaDayGroup[];
};
const monthFormatter = new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
});
const dayFormatter = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
});
const sameDayFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const formatDayLabel = (ts: number): string => {
const date = new Date(ts);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (sameDayFormatter.format(date) === sameDayFormatter.format(today)) return 'Today';
if (sameDayFormatter.format(date) === sameDayFormatter.format(yesterday)) return 'Yesterday';
return dayFormatter.format(date);
};
const groupMediaByMonthAndDay = (items: RoomMediaItem[]): MediaMonthGroup[] => {
const months: MediaMonthGroup[] = [];
for (const item of items) {
const date = new Date(item.ts);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`;
let month = months[months.length - 1];
if (!month || month.monthKey !== monthKey) {
month = {
monthKey,
monthLabel: monthFormatter.format(date),
days: [],
};
months.push(month);
}
let day = month.days[month.days.length - 1];
if (!day || day.dayKey !== dayKey) {
day = {
dayKey,
dayLabel: formatDayLabel(item.ts),
items: [],
};
month.days.push(day);
}
day.items.push(item);
}
return months;
};
type MediaSenderChip = {
userId: string;
name: string;
avatarMxc?: string;
count: number;
};
export type MediaDrawerProps = {
room: Room;
/** Full-page takeover when the layout is too narrow for a side rail. */
solo?: boolean;
};
export const MediaDrawer = as<'div', MediaDrawerProps>(({ room, solo = false, className, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
const [, setMediaDrawer] = useAtom(isMediaDrawerAtom);
const { items, loading, hasMore, loadMore } = useRoomMediaEvents(room);
const [excludedSenders, setExcludedSenders] = useState<Set<string>>(() => new Set());
const scrollRef = useRef<HTMLDivElement>(null);
const restoredScrollRef = useRef(false);
const senders = useMemo<MediaSenderChip[]>(() => {
const counts = new Map<string, number>();
for (const item of items) {
counts.set(item.sender, (counts.get(item.sender) ?? 0) + 1);
}
return Array.from(counts.entries())
.map(([userId, count]) => ({
userId,
count,
name: getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
avatarMxc: getMemberAvatarMxc(room, userId),
}))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
}, [items, room]);
const filteredItems = useMemo(
() => items.filter((item) => !excludedSenders.has(item.sender)),
[items, excludedSenders]
);
const sections = useMemo(() => groupMediaByMonthAndDay(filteredItems), [filteredItems]);
useLayoutEffect(() => {
restoredScrollRef.current = false;
}, [room.roomId]);
useLayoutEffect(() => {
if (restoredScrollRef.current || items.length === 0) return;
const el = scrollRef.current;
if (!el) return;
const saved = getMediaDrawerScrollTop(room.roomId);
if (typeof saved === 'number') {
el.scrollTop = saved;
}
restoredScrollRef.current = true;
}, [room.roomId, items.length]);
const handleClose = useCallback(() => {
setMediaDrawer(false);
}, [setMediaDrawer]);
const handleSenderClick = useCallback(
(evt: React.MouseEvent, userId: string) => {
if (evt.ctrlKey || evt.metaKey) {
evt.preventDefault();
const allIds = senders.map((sender) => sender.userId);
setExcludedSenders((prev) => {
const enabledIds = allIds.filter((id) => !prev.has(id));
const alreadySolo = enabledIds.length === 1 && enabledIds[0] === userId;
if (alreadySolo) return new Set();
return new Set(allIds.filter((id) => id !== userId));
});
return;
}
setExcludedSenders((prev) => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
},
[senders]
);
const handleOpen = useCallback(
(eventId: string) => {
navigateRoom(room.roomId, eventId);
if (solo) setMediaDrawer(false);
},
[navigateRoom, room.roomId, solo, setMediaDrawer]
);
const handleScroll = useCallback(
(evt: React.UIEvent<HTMLDivElement>) => {
const el = evt.currentTarget;
setMediaDrawerScrollTop(room.roomId, el.scrollTop);
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
if (nearBottom && hasMore && !loading) {
loadMore();
}
},
[hasMore, loading, loadMore, room.roomId]
);
return (
<Box
shrink="No"
grow={solo ? 'Yes' : undefined}
direction="Column"
className={classNames(
solo ? css.MediaDrawerSolo : css.MediaDrawer,
ContainerColor({ variant: 'Background' }),
className
)}
{...props}
ref={ref}
>
<Header className={css.MediaDrawerHeader} size="600" variant="Background">
<Box grow="Yes" alignItems="Center">
<Text size="H5" truncate>
Shared Media
</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} size="100" />
</IconButton>
</Header>
{senders.length > 0 && (
<div className={css.MediaUserFiltersScroll}>
<div className={css.MediaUserFilters}>
{senders.map((sender) => {
const enabled = !excludedSenders.has(sender.userId);
const avatarUrl = sender.avatarMxc
? mxcUrlToHttp(mx, sender.avatarMxc, useAuthentication, 48, 48, 'crop') ??
undefined
: undefined;
return (
<Chip
key={sender.userId}
className={css.MediaUserChip}
variant={enabled ? 'Primary' : 'SurfaceVariant'}
outlined
radii="Pill"
aria-pressed={enabled}
onClick={(evt) => handleSenderClick(evt, sender.userId)}
before={
<Avatar size="200">
<UserAvatar
userId={sender.userId}
src={avatarUrl}
alt={sender.name}
renderFallback={() => (
<Text as="span" size="L400">
{sender.name.slice(0, 1).toUpperCase()}
</Text>
)}
/>
</Avatar>
}
>
<Text className={css.MediaUserChipLabel} size="T200" truncate>
{sender.name}
</Text>
</Chip>
);
})}
</div>
</div>
)}
<Box grow="Yes" direction="Column" className={css.MediaDrawerContentBase}>
<Scroll
ref={scrollRef}
visibility="Hover"
hideTrack
variant="Background"
onScroll={handleScroll}
>
<div className={css.MediaDrawerContent}>
{items.length === 0 && !loading && (
<Text className={css.MediaEmpty} size="T300" priority="300">
No shared photos or videos yet.
</Text>
)}
{items.length > 0 && filteredItems.length === 0 && (
<Text className={css.MediaEmpty} size="T300" priority="300">
No media from the selected people.
</Text>
)}
{sections.map((month) => (
<section key={month.monthKey} className={css.MediaMonthSection}>
<Text className={css.MediaMonthHeader} size="H6" as="h3">
{month.monthLabel}
</Text>
{month.days.map((day) => (
<div key={day.dayKey} className={css.MediaDaySection}>
<Text className={css.MediaDayHeader} size="T200" priority="300" as="h4">
{day.dayLabel}
</Text>
<div className={css.MediaGrid}>
{day.items.map((item) => (
<button
key={item.eventId}
type="button"
className={css.MediaTile}
onClick={() => handleOpen(item.eventId)}
title={item.body}
aria-label={item.body}
>
<MediaTileThumb item={item} />
{item.msgtype === MsgType.Video && (
<span className={css.MediaTileBadge}>
<Icon src={Icons.Play} size="100" />
</span>
)}
</button>
))}
</div>
</div>
))}
</section>
))}
{(loading || hasMore) && (
<div className={css.MediaFooter} style={{ minHeight: config.space.S700 }}>
{loading && <Spinner size="400" />}
</div>
)}
</div>
</Scroll>
</Box>
</Box>
);
});

View File

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

View File

@@ -145,11 +145,23 @@ export function Search({ requestClose }: SearchProps) {
const getTargetStr: SearchItemStrGetter<string> = useCallback( const getTargetStr: SearchItemStrGetter<string> = useCallback(
(roomId: string) => { (roomId: string) => {
const roomName = getRoom(roomId)?.name ?? roomId; const room = getRoom(roomId);
const roomName = room?.name ?? roomId;
if (mDirects.has(roomId)) { if (mDirects.has(roomId)) {
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId()); const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId); if (!targetUserId) return roomName;
if (targetUsername) return [roomName, targetUsername]; const targetUsername = getMxIdLocalPart(targetUserId);
const targetServer = getMxIdServer(targetUserId);
const member = room?.getMember(targetUserId);
const displayName = member?.name || member?.rawDisplayName;
return [
roomName,
displayName,
targetUsername,
targetUserId,
targetServer ? `@${targetUsername}:${targetServer}` : undefined,
targetServer,
].filter((s): s is string => typeof s === 'string' && s.length > 0);
} }
return roomName; return roomName;
}, },

View File

@@ -1,5 +1,5 @@
import { useMatch } from 'react-router-dom'; import { useMatch } from 'react-router-dom';
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils'; import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '../../pages/pathUtils';
export const useDirectSelected = (): boolean => { export const useDirectSelected = (): boolean => {
const directMatch = useMatch({ const directMatch = useMatch({
@@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => {
return !!match; return !!match;
}; };
export const useDirectSearchSelected = (): boolean => {
const match = useMatch({
path: getDirectSearchPath(),
caseSensitive: true,
end: false,
});
return !!match;
};

View File

@@ -1,89 +1,57 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { getCurrentAccessToken } from '../utils/auth'; import {
getCachedAuthenticatedMediaUrl,
isAuthenticatedMediaUrl,
resolveAuthenticatedMediaUrl,
} from '../utils/authenticatedMediaCache';
/** /**
* Fetches media with authentication and returns a blob URL. * Resolves media URLs for Matrix authenticated media.
* This is needed because service workers may not work reliably in Tauri/WebKit environments, * Uses a shared blob-URL cache so avatars/thumbs are not re-fetched on every remount.
* and cross-origin image requests cannot include Authorization headers.
*/ */
export const useAuthenticatedMediaUrl = ( export const useAuthenticatedMediaUrl = (
src: string | undefined, src: string | undefined,
useAuthentication: boolean useAuthentication: boolean
): string | undefined => { ): string | undefined => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined); const [blobUrl, setBlobUrl] = useState<string | undefined>(() => {
if (!src) return undefined;
if (!useAuthentication || !isAuthenticatedMediaUrl(src)) return src;
return getCachedAuthenticatedMediaUrl(src);
});
useEffect(() => { useEffect(() => {
if (!src) { if (!src) {
setBlobUrl(undefined); setBlobUrl(undefined);
return; return undefined;
} }
// If not using authentication, just return the original URL if (!useAuthentication || !isAuthenticatedMediaUrl(src)) {
if (!useAuthentication) {
setBlobUrl(src); setBlobUrl(src);
return; return undefined;
} }
// Check if this is an authenticated media URL const cached = getCachedAuthenticatedMediaUrl(src);
const isAuthenticatedMediaUrl = if (cached) {
src.includes('/_matrix/client/v1/media/download') || setBlobUrl(cached);
src.includes('/_matrix/client/v1/media/thumbnail') || return undefined;
(src.includes('/_matrix/media/') &&
(src.includes('/download/') || src.includes('/thumbnail/')));
if (!isAuthenticatedMediaUrl) {
setBlobUrl(src);
return;
} }
let cancelled = false; let cancelled = false;
let objectUrl: string | undefined;
const fetchMedia = async () => { resolveAuthenticatedMediaUrl(src, useAuthentication)
try { .then((url) => {
// Always use current session's token to avoid stale tokens during account switches if (!cancelled) setBlobUrl(url);
const accessToken = getCurrentAccessToken(); })
let response = await fetch(src, { .catch((error) => {
method: 'GET', console.warn('[useAuthenticatedMediaUrl] Error fetching authenticated media:', error);
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[useAuthenticatedMediaUrl] Auth failed (401), attempting unauthenticated fallback for:', src);
response = await fetch(src, { method: 'GET' });
}
if (!response.ok) {
console.warn(`Failed to fetch authenticated media: ${response.status}`);
// Fall back to original URL in case server doesn't require auth
if (!cancelled) setBlobUrl(src);
return;
}
const blob = await response.blob();
if (!cancelled) {
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
}
} catch (error) {
console.warn('Error fetching authenticated media:', error);
// Fall back to original URL
if (!cancelled) setBlobUrl(src); if (!cancelled) setBlobUrl(src);
} });
};
fetchMedia();
return () => { return () => {
cancelled = true; cancelled = true;
if (objectUrl) { // Shared cache owns blob URLs — do not revoke on unmount.
URL.revokeObjectURL(objectUrl);
}
}; };
}, [src, useAuthentication, mx]); }, [src, useAuthentication, mx]);
@@ -98,42 +66,11 @@ export const useAuthenticatedMediaFetch = () => {
const mx = useMatrixClient(); const mx = useMatrixClient();
return useCallback( return useCallback(
async (src: string): Promise<string> => { async (src: string, useAuthentication = true): Promise<string> => {
const isAuthenticatedMediaUrl =
src.includes('/_matrix/client/v1/media/download') ||
src.includes('/_matrix/client/v1/media/thumbnail') ||
(src.includes('/_matrix/media/') &&
(src.includes('/download/') || src.includes('/thumbnail/')));
if (!isAuthenticatedMediaUrl) {
return src;
}
try { try {
// Always use current session's token to avoid stale tokens during account switches return await resolveAuthenticatedMediaUrl(src, useAuthentication);
const accessToken = getCurrentAccessToken();
let response = await fetch(src, {
method: 'GET',
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[useAuthenticatedMediaFetch] Auth failed (401), attempting unauthenticated fallback');
response = await fetch(src, { method: 'GET' });
}
if (!response.ok) {
console.warn(`Failed to fetch authenticated media: ${response.status}`);
return src;
}
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) { } catch (error) {
console.warn('Error fetching authenticated media:', error); console.warn('[useAuthenticatedMediaFetch] Error fetching authenticated media:', error);
return src; return src;
} }
}, },

View File

@@ -1,12 +1,30 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useClientConfig } from './useClientConfig'; import { useClientConfig } from './useClientConfig';
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common'; import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
import { isElectron } from '../utils/tauri';
/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
export const ELECTRON_PROTOCOL_HOST = 'desktop';
/**
* Build an absolute URL for the given in-app path.
*
* In Electron, SSO (and other browser handoffs) must return via paarrot://desktop/...
* — a named host so consent UI identifies the desktop app, and a real pathname
* (not a hash) because browsers strip fragments when launching custom protocols.
*/
export const usePathWithOrigin = (path: string): string => { export const usePathWithOrigin = (path: string): string => {
const { hashRouter } = useClientConfig(); const { hashRouter } = useClientConfig();
const { origin } = window.location; const { origin } = window.location;
const pathWithOrigin = useMemo(() => { const pathWithOrigin = useMemo(() => {
if (isElectron()) {
const basename = trimSlash(hashRouter?.basename ?? '');
const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
return `paarrot://${ELECTRON_PROTOCOL_HOST}${route}`;
}
let url: string = trimSlash(origin); let url: string = trimSlash(origin);
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`; url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;

View File

@@ -0,0 +1,256 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import to from 'await-to-js';
import {
Direction,
IRoomEvent,
MatrixEvent,
MsgType,
RelationType,
Room,
} from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { useMatrixClient } from './useMatrixClient';
import { IEncryptedFile } from '../../types/matrix/common';
export type RoomMediaItem = {
eventId: string;
msgtype: typeof MsgType.Image | typeof MsgType.Video;
/** MXC used for an image thumbnail when available. */
thumbMxc?: string;
thumbMimeType?: string;
thumbEncInfo?: EncryptedAttachmentInfo;
/** Full media MXC — used to extract a video frame when no image thumb exists. */
fullMxc: string;
fullMimeType?: string;
fullEncInfo?: EncryptedAttachmentInfo;
body: string;
ts: number;
sender: string;
};
const PAGE_SIZE = 50;
const TARGET_BATCH = 24;
const MAX_PAGES_PER_LOAD = 8;
type RoomMediaCache = {
items: RoomMediaItem[];
hasMore: boolean;
nextToken: string | null;
seenIds: Set<string>;
};
/** Survives MediaDrawer remounts within the same room/session. */
const mediaCache = new Map<string, RoomMediaCache>();
const getOrCreateCache = (roomId: string): RoomMediaCache => {
const existing = mediaCache.get(roomId);
if (existing) return existing;
const created: RoomMediaCache = {
items: [],
hasMore: true,
nextToken: null,
seenIds: new Set(),
};
mediaCache.set(roomId, created);
return created;
};
const toMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => {
if (mEvent.isRedacted()) return undefined;
const relation = mEvent.getRelation();
if (relation?.rel_type === RelationType.Replace) return undefined;
const content = mEvent.getContent();
const msgtype = content.msgtype;
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video) return undefined;
const file = content.file as IEncryptedFile | undefined;
const info = content.info as
| {
mimetype?: string;
thumbnail_url?: string;
thumbnail_info?: { mimetype?: string };
thumbnail_file?: IEncryptedFile;
}
| undefined;
const fullMxc =
(typeof content.url === 'string' && content.url) ||
(typeof file?.url === 'string' && file.url) ||
undefined;
if (!fullMxc) return undefined;
const thumbFile = info?.thumbnail_file;
const thumbMxc =
(typeof info?.thumbnail_url === 'string' && info.thumbnail_url) ||
(typeof thumbFile?.url === 'string' && thumbFile.url) ||
undefined;
const eventId = mEvent.getId();
const sender = mEvent.getSender();
if (!eventId || !sender) return undefined;
const usingThumbEnc = !!thumbMxc && thumbMxc === thumbFile?.url;
return {
eventId,
msgtype,
thumbMxc,
thumbMimeType: usingThumbEnc
? info?.thumbnail_info?.mimetype
: thumbMxc
? info?.thumbnail_info?.mimetype
: undefined,
thumbEncInfo: usingThumbEnc ? thumbFile : undefined,
fullMxc,
fullMimeType: info?.mimetype,
fullEncInfo: file,
body: typeof content.body === 'string' ? content.body : msgtype,
ts: mEvent.getTs(),
sender,
};
};
/**
* Paginates room history for image/video messages without mutating the live timeline.
*/
export const useRoomMediaEvents = (room: Room) => {
const mx = useMatrixClient();
const roomId = room.roomId;
const cacheRef = useRef(getOrCreateCache(roomId));
if (cacheRef.current !== mediaCache.get(roomId)) {
cacheRef.current = getOrCreateCache(roomId);
}
const [items, setItems] = useState<RoomMediaItem[]>(() => cacheRef.current.items);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(() => cacheRef.current.hasMore);
const loadingRef = useRef(false);
const decryptEvent = useCallback(
async (raw: IRoomEvent | MatrixEvent) => {
const mEvent = raw instanceof MatrixEvent ? raw : new MatrixEvent(raw);
if (mEvent.isEncrypted() && mx.getCrypto()) {
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
}
return mEvent;
},
[mx]
);
const fetchPages = useCallback(async () => {
const cache = cacheRef.current;
const collected: RoomMediaItem[] = [];
for (let page = 0; page < MAX_PAGES_PER_LOAD; page += 1) {
const response = await mx.createMessagesRequest(
roomId,
cache.nextToken,
PAGE_SIZE,
Direction.Backward
);
cache.nextToken = response.end ?? null;
if (!response.end) {
cache.hasMore = false;
setHasMore(false);
}
for (const raw of response.chunk ?? []) {
const mEvent = await decryptEvent(raw);
const item = toMediaItem(mEvent);
if (!item || cache.seenIds.has(item.eventId)) continue;
cache.seenIds.add(item.eventId);
collected.push(item);
}
if (collected.length >= TARGET_BATCH || !response.end) break;
}
return collected;
}, [mx, roomId, decryptEvent]);
const loadMore = useCallback(async () => {
const cache = cacheRef.current;
if (loadingRef.current || !cache.hasMore) return;
loadingRef.current = true;
setLoading(true);
try {
const collected = await fetchPages();
if (collected.length > 0) {
cache.items = [...cache.items, ...collected];
setItems(cache.items);
}
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetchPages]);
useEffect(() => {
const cache = getOrCreateCache(roomId);
cacheRef.current = cache;
// Already loaded for this room — restore without re-fetching.
if (cache.items.length > 0 || cache.nextToken !== null || !cache.hasMore) {
setItems(cache.items);
setHasMore(cache.hasMore);
return undefined;
}
let cancelled = false;
const bootstrap = async () => {
cache.seenIds = new Set();
cache.nextToken = null;
cache.hasMore = true;
cache.items = [];
setItems([]);
setHasMore(true);
setLoading(true);
loadingRef.current = true;
try {
const localFound: RoomMediaItem[] = [];
const localEvents = room.getLiveTimeline().getEvents();
for (let i = localEvents.length - 1; i >= 0; i -= 1) {
const mEvent = await decryptEvent(localEvents[i]);
const item = toMediaItem(mEvent);
if (!item || cache.seenIds.has(item.eventId)) continue;
cache.seenIds.add(item.eventId);
localFound.push(item);
}
if (cancelled) return;
if (localFound.length > 0) {
cache.items = localFound;
setItems(localFound);
}
const collected = await fetchPages();
if (cancelled) return;
if (collected.length > 0) {
cache.items = [...cache.items, ...collected];
setItems(cache.items);
}
} finally {
if (!cancelled) {
loadingRef.current = false;
setLoading(false);
}
}
};
bootstrap();
return () => {
cancelled = true;
};
// Only re-bootstrap when switching rooms — not when callback identities change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId]);
return { items, loading, hasMore, loadMore };
};

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { NavigateOptions, useNavigate } from 'react-router-dom'; import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix'; import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import { import {
@@ -7,21 +7,25 @@ import {
getHomeRoomPath, getHomeRoomPath,
getSpacePath, getSpacePath,
getSpaceRoomPath, getSpaceRoomPath,
withRoomEventId,
} from '../pages/pathUtils'; } from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room'; import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents'; import { roomToParentsAtom } from '../state/room/roomToParents';
import { mDirectAtom } from '../state/mDirectList'; import { mDirectAtom } from '../state/mDirectList';
import { useSelectedRoom } from './router/useSelectedRoom';
import { useSelectedSpace } from './router/useSelectedSpace'; import { useSelectedSpace } from './router/useSelectedSpace';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
export const useRoomNavigate = () => { export const useRoomNavigate = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace(); const spaceSelectedId = useSelectedSpace();
const selectedRoomId = useSelectedRoom();
const [developerTools] = useSetting(settingsAtom, 'developerTools'); const [developerTools] = useSetting(settingsAtom, 'developerTools');
const navigateSpace = useCallback( const navigateSpace = useCallback(
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
const navigateRoom = useCallback( const navigateRoom = useCallback(
(roomId: string, eventId?: string, opts?: NavigateOptions) => { (roomId: string, eventId?: string, opts?: NavigateOptions) => {
// Already viewing this room — keep the current home/direct/space URL and only
// change the event id. Otherwise guessPerfectParent can yank the sidebar to
// wherever the room "belongs".
if (selectedRoomId === roomId) {
navigate(withRoomEventId(location.pathname, eventId), opts);
return;
}
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId; const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
@@ -68,7 +80,16 @@ export const useRoomNavigate = () => {
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
}, },
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools] [
mx,
navigate,
location.pathname,
selectedRoomId,
spaceSelectedId,
roomToParents,
mDirects,
developerTools,
]
); );
return { return {

View File

@@ -0,0 +1,60 @@
import { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { Room } from 'matrix-js-sdk';
import {
roomIdToUploadItemsAtomFamily,
TUploadItem,
} from '../state/room/roomInputDrafts';
import { encryptFile } from '../utils/matrix';
import { safeFile } from '../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../utils/common';
import { useFilePicker } from './useFilePicker';
/**
* Enqueues files into the room composer upload board (shared by RoomInput and header media).
*/
export const useRoomUploadFiles = (room: Room) => {
const setSelectedFiles = useSetAtom(roomIdToUploadItemsAtomFamily(room.roomId));
const handleFiles = useCallback(
async (files: File[]) => {
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
},
})
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
})
);
}
setSelectedFiles({
type: 'PUT',
item: fileItems,
});
},
[setSelectedFiles, room]
);
const pickFiles = useFilePicker(handleFiles, true);
return { handleFiles, pickFiles };
};

View File

@@ -1,7 +1,7 @@
import { lightTheme } from 'folds'; import { lightTheme } from 'folds';
import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css'; import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI'; import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
@@ -17,6 +17,11 @@ export type Theme = {
classNames: string[]; classNames: string[];
}; };
export const isStationeryTheme = (theme: Theme | string): boolean => {
const id = typeof theme === 'string' ? theme : theme.id;
return id === 'stationery-theme' || id === 'stationery-dark-theme';
};
export const LightTheme: Theme = { export const LightTheme: Theme = {
id: 'light-theme', id: 'light-theme',
kind: ThemeKind.Light, kind: ThemeKind.Light,
@@ -63,6 +68,16 @@ export const CatppuccinMochaTheme: Theme = {
kind: ThemeKind.Dark, kind: ThemeKind.Dark,
classNames: ['catppuccin-mocha-theme', catppuccinMochaTheme, onDarkFontWeight, 'prism-dark'], classNames: ['catppuccin-mocha-theme', catppuccinMochaTheme, onDarkFontWeight, 'prism-dark'],
}; };
export const StationeryTheme: Theme = {
id: 'stationery-theme',
kind: ThemeKind.Light,
classNames: ['stationery-theme', 'stationery', stationeryTheme, onLightFontWeight, 'prism-light'],
};
export const StationeryDarkTheme: Theme = {
id: 'stationery-dark-theme',
kind: ThemeKind.Dark,
classNames: ['stationery-dark-theme', 'stationery', stationeryDarkTheme, onDarkFontWeight, 'prism-dark'],
};
export const useThemes = (): Theme[] => { export const useThemes = (): Theme[] => {
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0); const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
@@ -76,7 +91,7 @@ export const useThemes = (): Theme[] => {
}, []); }, []);
const builtInThemes: Theme[] = useMemo(() => [ const builtInThemes: Theme[] = useMemo(() => [
LightTheme, SilverTheme, DarkTheme, ButterTheme, LightTheme, SilverTheme, StationeryTheme, StationeryDarkTheme, DarkTheme, ButterTheme,
DiscordTheme, DiscordDarkerTheme, TwilightTheme, DiscordTheme, DiscordDarkerTheme, TwilightTheme,
MochaTheme, CatppuccinMochaTheme MochaTheme, CatppuccinMochaTheme
], []); ], []);
@@ -112,6 +127,8 @@ export const useThemeNames = (): Record<string, string> => {
const builtInNames = { const builtInNames = {
[LightTheme.id]: 'Light', [LightTheme.id]: 'Light',
[SilverTheme.id]: 'Silver', [SilverTheme.id]: 'Silver',
[StationeryTheme.id]: 'Stationery',
[StationeryDarkTheme.id]: 'Stationery Dark',
[DarkTheme.id]: 'Dark', [DarkTheme.id]: 'Dark',
[ButterTheme.id]: 'Butter', [ButterTheme.id]: 'Butter',
[DiscordTheme.id]: 'Discord', [DiscordTheme.id]: 'Discord',

View File

@@ -41,7 +41,7 @@ import {
} from './pathUtils'; } from './pathUtils';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { Direct, DirectCreate, DirectRouteRoomProvider, DirectSearch } from './client/direct';
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space'; import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout'; import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
import { ForumFeedPage } from '../features/forum/ForumFeedPage'; import { ForumFeedPage } from '../features/forum/ForumFeedPage';
@@ -223,6 +223,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
> >
{mobile ? null : <Route index element={<WelcomePage />} />} {mobile ? null : <Route index element={<WelcomePage />} />}
<Route path={_CREATE_PATH} element={<DirectCreate />} /> <Route path={_CREATE_PATH} element={<DirectCreate />} />
<Route path={_SEARCH_PATH} element={<DirectSearch />} />
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} /> <Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
<Route path={_INVITES_PATH} element={<Invites />} /> <Route path={_INVITES_PATH} element={<Invites />} />
<Route <Route

View File

@@ -378,6 +378,14 @@ function MessageNotifications() {
return; return;
} }
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
const notificationType = getNotificationType(mx, room.roomId);
const isDm = mDirects.has(room.roomId);
const mentionsOnly =
notificationType === NotificationType.MentionsAndKeywords ||
(notificationType === NotificationType.Default && !isDm);
if (mentionsOnly && unreadInfo.highlight === 0) return;
if ( if (
showNotifications && showNotifications &&
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted')) ((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
@@ -399,7 +407,6 @@ function MessageNotifications() {
messageBody = typeof content.body === 'string' ? content.body : undefined; messageBody = typeof content.body === 'string' ? content.body : undefined;
} }
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
notify({ notify({
roomName: room.name ?? 'Unknown', roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc roomAvatar: avatarMxc
@@ -430,6 +437,7 @@ function MessageNotifications() {
notify, notify,
selectedRoomId, selectedRoomId,
useAuthentication, useAuthentication,
mDirects,
]); ]);
return null; return null;

View File

@@ -29,7 +29,20 @@ export function SidebarNav() {
<Sidebar> <Sidebar>
<SidebarContent <SidebarContent
scrollable={ scrollable={
<Scroll ref={scrollRef} variant="Background" size="0"> <Scroll
ref={scrollRef}
variant="Background"
size="0"
data-sidebar-scroll=""
style={{
marginRight: -25,
paddingRight: 25,
width: 'calc(100% + 25px)',
boxSizing: 'border-box',
overflowX: 'hidden',
overflowY: 'auto',
}}
>
<SidebarStack> <SidebarStack>
{!homeHidden && <HomeTab />} {!homeHidden && <HomeTab />}
<DirectTab /> <DirectTab />

View File

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

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 './Direct';
export * from './RoomProvider'; export * from './RoomProvider';
export * from './DirectCreate'; export * from './DirectCreate';
export * from './Search';

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import React, {
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { Avatar, Box, Button, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Spinner, Text, color, config, toRem } from 'folds'; import { Avatar, Box, Button, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Spinner, Text, color, config, toRem } from 'folds';
import { Icon, Icons } from '../../../components/icons'; import { Icon, Icons } from '../../../components/icons';
import { useVirtualizer } from '@tanstack/react-virtual';
import { JoinRule, Room, Thread, ThreadEvent } from 'matrix-js-sdk'; import { JoinRule, Room, Thread, ThreadEvent } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
@@ -30,7 +29,6 @@ import {
useSpaceSearchSelected, useSpaceSearchSelected,
} from '../../../hooks/router/useSelectedSpace'; } from '../../../hooks/router/useSelectedSpace';
import { useSpace } from '../../../hooks/useSpace'; import { useSpace } from '../../../hooks/useSpace';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem, UnjoinedSubRoomItem } from '../../../features/room-nav'; import { RoomNavCategoryButton, RoomNavItem, UnjoinedSubRoomItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories'; import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
@@ -74,6 +72,119 @@ import { SpaceOptionsMenu } from '../../../features/space/SpaceOptionsMenu';
import { ForumFeedSidebar } from '../../../features/forum/ForumFeedSidebar'; import { ForumFeedSidebar } from '../../../features/forum/ForumFeedSidebar';
import * as forumBoardCss from '../../../features/forum/ForumBoardView.css'; import * as forumBoardCss from '../../../features/forum/ForumBoardView.css';
type SpaceHierarchyItem =
| { type: 'hierarchy'; roomId: string }
| { type: 'subroom'; roomId: string; room: Room; depth: number; isLast?: boolean }
| { type: 'unjoined-subroom'; roomId: string; depth: number; isLast?: boolean };
type SpaceNavSectionData = {
spaceRoomId: string;
categoryId: string;
label: string;
items: SpaceHierarchyItem[];
};
type SpaceNavSectionProps = {
section: SpaceNavSectionData;
closed: boolean;
onCategoryClick: MouseEventHandler<HTMLButtonElement>;
selectedRoomId?: string;
mDirects: Set<string>;
notificationPreferences: ReturnType<typeof useRoomsNotificationPreferencesContext>;
getToLink: (roomId: string) => string;
};
function SpaceNavSection({
section,
closed,
onCategoryClick,
selectedRoomId,
mDirects,
notificationPreferences,
getToLink,
}: SpaceNavSectionProps) {
const mx = useMatrixClient();
return (
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={section.categoryId}
onClick={onCategoryClick}
closed={closed}
>
{section.label}
</RoomNavCategoryButton>
</NavCategoryHeader>
{section.items.length > 0 && (
<div data-nav-rooms="">
<div data-nav-room-list="">
{section.items.map((item, index) => {
if (item.type === 'unjoined-subroom') {
return (
<UnjoinedSubRoomItem
key={`${item.roomId}:${index}`}
roomId={item.roomId}
depth={item.depth}
isLast={item.isLast}
/>
);
}
if (item.type === 'subroom') {
return (
<div
key={`${item.roomId}:${index}`}
data-nav-depth={item.depth}
style={{
display: 'flex',
alignItems: 'center',
position: 'relative',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<RoomNavItem
room={item.room}
selected={selectedRoomId === item.roomId}
hideIcon={item.depth > 0}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(
notificationPreferences,
item.roomId
)}
/>
</div>
</div>
);
}
const room = mx.getRoom(item.roomId);
if (!room || isSpace(room)) return null;
return (
<RoomNavItem
key={`${item.roomId}:${index}`}
room={room}
selected={selectedRoomId === item.roomId}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
);
})}
</div>
</div>
)}
</NavCategory>
);
}
function SpaceHeader() { function SpaceHeader() {
const space = useSpace(); const space = useSpace();
const spaceName = useRoomName(space); const spaceName = useRoomName(space);
@@ -272,61 +383,74 @@ export function Space() {
return hierarchy.filter((entry) => !globalSubRoomIds.has(entry.roomId)); return hierarchy.filter((entry) => !globalSubRoomIds.has(entry.roomId));
}, [hierarchy, globalSubRoomIds]); }, [hierarchy, globalSubRoomIds]);
// Flatten hierarchy to include sub-rooms // One paper dropdown per space: space header + its rooms/sub-rooms
type HierarchyItem = const navSections = useMemo(() => {
| { type: 'hierarchy'; roomId: string } const sections: SpaceNavSectionData[] = [];
| { type: 'subroom'; roomId: string; room: Room; depth: number; isLast?: boolean } let current: SpaceNavSectionData | null = null;
| { type: 'unjoined-subroom'; roomId: string; depth: number; isLast?: boolean };
const flattenedHierarchy = useMemo(() => {
const items: HierarchyItem[] = [];
const processedSubRooms = new Set<string>(); const processedSubRooms = new Set<string>();
const addSubRooms = (parentRoomId: string, baseDepth: number) => { const addSubRooms = (parentRoomId: string, baseDepth: number, into: SpaceHierarchyItem[]) => {
const room = mx.getRoom(parentRoomId); const room = mx.getRoom(parentRoomId);
if (!room || isSpace(room)) return; if (!room || isSpace(room)) return;
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? []; const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
// Filter to only joined sub-rooms that haven't been processed const joinedSubRooms = subRooms.filter(
const joinedSubRooms = subRooms.filter(subRoomId => (subRoomId) => !processedSubRooms.has(subRoomId) && mx.getRoom(subRoomId)
!processedSubRooms.has(subRoomId) && mx.getRoom(subRoomId)
); );
joinedSubRooms.forEach((subRoomId, index) => { joinedSubRooms.forEach((subRoomId, index) => {
processedSubRooms.add(subRoomId); processedSubRooms.add(subRoomId);
const isLast = index === joinedSubRooms.length - 1; const isLast = index === joinedSubRooms.length - 1;
const subRoom = mx.getRoom(subRoomId); const subRoom = mx.getRoom(subRoomId);
if (subRoom) { if (!subRoom) return;
// Joined sub-room into.push({
items.push({ type: 'subroom', roomId: subRoomId, room: subRoom, depth: baseDepth + 1, isLast }); type: 'subroom',
// Recursively add sub-rooms of sub-rooms roomId: subRoomId,
addSubRooms(subRoomId, baseDepth + 1); room: subRoom,
} depth: baseDepth + 1,
isLast,
});
addSubRooms(subRoomId, baseDepth + 1, into);
}); });
}; };
const startSection = (spaceRoomId: string, label: string) => {
current = {
spaceRoomId,
categoryId: makeNavCategoryId(space.roomId, spaceRoomId),
label,
items: [],
};
sections.push(current);
return current;
};
filteredHierarchy.forEach((entry) => { filteredHierarchy.forEach((entry) => {
items.push({ type: 'hierarchy', roomId: entry.roomId }); const isSpaceEntry = 'space' in entry && entry.space === true;
// Add sub-rooms after each non-space room
const room = mx.getRoom(entry.roomId); const room = mx.getRoom(entry.roomId);
if (isSpaceEntry || (room && isSpace(room))) {
startSection(
entry.roomId,
entry.roomId === space.roomId ? 'Rooms' : room?.name ?? entry.roomId
);
return;
}
if (!current) {
startSection(space.roomId, 'Rooms');
}
current!.items.push({ type: 'hierarchy', roomId: entry.roomId });
if (room && !isSpace(room)) { if (room && !isSpace(room)) {
addSubRooms(entry.roomId, 0); addSubRooms(entry.roomId, 0, current!.items);
} }
}); });
return items; return sections;
}, [mx, filteredHierarchy]); }, [mx, filteredHierarchy, space.roomId]);
const virtualizer = useVirtualizer({
count: flattenedHierarchy.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId) closedCategories.has(categoryId)
@@ -410,110 +534,20 @@ export function Space() {
</NavItem> </NavItem>
</> </>
)} )}
<PluginNavSlot location="channel-list" /> <PluginNavSlot location="channel-list" />
</NavCategory>
<NavCategory
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const item = flattenedHierarchy[vItem.index];
if (!item) return null;
// Unjoined sub-room item
if (item.type === 'unjoined-subroom') {
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} isLast={item.isLast} />
</VirtualTile>
);
}
// Sub-room item (nested under parent)
if (item.type === 'subroom') {
const paddingLeft = '30px';
let treeIcon = '';
if (item.depth > 0) {
treeIcon = item.isLast ? '╰' : '├';
}
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: '1.5rem' }}>
{treeIcon && (
<span style={{
paddingRight: '2px',
opacity: 0.5,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: '1',
userSelect: 'none'
}}>
{treeIcon}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<RoomNavItem
room={item.room}
selected={selectedRoomId === item.roomId}
hideIcon={item.depth > 0}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)}
/>
</div>
</div>
</VirtualTile>
);
}
// Hierarchy item (room or space)
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
if (isSpace(room)) {
const categoryId = makeNavCategoryId(space.roomId, roomId);
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={categoryId}
onClick={handleCategoryClick}
closed={closedCategories.has(categoryId)}
>
{roomId === space.roomId ? 'Rooms' : room?.name}
</RoomNavCategoryButton>
</NavCategoryHeader>
</div>
</VirtualTile>
);
}
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selectedRoomId === roomId}
showAvatar={mDirects.has(roomId)}
direct={mDirects.has(roomId)}
linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
</VirtualTile>
);
})}
</NavCategory> </NavCategory>
{navSections.map((section) => (
<SpaceNavSection
key={section.categoryId}
section={section}
closed={closedCategories.has(section.categoryId)}
onCategoryClick={handleCategoryClick}
selectedRoomId={selectedRoomId}
mDirects={mDirects}
notificationPreferences={notificationPreferences}
getToLink={getToLink}
/>
))}
</Box> </Box>
</PageNavContent> </PageNavContent>
</PageNav> </PageNav>

View File

@@ -3,6 +3,7 @@ import {
DIRECT_CREATE_PATH, DIRECT_CREATE_PATH,
DIRECT_PATH, DIRECT_PATH,
DIRECT_ROOM_PATH, DIRECT_ROOM_PATH,
DIRECT_SEARCH_PATH,
DIRECT_NOTIFICATIONS_PATH, DIRECT_NOTIFICATIONS_PATH,
DIRECT_INVITES_PATH, DIRECT_INVITES_PATH,
EXPLORE_FEATURED_PATH, EXPLORE_FEATURED_PATH,
@@ -121,6 +122,7 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string
export const getDirectPath = (): string => DIRECT_PATH; export const getDirectPath = (): string => DIRECT_PATH;
export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH; export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH;
export const getDirectSearchPath = (): string => DIRECT_SEARCH_PATH;
export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => { export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
const params = { const params = {
roomIdOrAlias, roomIdOrAlias,
@@ -180,6 +182,28 @@ export const getSpaceRoomPath = (
return generatePath(SPACE_ROOM_PATH, params); return generatePath(SPACE_ROOM_PATH, params);
}; };
/**
* Keep the current room URL (home / direct / space) and only add, replace, or clear
* the optional `$eventId` segment. Used so same-room jumps (media, permalinks) don't
* re-home the room into a different sidebar section.
*/
export const withRoomEventId = (pathname: string, eventId?: string): string => {
const parts = pathname.split('/').filter(Boolean);
if (parts.length > 0) {
const lastDecoded = decodeRouteParam(parts[parts.length - 1]);
if (lastDecoded?.startsWith('$')) {
parts.pop();
}
}
if (eventId) {
parts.push(encodeURIComponent(eventId));
}
return `/${parts.join('/')}/`;
};
export const getExplorePath = (): string => EXPLORE_PATH; export const getExplorePath = (): string => EXPLORE_PATH;
export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH; export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH;
export const getExploreServerPath = (server: string): string => { export const getExploreServerPath = (server: string): string => {

View File

@@ -54,6 +54,7 @@ export type DirectCreateSearchParams = {
userId?: string; userId?: string;
}; };
export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`; export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`;
export const DIRECT_SEARCH_PATH = `/direct/${_SEARCH_PATH}`;
export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`; export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`;
export const SPACE_PATH = '/:spaceIdOrAlias/'; export const SPACE_PATH = '/:spaceIdOrAlias/';

View File

@@ -31,7 +31,7 @@ import {
mxcUrlToHttp, mxcUrlToHttp,
} from '../utils/matrix'; } from '../utils/matrix';
import { getMemberDisplayName } from '../utils/room'; import { getMemberDisplayName } from '../utils/room';
import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '../utils/regex'; import { EMOJI_REG_G, sanitizeForRegex } from '../utils/regex';
import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
import { findAndReplace } from '../utils/findAndReplace'; import { findAndReplace } from '../utils/findAndReplace';
import { import {
@@ -48,8 +48,6 @@ import { openExternalUrl } from '../utils/tauri';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
/** /**
* Click handler for external links - uses Tauri shell on desktop/mobile * Click handler for external links - uses Tauri shell on desktop/mobile
*/ */
@@ -106,6 +104,7 @@ export const renderMatrixMention = (
{...customProps} {...customProps}
className={css.Mention({ highlight: mx.getUserId() === userId })} className={css.Mention({ highlight: mx.getUserId() === userId })}
data-mention-id={userId} data-mention-id={userId}
data-mention-self={mx.getUserId() === userId ? '' : undefined}
> >
{`@${ {`@${
(currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId) (currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId)
@@ -215,7 +214,7 @@ export const highlightText = (
text, text,
regex, regex,
(match, pushIndex) => ( (match, pushIndex) => (
<span key={`highlight-${pushIndex}`} className={css.highlightText}> <span key={`highlight-${pushIndex}`} className={css.highlightText} data-text-highlight="">
{match[0]} {match[0]}
</span> </span>
), ),

View File

@@ -0,0 +1,168 @@
import { encodeBlurHash, validBlurHash } from '../utils/blurHash';
import { fitMediaSize } from '../utils/common';
const STORAGE_KEY = 'paarrot.media.meta';
const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions';
const MAX_ENTRIES = 500;
/** Same max box as MImage / MVideo attachment rendering. */
export const ATTACHMENT_MAX_SIZE = 400;
export type MediaMeta = {
w: number;
h: number;
blurHash?: string;
};
export type MediaDimensions = Pick<MediaMeta, 'w' | 'h'>;
type CacheMap = Record<string, MediaMeta>;
let memoryCache: CacheMap | null = null;
const loadCache = (): CacheMap => {
if (memoryCache) return memoryCache;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
memoryCache = JSON.parse(raw) as CacheMap;
return memoryCache;
}
// Migrate older dimensions-only cache once.
const legacy = localStorage.getItem(LEGACY_DIMENSIONS_KEY);
if (legacy) {
memoryCache = JSON.parse(legacy) as CacheMap;
localStorage.setItem(STORAGE_KEY, legacy);
localStorage.removeItem(LEGACY_DIMENSIONS_KEY);
return memoryCache;
}
} catch {
// ignore
}
memoryCache = {};
return memoryCache;
};
const persistCache = (cache: CacheMap) => {
memoryCache = cache;
try {
const keys = Object.keys(cache);
if (keys.length > MAX_ENTRIES) {
const trimmed: CacheMap = {};
keys.slice(keys.length - MAX_ENTRIES).forEach((key) => {
trimmed[key] = cache[key];
});
memoryCache = trimmed;
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
return;
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
} catch {
// Quota / private mode — keep in-memory only.
}
};
const touchEntry = (mxcUrl: string, patch: Partial<MediaMeta>): void => {
if (!mxcUrl) return;
const cache = loadCache();
const prev = cache[mxcUrl];
const next: MediaMeta = {
w: patch.w ?? prev?.w ?? 0,
h: patch.h ?? prev?.h ?? 0,
blurHash: patch.blurHash ?? prev?.blurHash,
};
if (next.w <= 0 || next.h <= 0) return;
if (
prev &&
prev.w === next.w &&
prev.h === next.h &&
prev.blurHash === next.blurHash
) {
return;
}
if (prev) delete cache[mxcUrl];
cache[mxcUrl] = next;
persistCache(cache);
};
export const getMediaDimensions = (mxcUrl: string): MediaDimensions | undefined => {
if (!mxcUrl) return undefined;
const entry = loadCache()[mxcUrl];
if (!entry || entry.w <= 0 || entry.h <= 0) return undefined;
return { w: entry.w, h: entry.h };
};
/** Fit attachment box size using event info, then local dimension cache. */
export const resolveAttachmentBoxSize = (
mxcUrl: string | undefined,
w?: number,
h?: number,
maxSize: number = ATTACHMENT_MAX_SIZE
): { width: number; height: number } => {
const cached = mxcUrl && (!w || !h) ? getMediaDimensions(mxcUrl) : undefined;
const naturalW = w && w > 0 ? w : cached?.w;
const naturalH = h && h > 0 ? h : cached?.h;
return fitMediaSize(naturalW ?? 0, naturalH ?? 0, maxSize, maxSize);
};
export const setMediaDimensions = (mxcUrl: string, w: number, h: number): void => {
if (!mxcUrl || w <= 0 || h <= 0) return;
touchEntry(mxcUrl, { w, h });
};
export const getMediaBlurHash = (mxcUrl: string): string | undefined => {
if (!mxcUrl) return undefined;
return validBlurHash(loadCache()[mxcUrl]?.blurHash);
};
export const setMediaBlurHash = (mxcUrl: string, blurHash: string, w?: number, h?: number): void => {
const hash = validBlurHash(blurHash);
if (!mxcUrl || !hash) return;
const cache = loadCache();
const prev = cache[mxcUrl];
const nextW = w && w > 0 ? w : prev?.w;
const nextH = h && h > 0 ? h : prev?.h;
if (!nextW || !nextH) {
// Dimensions required for the shared meta entry; skip until we know size.
return;
}
touchEntry(mxcUrl, { w: nextW, h: nextH, blurHash: hash });
};
/** Encode + cache a blurhash from a loaded image/video (idle, non-blocking). */
export const rememberMediaBlurHash = (
mxcUrl: string,
media: HTMLImageElement | HTMLVideoElement
): void => {
if (!mxcUrl || typeof window === 'undefined') return;
const run = () => {
try {
const w =
media instanceof HTMLVideoElement
? media.videoWidth
: media.naturalWidth || media.width;
const h =
media instanceof HTMLVideoElement
? media.videoHeight
: media.naturalHeight || media.height;
if (w <= 0 || h <= 0) return;
setMediaDimensions(mxcUrl, w, h);
if (getMediaBlurHash(mxcUrl)) return;
const encW = 32;
const encH = Math.max(1, Math.round(encW * (h / w)));
const hash = encodeBlurHash(media, encW, encH);
if (hash) setMediaBlurHash(mxcUrl, hash, w, h);
} catch {
// Encoding can fail for tainted canvases; ignore.
}
};
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(() => run(), { timeout: 1500 });
} else {
window.setTimeout(run, 0);
}
};

View File

@@ -0,0 +1,17 @@
import { atom } from 'jotai';
/** Session flag for the Shared Media side drawer (mutually exclusive with Members). */
export const isMediaDrawerAtom = atom(false);
const scrollTops = new Map<string, number>();
export const getMediaDrawerScrollTop = (roomId: string): number | undefined =>
scrollTops.get(roomId);
export const setMediaDrawerScrollTop = (roomId: string, top: number): void => {
scrollTops.set(roomId, top);
};
export const clearMediaDrawerScrollTop = (roomId: string): void => {
scrollTops.delete(roomId);
};

View File

@@ -0,0 +1,46 @@
export type RoomScrollRange = {
start: number;
end: number;
};
export type RoomScrollState = {
top: number;
atBottom: boolean;
range: RoomScrollRange;
};
const cache = new Map<string, RoomScrollState>();
const MAX_ENTRIES = 20;
export const getRoomScrollState = (roomId: string): RoomScrollState | undefined =>
cache.get(roomId);
export const saveRoomScrollState = (roomId: string, state: RoomScrollState): void => {
if (cache.has(roomId)) cache.delete(roomId);
cache.set(roomId, state);
while (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value as string | undefined;
if (!oldest) break;
cache.delete(oldest);
}
};
export const clearRoomScrollState = (roomId: string): void => {
cache.delete(roomId);
};
/** Survives RoomTimeline remounts (e.g. clearing an event permalink URL). */
const returnAnchors = new Map<string, string>();
export const getRoomReturnAnchor = (roomId: string): string | undefined =>
returnAnchors.get(roomId);
export const setRoomReturnAnchor = (roomId: string, eventId: string): void => {
returnAnchors.set(roomId, eventId);
};
export const clearRoomReturnAnchor = (roomId: string): void => {
returnAnchors.delete(roomId);
};

View File

@@ -262,9 +262,10 @@ export const EmoticonBase = style([
DefaultReset, DefaultReset,
{ {
display: 'inline-block', display: 'inline-block',
padding: '0.05rem', verticalAlign: '-0.1em',
height: '1em', lineHeight: 1,
verticalAlign: 'middle', height: 'auto',
padding: 0,
}, },
]); ]);
@@ -272,18 +273,10 @@ export const Emoticon = recipe({
base: [ base: [
DefaultReset, DefaultReset,
{ {
display: 'inline-flex', display: 'inline',
justifyContent: 'center', fontSize: '1.2em',
alignItems: 'center', lineHeight: 1,
verticalAlign: 'inherit',
height: '1em',
minWidth: '1em',
fontSize: '1.33em',
lineHeight: '1em',
verticalAlign: 'middle',
position: 'relative',
top: '-0.35em',
borderRadius: config.radii.R300,
}, },
], ],
variants: { variants: {
@@ -298,7 +291,9 @@ export const Emoticon = recipe({
export const EmoticonImg = style([ export const EmoticonImg = style([
DefaultReset, DefaultReset,
{ {
height: '1em', display: 'block',
height: '1.2em',
width: '1.2em',
cursor: 'default', cursor: 'default',
}, },
]); ]);

View File

@@ -0,0 +1,87 @@
import { getCurrentAccessToken } from './auth';
const MAX_ENTRIES = 250;
/** Shared blob URLs for authenticated Matrix media (avatars, thumbs, etc.). */
const blobCache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
export const isAuthenticatedMediaUrl = (url: string): boolean =>
url.includes('/_matrix/client/v1/media/download') ||
url.includes('/_matrix/client/v1/media/thumbnail') ||
(url.includes('/_matrix/media/') &&
(url.includes('/download/') || url.includes('/thumbnail/')));
export const getCachedAuthenticatedMediaUrl = (src: string | undefined): string | undefined => {
if (!src) return undefined;
return blobCache.get(src);
};
const touch = (src: string, blobUrl: string) => {
// Re-insert for LRU ordering (Map preserves insertion order).
if (blobCache.has(src)) blobCache.delete(src);
blobCache.set(src, blobUrl);
while (blobCache.size > MAX_ENTRIES) {
const oldest = blobCache.keys().next().value as string | undefined;
if (!oldest) break;
const oldUrl = blobCache.get(oldest);
blobCache.delete(oldest);
if (oldUrl) URL.revokeObjectURL(oldUrl);
}
};
const fetchBlobUrl = async (src: string): Promise<string> => {
const accessToken = getCurrentAccessToken();
let response = await fetch(src, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok && response.status === 401 && accessToken) {
response = await fetch(src, { method: 'GET' });
}
if (!response.ok) {
throw new Error(`Failed to fetch authenticated media: ${response.status}`);
}
const blob = await response.blob();
return URL.createObjectURL(blob);
};
/**
* Returns a stable blob: URL for authenticated media, reusing cache across mounts.
* Non-auth URLs are returned as-is.
*/
export const resolveAuthenticatedMediaUrl = async (
src: string,
useAuthentication: boolean
): Promise<string> => {
if (!useAuthentication || !isAuthenticatedMediaUrl(src)) {
return src;
}
const cached = blobCache.get(src);
if (cached) {
touch(src, cached);
return cached;
}
let pending = inflight.get(src);
if (!pending) {
pending = fetchBlobUrl(src)
.then((blobUrl) => {
touch(src, blobUrl);
inflight.delete(src);
return blobUrl;
})
.catch((err) => {
inflight.delete(src);
throw err;
});
inflight.set(src, pending);
}
return pending;
};

View File

@@ -87,6 +87,23 @@ export const scaleYDimension = (x: number, scaledX: number, y: number): number =
return scaleFactor * y; return scaleFactor * y;
}; };
/** Fit natural media size into a max box without upscaling. */
export const fitMediaSize = (
w: number,
h: number,
maxW: number,
maxH: number
): { width: number; height: number } => {
if (w <= 0 || h <= 0) {
return { width: maxW, height: Math.round(maxW * 0.75) };
}
const scale = Math.min(maxW / w, maxH / h, 1);
return {
width: Math.max(1, Math.round(w * scale)),
height: Math.max(1, Math.round(h * scale)),
};
};
export const parseGeoUri = (location: string) => { export const parseGeoUri = (location: string) => {
const [, data] = location.split(':'); const [, data] = location.split(':');
const [cords] = data.split(';'); const [cords] = data.split(';');

View File

@@ -13,13 +13,18 @@ export const findAndReplace = <ReplaceReturnType, ConvertReturnType>(
const result: Array<ReplaceReturnType | ConvertReturnType> = []; const result: Array<ReplaceReturnType | ConvertReturnType> = [];
let lastEnd = 0; let lastEnd = 0;
regex.lastIndex = 0;
let match: RegExpExecArray | RegExpMatchArray | null = regex.exec(text); let match: RegExpExecArray | RegExpMatchArray | null = regex.exec(text);
while (match !== null && typeof match.index === 'number') { while (match !== null && typeof match.index === 'number') {
result.push(convertPart(text.slice(lastEnd, match.index), result.length)); result.push(convertPart(text.slice(lastEnd, match.index), result.length));
result.push(replace(match, result.length)); result.push(replace(match, result.length));
lastEnd = match.index + match[0].length; lastEnd = match.index + match[0].length;
if (match[0].length === 0) {
regex.lastIndex += 1;
}
if (regex.global) match = regex.exec(text); if (regex.global) match = regex.exec(text);
else break;
} }
result.push(convertPart(text.slice(lastEnd), result.length)); result.push(convertPart(text.slice(lastEnd), result.length));

View File

@@ -0,0 +1,2 @@
/** Stationery paper ink for display names (follows --stationery-ink) */
export const STATIONERY_NAME_INK = 'var(--stationery-ink)';

File diff suppressed because one or more lines are too long

View File

@@ -722,3 +722,199 @@ export const catppuccinMochaTheme = createTheme(color, {
}, },
}); });
/** Notebook / stationery desk — paper, ink, washi accents */
export const stationeryTheme = createTheme(color, {
Background: {
Container: '#D9D0C0',
ContainerHover: '#CFC5B3',
ContainerActive: '#C4B9A5',
ContainerLine: '#B8AC96',
OnContainer: '#2E2A24',
},
Surface: {
Container: '#FFFCF5',
ContainerHover: '#F5F0E6',
ContainerActive: '#EBE4D6',
ContainerLine: '#DDD4C4',
OnContainer: '#2E2A24',
},
SurfaceVariant: {
Container: '#F5F0E6',
ContainerHover: '#EBE4D6',
ContainerActive: '#DDD4C4',
ContainerLine: '#CFC5B3',
OnContainer: '#2E2A24',
},
Primary: {
Main: '#3D7A5F',
MainHover: '#356B53',
MainActive: '#2E5C47',
MainLine: '#274E3C',
OnMain: '#FFFCF5',
Container: '#C8DFD2',
ContainerHover: '#B8D4C4',
ContainerActive: '#A8C9B6',
ContainerLine: '#98BEA8',
OnContainer: '#1E3D2F',
},
Secondary: {
Main: '#2E2A24',
MainHover: '#3F3A32',
MainActive: '#4A443B',
MainLine: '#554E44',
OnMain: '#FFFCF5',
Container: '#DDD4C4',
ContainerHover: '#CFC5B3',
ContainerActive: '#C4B9A5',
ContainerLine: '#B8AC96',
OnContainer: '#2E2A24',
},
Success: {
Main: '#2F7A4A',
MainHover: '#296B41',
MainActive: '#235C38',
MainLine: '#1E4E30',
OnMain: '#FFFCF5',
Container: '#C5E0CF',
ContainerHover: '#B5D6C1',
ContainerActive: '#A5CCB3',
ContainerLine: '#95C2A5',
OnContainer: '#1A4028',
},
Warning: {
Main: '#B87820',
MainHover: '#A66B1C',
MainActive: '#945E19',
MainLine: '#825215',
OnMain: '#FFFCF5',
Container: '#F5E4C0',
ContainerHover: '#F0DBAD',
ContainerActive: '#EBD29A',
ContainerLine: '#E6C987',
OnContainer: '#5C3C10',
},
Critical: {
Main: '#B83A3A',
MainHover: '#A63434',
MainActive: '#942E2E',
MainLine: '#822828',
OnMain: '#FFFCF5',
Container: '#F0C8C8',
ContainerHover: '#EBB8B8',
ContainerActive: '#E6A8A8',
ContainerLine: '#E19898',
OnContainer: '#5C1D1D',
},
Other: {
FocusRing: 'rgba(61, 122, 95, 0.45)',
Shadow: 'rgba(46, 42, 36, 0.18)',
Overlay: 'rgba(46, 42, 36, 0.45)',
},
});
/** Stationery dark — Catppuccin Mocha colors + stationery chrome */
export const stationeryDarkTheme = createTheme(color, {
Background: {
Container: '#11111b',
ContainerHover: '#181825',
ContainerActive: '#1e1e2e',
ContainerLine: '#313244',
OnContainer: '#cdd6f4',
},
Surface: {
Container: '#1e1e2e',
ContainerHover: '#313244',
ContainerActive: '#45475a',
ContainerLine: '#585b70',
OnContainer: '#cdd6f4',
},
SurfaceVariant: {
Container: '#313244',
ContainerHover: '#45475a',
ContainerActive: '#585b70',
ContainerLine: '#6c7086',
OnContainer: '#cdd6f4',
},
Primary: {
Main: '#cba6f7',
MainHover: '#b894e0',
MainActive: '#a682c9',
MainLine: '#9470b2',
OnMain: '#11111b',
Container: '#313244',
ContainerHover: '#45475a',
ContainerActive: '#585b70',
ContainerLine: '#6c7086',
OnContainer: '#cba6f7',
},
Secondary: {
Main: '#bac2de',
MainHover: '#a6adc8',
MainActive: '#9399b2',
MainLine: '#7f849c',
OnMain: '#11111b',
Container: '#1e1e2e',
ContainerHover: '#313244',
ContainerActive: '#45475a',
ContainerLine: '#585b70',
OnContainer: '#cdd6f4',
},
Success: {
Main: '#a6e3a1',
MainHover: '#94d98e',
MainActive: '#82cf7b',
MainLine: '#70c568',
OnMain: '#11111b',
Container: '#1a2e1a',
ContainerHover: '#213821',
ContainerActive: '#284228',
ContainerLine: '#2f4c2f',
OnContainer: '#a6e3a1',
},
Warning: {
Main: '#fab387',
MainHover: '#f8a070',
MainActive: '#f68d59',
MainLine: '#f47a42',
OnMain: '#11111b',
Container: '#2e1f0f',
ContainerHover: '#3a2714',
ContainerActive: '#462f19',
ContainerLine: '#52371e',
OnContainer: '#fab387',
},
Critical: {
Main: '#f38ba8',
MainHover: '#f07494',
MainActive: '#ed5d80',
MainLine: '#ea466c',
OnMain: '#11111b',
Container: '#2e1119',
ContainerHover: '#3a1620',
ContainerActive: '#461b27',
ContainerLine: '#52202e',
OnContainer: '#f38ba8',
},
Other: {
FocusRing: 'rgba(203, 166, 247, 0.6)',
Shadow: 'rgba(0, 0, 0, 0.9)',
Overlay: 'rgba(0, 0, 0, 0.8)',
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,9 @@ import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer'; import { enableMapSet } from 'immer';
import '@fontsource-variable/inter'; import '@fontsource-variable/inter';
import '@fontsource/caveat/500.css';
import '@fontsource/caveat/600.css';
import '@fontsource/caveat/700.css';
import 'folds/dist/style.css'; import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds'; import { configClass, varsClass } from 'folds';