Compare commits
9 Commits
154f4dfdb0
...
b52926f7d8
| Author | SHA1 | Date | |
|---|---|---|---|
| b52926f7d8 | |||
| d338e1c35e | |||
| 9589680a81 | |||
| 27f1357fc0 | |||
| 9509a9705e | |||
| 61d41900cc | |||
| e7777f42d8 | |||
| ae70eae0fc | |||
| c286501be8 |
@@ -76,6 +76,7 @@ tauri.ts / electron main
|
||||
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
|
||||
- Pusher registration races on fast login/logout
|
||||
- Android small icon: `ic_stat_paarrot` must exist in Android resources
|
||||
- Background wake (`MatrixSyncService`) must filter by push rules / unread_notifications; a push for one room used to notify for every new message in the sync batch
|
||||
|
||||
## Future work
|
||||
|
||||
@@ -91,3 +92,4 @@ tauri.ts / electron main
|
||||
|
||||
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
|
||||
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
|
||||
- Android notify filter: mute / mentions / default-room behavior lives in `MatrixSyncService.resolveRoomNotifyMode`
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource/caveat": "5.3.0",
|
||||
"@fontsource/inter": "5.2.8",
|
||||
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
@@ -2368,6 +2369,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/caveat": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.3.0.tgz",
|
||||
"integrity": "sha512-eHTfzQxFSW9ABERRLNruHoNJmKed2J7pqTUc5lp4a4POMgXGKpxmWj3d9Hz10kmAg6iuTTLe6UagbA4bnsmfQw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite --open false",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "yarn check:eslint && yarn check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource/caveat": "5.3.0",
|
||||
"@fontsource/inter": "5.2.8",
|
||||
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
|
||||
@@ -1,16 +1,53 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
const decodeSegment = (segment: string): string => {
|
||||
let decoded = segment;
|
||||
try {
|
||||
let next = decodeURIComponent(decoded);
|
||||
while (next !== decoded) {
|
||||
decoded = next;
|
||||
next = decodeURIComponent(decoded);
|
||||
}
|
||||
} catch {
|
||||
// keep partially decoded value
|
||||
}
|
||||
return decoded;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for Outlet that adds route-based animation
|
||||
* Forces remount on route change by using location as key
|
||||
* Room routes are `:roomIdOrAlias/:eventId?/`. Jumping to an event (or clearing it)
|
||||
* changes the pathname but not the room — keep the outlet mounted so we don't replay
|
||||
* the route enter animation or remount drawers like Shared Media.
|
||||
*
|
||||
* Path segments are decoded so `!room:server` and `%21room%3Aserver` share a key.
|
||||
*/
|
||||
const getOutletTransitionKey = (pathname: string): string => {
|
||||
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
|
||||
if (segments.length === 0) return pathname;
|
||||
|
||||
// Matrix event IDs start with `$`
|
||||
if (segments[segments.length - 1].startsWith('$')) {
|
||||
segments.pop();
|
||||
}
|
||||
|
||||
return `/${segments.join('/')}/`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for Outlet that adds route-based animation.
|
||||
* Remounts (and animates) when leaving a room / switching rooms, not on same-room event hops.
|
||||
*/
|
||||
export function AnimatedOutlet() {
|
||||
const location = useLocation();
|
||||
|
||||
const transitionKey = useMemo(
|
||||
() => getOutletTransitionKey(location.pathname),
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={location.pathname}
|
||||
key={transitionKey}
|
||||
data-route-transition="true"
|
||||
style={{
|
||||
flex: 1,
|
||||
|
||||
@@ -6,11 +6,9 @@ import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { Box, Scroll, Text } from 'folds';
|
||||
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
|
||||
import { Descendant, Editor, createEditor, Element as SlateElement, Text as SlateText } from 'slate';
|
||||
import {
|
||||
Slate,
|
||||
Editable,
|
||||
@@ -172,48 +170,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
[editor, onKeyDown]
|
||||
);
|
||||
|
||||
const handleBeforeInput = useCallback(
|
||||
(event: Event) => {
|
||||
const inputEvent = event as InputEvent;
|
||||
|
||||
// Handle autocorrect replacement that causes text duplication
|
||||
if (inputEvent.inputType === 'insertReplacementText' ||
|
||||
inputEvent.inputType === 'insertFromComposition') {
|
||||
const { selection } = editor;
|
||||
if (!selection) return;
|
||||
|
||||
// Get the data being inserted
|
||||
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
|
||||
|
||||
if (data) {
|
||||
event.preventDefault();
|
||||
|
||||
// If there's selected text, delete it first
|
||||
if (selection && !Range.isCollapsed(selection)) {
|
||||
Transforms.delete(editor, { at: selection });
|
||||
}
|
||||
|
||||
// Insert the replacement text
|
||||
editor.insertText(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const editableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
|
||||
if (!editableElement) return;
|
||||
|
||||
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||
|
||||
return () => {
|
||||
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||
};
|
||||
}, [handleBeforeInput]);
|
||||
|
||||
const renderPlaceholder = useCallback(
|
||||
({ attributes, children }: RenderPlaceholderProps) => (
|
||||
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||
@@ -256,10 +212,9 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
className={css.EditorTextareaScroll}
|
||||
variant="SurfaceVariant"
|
||||
style={scrollStyle}
|
||||
size="300"
|
||||
size="0"
|
||||
visibility="Hover"
|
||||
hideTrack
|
||||
ref={editableRef}
|
||||
>
|
||||
<Editable
|
||||
data-editable-name={editableName}
|
||||
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
MATRIX_SPOILER_PROPERTY_NAME,
|
||||
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
||||
} from '../../../types/matrix/common';
|
||||
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
|
||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
|
||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||
|
||||
@@ -199,6 +201,20 @@ type MImageProps = {
|
||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||
outlined?: boolean;
|
||||
};
|
||||
|
||||
const resolveMediaBoxSize = (
|
||||
mxcUrl: string,
|
||||
w?: number,
|
||||
h?: number
|
||||
): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h);
|
||||
|
||||
/** Extra space after media so following text stays on the 24px ruled grid — applied outside the photo chrome */
|
||||
const STATIONERY_LINE_PX = 24;
|
||||
const snapStationeryGridPad = (height: number): number => {
|
||||
const rem = height % STATIONERY_LINE_PX;
|
||||
return rem === 0 ? 0 : STATIONERY_LINE_PX - rem;
|
||||
};
|
||||
|
||||
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
const imgInfo = content?.info;
|
||||
const mxcUrl = content.file?.url ?? content.url;
|
||||
@@ -206,9 +222,24 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
return <BrokenContent />;
|
||||
}
|
||||
|
||||
const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h);
|
||||
const gridPad = snapStationeryGridPad(height);
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined} transparent>
|
||||
<AttachmentBox>
|
||||
<StationeryMedia
|
||||
outlined={outlined}
|
||||
transparent
|
||||
tiltSeed={mxcUrl}
|
||||
gridPad={gridPad}
|
||||
>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
width: toRem(width),
|
||||
height: toRem(height),
|
||||
['--media-h' as string]: `${height}px`,
|
||||
}}
|
||||
data-paarrot-media-height={height}
|
||||
>
|
||||
{renderImageContent({
|
||||
body: content.body || 'Image',
|
||||
info: imgInfo,
|
||||
@@ -219,7 +250,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||
})}
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
</StationeryMedia>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,9 +282,20 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
}
|
||||
|
||||
const filename = content.filename ?? content.body ?? 'Video';
|
||||
const { width, height } = resolveMediaBoxSize(
|
||||
mxcUrl,
|
||||
videoInfo.w ?? videoInfo.thumbnail_info?.w,
|
||||
videoInfo.h ?? videoInfo.thumbnail_info?.h
|
||||
);
|
||||
const gridPad = snapStationeryGridPad(height);
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined} transparent>
|
||||
<StationeryMedia
|
||||
outlined={outlined}
|
||||
transparent
|
||||
tiltSeed={mxcUrl}
|
||||
gridPad={gridPad}
|
||||
>
|
||||
<AttachmentHeader>
|
||||
<FileHeader
|
||||
body={filename}
|
||||
@@ -268,7 +310,13 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
width: toRem(width),
|
||||
height: toRem(height),
|
||||
}}
|
||||
data-paarrot-media-height={height}
|
||||
>
|
||||
{renderVideoContent({
|
||||
body: content.body || 'Video',
|
||||
info: videoInfo,
|
||||
@@ -279,7 +327,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||
})}
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
</StationeryMedia>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -418,12 +466,16 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
|
||||
return <MessageBrokenContent />;
|
||||
}
|
||||
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
|
||||
const tapeAlt = hashStationerySeed(mxcUrl) % 2 === 1;
|
||||
|
||||
return (
|
||||
<AttachmentBox
|
||||
data-stationery-media=""
|
||||
data-tape-alt={tapeAlt ? '' : undefined}
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
width: toRem(152),
|
||||
['--media-rot' as string]: stationeryMediaRot(mxcUrl),
|
||||
}}
|
||||
>
|
||||
{renderImageContent({
|
||||
|
||||
@@ -64,6 +64,33 @@ export const ReactionText = style([
|
||||
},
|
||||
]);
|
||||
|
||||
export const ReactionStack = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
display: 'inline-grid',
|
||||
placeItems: 'center',
|
||||
gridTemplateColumns: '1fr',
|
||||
gridTemplateRows: '1fr',
|
||||
lineHeight: toRem(20),
|
||||
minWidth: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ReactionSticker = style([
|
||||
DefaultReset,
|
||||
{
|
||||
minWidth: 0,
|
||||
maxWidth: toRem(150),
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: toRem(20),
|
||||
gridArea: '1 / 1',
|
||||
transformOrigin: 'center center',
|
||||
},
|
||||
]);
|
||||
|
||||
export const ReactionImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
|
||||
@@ -6,6 +6,9 @@ import * as css from './Reaction.css';
|
||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
|
||||
const MAX_STICKER_STACK = 4;
|
||||
|
||||
export const Reaction = as<
|
||||
'button',
|
||||
@@ -15,35 +18,101 @@ export const Reaction = as<
|
||||
reaction: string;
|
||||
useAuthentication?: boolean;
|
||||
}
|
||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
|
||||
<Box
|
||||
as="button"
|
||||
className={classNames(css.Reaction, className)}
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
gap="200"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Text className={css.ReactionText} as="span" size="T400">
|
||||
{reaction.startsWith('mxc://') ? (
|
||||
<img
|
||||
className={css.ReactionImg}
|
||||
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
||||
}
|
||||
alt={reaction}
|
||||
/>
|
||||
) : (
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{reaction}
|
||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
|
||||
const theme = useTheme();
|
||||
const stationery = isStationeryTheme(theme);
|
||||
const isCustomEmoji = reaction.startsWith('mxc://');
|
||||
const customSrc = isCustomEmoji
|
||||
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
||||
: undefined;
|
||||
|
||||
const emoji = isCustomEmoji ? (
|
||||
<img className={css.ReactionImg} src={customSrc} alt={reaction} />
|
||||
) : (
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{reaction}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
|
||||
if (!stationery) {
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
className={classNames(css.Reaction, className)}
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
gap="200"
|
||||
data-reaction=""
|
||||
aria-label={`${reaction}, ${count}`}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Text className={css.ReactionText} as="span" size="T400">
|
||||
{emoji}
|
||||
</Text>
|
||||
<Text as="span" size="T300" data-reaction-count="">
|
||||
{count}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
|
||||
const showCount = count > 2;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
className={classNames(css.Reaction, className)}
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
gap="200"
|
||||
data-reaction=""
|
||||
data-reaction-stack={stackCount}
|
||||
aria-label={`${reaction}, ${count}`}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<span className={css.ReactionStack} data-reaction-stack-layer="">
|
||||
{Array.from({ length: stackCount }, (_, i) => {
|
||||
const layer = stackCount - 1 - i;
|
||||
return (
|
||||
<Text
|
||||
key={layer}
|
||||
className={css.ReactionSticker}
|
||||
as="span"
|
||||
size="T400"
|
||||
data-reaction-sticker=""
|
||||
style={{
|
||||
['--sticker-i' as string]: layer,
|
||||
zIndex: i + 1,
|
||||
}}
|
||||
aria-hidden={i < stackCount - 1 ? true : undefined}
|
||||
>
|
||||
{isCustomEmoji ? (
|
||||
<img
|
||||
className={css.ReactionImg}
|
||||
src={customSrc}
|
||||
alt={i === stackCount - 1 ? reaction : ''}
|
||||
/>
|
||||
) : (
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{reaction}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{showCount && (
|
||||
<Text as="span" size="T300" data-reaction-count="">
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text as="span" size="T300">
|
||||
{count}
|
||||
</Text>
|
||||
</Box>
|
||||
));
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
type ReactionTooltipMsgProps = {
|
||||
room: Room;
|
||||
|
||||
@@ -14,29 +14,54 @@ import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
import { STATIONERY_NAME_INK } from '../../utils/paperSafeInk';
|
||||
|
||||
type ReplyLayoutProps = {
|
||||
userColor?: string;
|
||||
username?: ReactNode;
|
||||
};
|
||||
export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
||||
({ username, userColor, className, children, ...props }, ref) => (
|
||||
<Box
|
||||
className={classNames(css.Reply, className)}
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
|
||||
<Icon size="100" src={Icons.ReplyArrow} />
|
||||
{username}
|
||||
({ username, userColor, className, children, ...props }, ref) => {
|
||||
const theme = useTheme();
|
||||
const isStationery = isStationeryTheme(theme);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.Reply, className)}
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Box
|
||||
style={
|
||||
isStationery
|
||||
? {
|
||||
color: STATIONERY_NAME_INK,
|
||||
maxWidth: toRem(200),
|
||||
...(userColor
|
||||
? {
|
||||
['--username-accent' as string]: userColor,
|
||||
textDecorationColor: userColor,
|
||||
}
|
||||
: null),
|
||||
}
|
||||
: { color: userColor, maxWidth: toRem(200) }
|
||||
}
|
||||
data-username-accent={isStationery && userColor ? '' : undefined}
|
||||
alignItems="Center"
|
||||
shrink="No"
|
||||
>
|
||||
<Icon size="100" src={Icons.ReplyArrow} />
|
||||
{username}
|
||||
</Box>
|
||||
<Box grow="Yes" className={css.ReplyContent}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box grow="Yes" className={css.ReplyContent}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||
|
||||
293
src/app/components/message/StationeryMedia.tsx
Normal file
293
src/app/components/message/StationeryMedia.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import React, {
|
||||
ComponentProps,
|
||||
CSSProperties,
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { Attachment } from './attachment';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
|
||||
type Corner = 'tl' | 'tr' | 'bl' | 'br';
|
||||
|
||||
type StationeryMediaProps = ComponentProps<typeof Attachment> & {
|
||||
children: ReactNode;
|
||||
/** Stable id (e.g. mxc URL) so tilt/tape don't reshuffle on timeline re-renders */
|
||||
tiltSeed?: string;
|
||||
/** Invisible bottom spacer (px) so following text stays on the ruled grid */
|
||||
gridPad?: number;
|
||||
};
|
||||
|
||||
const CORNERS: { id: Corner; x: 0 | 1; y: 0 | 1 }[] = [
|
||||
{ id: 'tl', x: 0, y: 0 },
|
||||
{ id: 'tr', x: 1, y: 0 },
|
||||
{ id: 'bl', x: 0, y: 1 },
|
||||
{ id: 'br', x: 1, y: 1 },
|
||||
];
|
||||
|
||||
const MEDIA_ROTS = [-0.9, 0.7, -0.5, 1.0, -1.1, 0.8, -0.4, 0.9] as const;
|
||||
|
||||
export function hashStationerySeed(seed: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function stationeryMediaRot(seed: string): string {
|
||||
return `${MEDIA_ROTS[hashStationerySeed(seed) % MEDIA_ROTS.length]}deg`;
|
||||
}
|
||||
|
||||
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
|
||||
|
||||
/**
|
||||
* Fold sits halfway between corner and cursor; flap hinges on that fold.
|
||||
* `keep` = remaining photo; `flap` = lifted triangle.
|
||||
*/
|
||||
function cornerPeelPaths(
|
||||
id: Corner,
|
||||
w: number,
|
||||
h: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
ux: number,
|
||||
uy: number,
|
||||
size: number
|
||||
): {
|
||||
keep: string;
|
||||
flap: string;
|
||||
origin: string;
|
||||
axisX: number;
|
||||
axisY: number;
|
||||
hingeSign: number;
|
||||
} {
|
||||
const eps = 0.0001;
|
||||
// Fold line through the midpoint on corner→cursor, ⊥ to that ray
|
||||
const px = cx + ux * size;
|
||||
const py = cy + uy * size;
|
||||
|
||||
let ax = 0;
|
||||
let ay = 0;
|
||||
let bx = 0;
|
||||
let by = 0;
|
||||
|
||||
if (id === 'tr') {
|
||||
ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w);
|
||||
ay = 0;
|
||||
bx = w;
|
||||
by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h);
|
||||
} else if (id === 'tl') {
|
||||
ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w);
|
||||
ay = 0;
|
||||
bx = 0;
|
||||
by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h);
|
||||
} else if (id === 'br') {
|
||||
ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w);
|
||||
ay = h;
|
||||
bx = w;
|
||||
by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h);
|
||||
} else {
|
||||
ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w);
|
||||
ay = h;
|
||||
bx = 0;
|
||||
by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h);
|
||||
}
|
||||
|
||||
let keep: string;
|
||||
if (id === 'tr') {
|
||||
keep = `polygon(0 0, ${ax}px 0, ${w}px ${by}px, ${w}px ${h}px, 0 ${h}px)`;
|
||||
} else if (id === 'tl') {
|
||||
keep = `polygon(${ax}px 0, ${w}px 0, ${w}px ${h}px, 0 ${h}px, 0 ${by}px)`;
|
||||
} else if (id === 'br') {
|
||||
keep = `polygon(0 0, ${w}px 0, ${w}px ${by}px, ${ax}px ${h}px, 0 ${h}px)`;
|
||||
} else {
|
||||
keep = `polygon(0 0, ${w}px 0, ${w}px ${h}px, ${ax}px ${h}px, 0 ${by}px)`;
|
||||
}
|
||||
|
||||
const flap = `polygon(${cx}px ${cy}px, ${ax}px ${ay}px, ${bx}px ${by}px)`;
|
||||
|
||||
// Hinge on the fold (midpoint), axis along the fold edge
|
||||
const midX = (ax + bx) / 2;
|
||||
const midY = (ay + by) / 2;
|
||||
const fdx = bx - ax;
|
||||
const fdy = by - ay;
|
||||
const flen = Math.hypot(fdx, fdy) || 1;
|
||||
const axisX = fdx / flen;
|
||||
const axisY = fdy / flen;
|
||||
// Sign so the corner swings up over the fold (right-hand rule)
|
||||
const cross = fdx * (cy - midY) - fdy * (cx - midX);
|
||||
const hingeSign = cross >= 0 ? 1 : -1;
|
||||
|
||||
return {
|
||||
keep,
|
||||
flap,
|
||||
origin: `${midX}px ${midY}px`,
|
||||
axisX,
|
||||
axisY,
|
||||
hingeSign,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stationery-theme image chrome: clear corner tape + a mouse-driven peel
|
||||
* that shows a duplicated slice of the photo lifting toward the cursor.
|
||||
*/
|
||||
export function StationeryMedia({
|
||||
children,
|
||||
tiltSeed = 'stationery',
|
||||
gridPad = 0,
|
||||
style,
|
||||
...attachmentProps
|
||||
}: StationeryMediaProps) {
|
||||
const theme = useTheme();
|
||||
const isStationery = isStationeryTheme(theme);
|
||||
const peelRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const tiltStyle = useMemo((): CSSProperties => {
|
||||
return {
|
||||
...(style as CSSProperties | undefined),
|
||||
// Margin sits outside the paper chrome — snaps the grid without a white bar
|
||||
...(isStationery && gridPad > 0 ? { marginBottom: `${gridPad}px` } : null),
|
||||
['--media-rot' as string]: stationeryMediaRot(tiltSeed),
|
||||
};
|
||||
}, [tiltSeed, style, isStationery, gridPad]);
|
||||
|
||||
const tapeAlt = useMemo(() => hashStationerySeed(tiltSeed) % 2 === 1, [tiltSeed]);
|
||||
// Default tape covers TL; alt covers TR — peel only the free top corner
|
||||
const peelCorner = useMemo(
|
||||
() => (tapeAlt ? CORNERS.find((c) => c.id === 'tl')! : CORNERS.find((c) => c.id === 'tr')!),
|
||||
[tapeAlt]
|
||||
);
|
||||
|
||||
const clearPeel = useCallback((host: HTMLElement) => {
|
||||
const peel = peelRef.current;
|
||||
const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null;
|
||||
if (peel) {
|
||||
// Hide flap first (no opacity tween) so restoring the photo can't double-draw
|
||||
peel.style.transition = 'none';
|
||||
peel.style.opacity = '0';
|
||||
peel.style.setProperty('--peel-hinge', '0deg');
|
||||
peel.style.clipPath = '';
|
||||
}
|
||||
if (target) target.style.clipPath = '';
|
||||
}, []);
|
||||
|
||||
const syncPeel = useCallback(
|
||||
(host: HTMLElement, clientX: number, clientY: number) => {
|
||||
const peel = peelRef.current;
|
||||
if (!peel) return;
|
||||
|
||||
const rect = host.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
|
||||
const best = peelCorner;
|
||||
const cx = best.x * w;
|
||||
const cy = best.y * h;
|
||||
const dist = Math.hypot(cx - x, cy - y);
|
||||
const maxReach = Math.min(w, h) * 0.55;
|
||||
|
||||
// Fold halfway between corner and cursor; fade when leaving the corner zone
|
||||
let size = 0;
|
||||
let amount = 0;
|
||||
if (dist > 8 && dist <= maxReach) {
|
||||
size = dist * 0.5;
|
||||
amount = Math.min(1, size / (Math.min(w, h) * 0.22));
|
||||
} else if (dist > maxReach && dist < maxReach * 1.35) {
|
||||
const fade = 1 - (dist - maxReach) / (maxReach * 0.35);
|
||||
size = maxReach * 0.5 * Math.max(0, fade);
|
||||
amount = Math.max(0, fade);
|
||||
}
|
||||
|
||||
const img = host.querySelector('img');
|
||||
const url = img?.currentSrc || img?.src || '';
|
||||
|
||||
// Unit vector from corner → cursor (triangle aims this way)
|
||||
let ux = x - cx;
|
||||
let uy = y - cy;
|
||||
if (best.id === 'tr') {
|
||||
ux = Math.min(-0.05, ux);
|
||||
uy = Math.max(0.05, uy);
|
||||
} else {
|
||||
ux = Math.max(0.05, ux);
|
||||
uy = Math.max(0.05, uy);
|
||||
}
|
||||
const len = Math.hypot(ux, uy) || 1;
|
||||
ux /= len;
|
||||
uy /= len;
|
||||
|
||||
peel.dataset.corner = best.id;
|
||||
if (url) {
|
||||
peel.style.setProperty('--peel-img', `url(${JSON.stringify(url)})`);
|
||||
}
|
||||
|
||||
const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null;
|
||||
if (target && amount > 0.06 && size > 6) {
|
||||
const { keep, flap, origin, axisX, axisY, hingeSign } = cornerPeelPaths(
|
||||
best.id,
|
||||
w,
|
||||
h,
|
||||
cx,
|
||||
cy,
|
||||
ux,
|
||||
uy,
|
||||
size
|
||||
);
|
||||
target.style.clipPath = keep;
|
||||
peel.style.clipPath = flap;
|
||||
peel.style.transformOrigin = origin;
|
||||
peel.style.setProperty('--peel-axis-x', String(axisX));
|
||||
peel.style.setProperty('--peel-axis-y', String(axisY));
|
||||
peel.style.setProperty('--peel-hinge', `${hingeSign * amount * 52}deg`);
|
||||
peel.style.setProperty('--peel-tip-x', `${cx}px`);
|
||||
peel.style.setProperty('--peel-tip-y', `${cy}px`);
|
||||
peel.style.setProperty('--peel-grad-r', `${Math.max(size * 1.15, 12)}px`);
|
||||
peel.style.opacity = '1';
|
||||
} else {
|
||||
clearPeel(host);
|
||||
}
|
||||
},
|
||||
[peelCorner, clearPeel]
|
||||
);
|
||||
|
||||
const handleMove: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
|
||||
};
|
||||
|
||||
const handleEnter: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
|
||||
};
|
||||
|
||||
const handleLeave: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
clearPeel(evt.currentTarget);
|
||||
};
|
||||
|
||||
if (!isStationery) {
|
||||
return (
|
||||
<Attachment style={style} {...attachmentProps}>
|
||||
{children}
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Attachment
|
||||
{...attachmentProps}
|
||||
style={tiltStyle}
|
||||
data-stationery-media=""
|
||||
data-tape-alt={tapeAlt ? '' : undefined}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseMove={handleMove}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
<span ref={peelRef} data-stationery-peel="" aria-hidden="true" />
|
||||
{children}
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||
import { Icon, Icons } from '../../icons';
|
||||
import classNames from 'classnames';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
||||
@@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||
|
||||
/**
|
||||
* Fetches media with authentication headers and returns a blob URL.
|
||||
@@ -68,7 +69,7 @@ type RenderImageProps = {
|
||||
alt: string;
|
||||
title: string;
|
||||
src: string;
|
||||
onLoad: () => void;
|
||||
onLoad: (event?: React.SyntheticEvent<HTMLImageElement>) => void;
|
||||
onError: () => void;
|
||||
onClick: () => void;
|
||||
tabIndex: number;
|
||||
@@ -105,12 +106,23 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||
const eventBlurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||
const cachedBlurHash = getMediaBlurHash(url);
|
||||
const blurHash = eventBlurHash ?? cachedBlurHash;
|
||||
const cachedDims = getMediaDimensions(url);
|
||||
const blurResolutionX = 32;
|
||||
const ratioW = info?.w || cachedDims?.w;
|
||||
const ratioH = info?.h || cachedDims?.h;
|
||||
const blurResolutionY =
|
||||
ratioW && ratioH && ratioW > 0
|
||||
? Math.max(1, Math.round(blurResolutionX * (ratioH / ratioW)))
|
||||
: 32;
|
||||
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [viewer, setViewer] = useState(false);
|
||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||
const [showPlaceholder, setShowPlaceholder] = useState(true);
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
@@ -139,6 +151,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
const handleError = () => {
|
||||
setLoad(false);
|
||||
setError(true);
|
||||
setShowPlaceholder(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
@@ -150,8 +163,34 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
if (autoPlay) loadSrc();
|
||||
}, [autoPlay, loadSrc]);
|
||||
|
||||
// Keep blurhash under the fade, then remove it once the image is fully opaque.
|
||||
useEffect(() => {
|
||||
if (!load) {
|
||||
setShowPlaceholder(true);
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
{showPlaceholder &&
|
||||
(typeof blurHash === 'string' ? (
|
||||
<div className={css.BlurhashPlaceholder}>
|
||||
<Blurhash
|
||||
hash={blurHash}
|
||||
width="100%"
|
||||
height="100%"
|
||||
resolutionX={blurResolutionX}
|
||||
resolutionY={blurResolutionY}
|
||||
punch={1.2}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={css.MediaSkeleton} />
|
||||
))}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -177,15 +216,6 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
{typeof blurHash === 'string' && !load && (
|
||||
<BlurhashCanvas
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
width={32}
|
||||
height={32}
|
||||
hash={blurHash}
|
||||
punch={1}
|
||||
/>
|
||||
)}
|
||||
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Button
|
||||
@@ -201,12 +231,26 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
<Box
|
||||
className={classNames(
|
||||
css.AbsoluteContainer,
|
||||
css.MediaFadeIn,
|
||||
load && css.MediaFadeInLoaded,
|
||||
blurred && css.Blur
|
||||
)}
|
||||
>
|
||||
{renderImage({
|
||||
alt: body,
|
||||
title: body,
|
||||
src: srcState.data,
|
||||
onLoad: handleLoad,
|
||||
onLoad: (e?: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e?.currentTarget;
|
||||
if (img?.naturalWidth && img?.naturalHeight) {
|
||||
setMediaDimensions(url, img.naturalWidth, img.naturalHeight);
|
||||
}
|
||||
if (img) rememberMediaBlurHash(url, img);
|
||||
handleLoad();
|
||||
},
|
||||
onError: handleError,
|
||||
onClick: () => setViewer(true),
|
||||
tabIndex: 0,
|
||||
@@ -248,7 +292,8 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
)}
|
||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||
!load &&
|
||||
!blurred && (
|
||||
!blurred &&
|
||||
typeof blurHash !== 'string' && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" />
|
||||
</Box>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||
import { Icon, Icons } from '../../icons';
|
||||
import classNames from 'classnames';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import {
|
||||
@@ -25,6 +25,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||
|
||||
type RenderViewerProps = {
|
||||
src: string;
|
||||
@@ -34,7 +35,7 @@ type RenderViewerProps = {
|
||||
type RenderVideoProps = {
|
||||
title: string;
|
||||
src: string;
|
||||
onLoadedMetadata: () => void;
|
||||
onLoadedMetadata: (event?: React.SyntheticEvent<HTMLVideoElement>) => void;
|
||||
onError: () => void;
|
||||
autoPlay: boolean;
|
||||
controls: boolean;
|
||||
@@ -74,12 +75,23 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||
const eventBlurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||
const cachedBlurHash = getMediaBlurHash(url);
|
||||
const blurHash = eventBlurHash ?? cachedBlurHash;
|
||||
const cachedDims = getMediaDimensions(url);
|
||||
const blurResolutionX = 32;
|
||||
const blurW = info.w ?? info.thumbnail_info?.w ?? cachedDims?.w;
|
||||
const blurH = info.h ?? info.thumbnail_info?.h ?? cachedDims?.h;
|
||||
const blurResolutionY =
|
||||
blurW && blurH && blurW > 0
|
||||
? Math.max(1, Math.round(blurResolutionX * (blurH / blurW)))
|
||||
: 32;
|
||||
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [viewer, setViewer] = useState(false);
|
||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||
const [showPlaceholder, setShowPlaceholder] = useState(true);
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
@@ -95,12 +107,17 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
|
||||
const handleLoad = () => {
|
||||
const handleLoad = (video?: HTMLVideoElement) => {
|
||||
if (video?.videoWidth && video?.videoHeight) {
|
||||
setMediaDimensions(url, video.videoWidth, video.videoHeight);
|
||||
}
|
||||
if (video) rememberMediaBlurHash(url, video);
|
||||
setLoad(true);
|
||||
};
|
||||
const handleError = () => {
|
||||
setLoad(false);
|
||||
setError(true);
|
||||
setShowPlaceholder(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
@@ -112,8 +129,33 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
if (autoPlay) loadSrc();
|
||||
}, [autoPlay, loadSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!load) {
|
||||
setShowPlaceholder(true);
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
{showPlaceholder &&
|
||||
(typeof blurHash === 'string' ? (
|
||||
<div className={css.BlurhashPlaceholder}>
|
||||
<Blurhash
|
||||
hash={blurHash}
|
||||
width="100%"
|
||||
height="100%"
|
||||
resolutionX={blurResolutionX}
|
||||
resolutionY={blurResolutionY}
|
||||
punch={1.2}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={css.MediaSkeleton} />
|
||||
))}
|
||||
{renderViewer && srcState.status === AsyncStatus.Success && (
|
||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -136,15 +178,6 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
{typeof blurHash === 'string' && !load && (
|
||||
<BlurhashCanvas
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
width={32}
|
||||
height={32}
|
||||
hash={blurHash}
|
||||
punch={1}
|
||||
/>
|
||||
)}
|
||||
{renderThumbnail && !load && (
|
||||
<Box
|
||||
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
|
||||
@@ -169,11 +202,20 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
<Box
|
||||
className={classNames(
|
||||
css.AbsoluteContainer,
|
||||
css.MediaFadeIn,
|
||||
load && css.MediaFadeInLoaded,
|
||||
blurred && css.Blur
|
||||
)}
|
||||
>
|
||||
{renderVideo({
|
||||
title: body,
|
||||
src: srcState.data,
|
||||
onLoadedMetadata: handleLoad,
|
||||
onLoadedMetadata: (e?: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
handleLoad(e?.currentTarget);
|
||||
},
|
||||
onError: handleError,
|
||||
autoPlay: true,
|
||||
controls: true,
|
||||
@@ -213,7 +255,8 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
)}
|
||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||
!load &&
|
||||
!blurred && (
|
||||
!blurred &&
|
||||
typeof blurHash !== 'string' && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" />
|
||||
</Box>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config } from 'folds';
|
||||
|
||||
export const RelativeBase = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
borderRadius: '7px',
|
||||
@@ -16,9 +18,13 @@ export const RelativeBase = style([
|
||||
export const AbsoluteContainer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
borderRadius: 'inherit',
|
||||
@@ -34,6 +40,7 @@ export const AbsoluteFooter = style([
|
||||
bottom: config.space.S100,
|
||||
left: config.space.S100,
|
||||
right: config.space.S100,
|
||||
zIndex: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -43,3 +50,67 @@ export const Blur = style([
|
||||
filter: 'blur(44px)',
|
||||
},
|
||||
]);
|
||||
|
||||
const shimmer = keyframes({
|
||||
'0%': {
|
||||
backgroundPosition: '200% 0',
|
||||
},
|
||||
'100%': {
|
||||
backgroundPosition: '-200% 0',
|
||||
},
|
||||
});
|
||||
|
||||
/** Soft fallback when no blurhash is available. */
|
||||
export const MediaSkeleton = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
backgroundImage: `linear-gradient(
|
||||
90deg,
|
||||
${color.SurfaceVariant.Container} 0%,
|
||||
${color.SurfaceVariant.ContainerHover} 40%,
|
||||
${color.SurfaceVariant.Container} 80%
|
||||
)`,
|
||||
backgroundSize: '200% 100%',
|
||||
animation: `${shimmer} 1.4s ease-in-out infinite`,
|
||||
},
|
||||
]);
|
||||
|
||||
const blurhashFadeIn = keyframes({
|
||||
from: {
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
/** Soft blurhash layer that fills the reserved media box. */
|
||||
export const BlurhashPlaceholder = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 'inherit',
|
||||
zIndex: 0,
|
||||
// Soften the decode so it reads as color blobs; slight scale hides blur edges.
|
||||
filter: 'blur(14px)',
|
||||
transform: 'scale(1.06)',
|
||||
opacity: 0,
|
||||
animation: `${blurhashFadeIn} 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards`,
|
||||
},
|
||||
]);
|
||||
|
||||
/** Media starts invisible and fades in once loaded over the placeholder. */
|
||||
export const MediaFadeIn = style({
|
||||
zIndex: 1,
|
||||
opacity: 0,
|
||||
transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
});
|
||||
|
||||
export const MediaFadeInLoaded = style({
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { CSSProperties, useMemo } from 'react';
|
||||
import { Text, as } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './layout.css';
|
||||
import { isStationeryTheme, useTheme } from '../../../hooks/useTheme';
|
||||
import { STATIONERY_NAME_INK } from '../../../utils/paperSafeInk';
|
||||
|
||||
export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
|
||||
@@ -10,6 +12,7 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
|
||||
className
|
||||
)}
|
||||
data-message-collapsed={collapse ? '' : undefined}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -17,12 +20,45 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||
);
|
||||
|
||||
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
|
||||
<span className={classNames(css.AvatarBase, className)} {...props} ref={ref} />
|
||||
<span
|
||||
className={classNames(css.AvatarBase, className)}
|
||||
data-message-avatar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
|
||||
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
|
||||
));
|
||||
export const Username = as<'span'>(({ as: AsUsername = 'span', className, style, ...props }, ref) => {
|
||||
const theme = useTheme();
|
||||
const isStationery = isStationeryTheme(theme);
|
||||
const userColor =
|
||||
typeof (style as CSSProperties | undefined)?.color === 'string'
|
||||
? ((style as CSSProperties).color as string)
|
||||
: undefined;
|
||||
|
||||
const safeStyle = useMemo((): CSSProperties | undefined => {
|
||||
if (!isStationery) return style as CSSProperties | undefined;
|
||||
return {
|
||||
...(style as CSSProperties | undefined),
|
||||
// Name in ink; keep their color as an accent underline
|
||||
color: STATIONERY_NAME_INK,
|
||||
...(userColor
|
||||
? { ['--username-accent' as string]: userColor, textDecorationColor: userColor }
|
||||
: null),
|
||||
};
|
||||
}, [style, isStationery, userColor]);
|
||||
|
||||
return (
|
||||
<AsUsername
|
||||
className={classNames(css.Username, className)}
|
||||
data-message-username=""
|
||||
data-username-accent={isStationery && userColor ? '' : undefined}
|
||||
style={safeStyle}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
|
||||
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
|
||||
@@ -32,10 +68,12 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
|
||||
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
|
||||
<Text
|
||||
as={asComp}
|
||||
size="T400"
|
||||
size={jumboEmoji ? 'Inherit' : 'T400'}
|
||||
priority={notice ? '300' : '400'}
|
||||
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
||||
data-allow-text-selection="true"
|
||||
data-message-body=""
|
||||
data-jumbo-emoji={jumboEmoji ? '' : undefined}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
||||
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
||||
import * as htmlCss from '../../../styles/CustomHtml.css';
|
||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
@@ -117,6 +118,7 @@ export const MessageBase = recipe({
|
||||
collapse: {
|
||||
true: {
|
||||
marginTop: 0,
|
||||
paddingTop: config.space.S0,
|
||||
},
|
||||
},
|
||||
autoCollapse: {
|
||||
@@ -202,6 +204,14 @@ export const Username = style({
|
||||
'button&:hover, button&:focus-visible': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
// Stationery uses a colored border-bottom accent instead
|
||||
'html.stationery &, body.stationery &': {
|
||||
overflow: 'visible',
|
||||
overflowY: 'visible',
|
||||
},
|
||||
'body.stationery button&:hover, body.stationery button&:focus-visible': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -209,6 +219,8 @@ export const UsernameBold = style({
|
||||
fontWeight: 550,
|
||||
});
|
||||
|
||||
const jumboEmojiSize = toRem(70);
|
||||
|
||||
export const MessageTextBody = recipe({
|
||||
base: {
|
||||
wordBreak: 'break-word',
|
||||
@@ -226,8 +238,11 @@ export const MessageTextBody = recipe({
|
||||
},
|
||||
jumboEmoji: {
|
||||
true: {
|
||||
fontSize: '1.504em',
|
||||
lineHeight: '1.4962em',
|
||||
fontSize: jumboEmojiSize,
|
||||
lineHeight: 1,
|
||||
overflow: 'visible',
|
||||
overflowY: 'visible',
|
||||
paddingBottom: config.space.S200,
|
||||
},
|
||||
},
|
||||
emote: {
|
||||
@@ -240,3 +255,29 @@ export const MessageTextBody = recipe({
|
||||
});
|
||||
|
||||
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
|
||||
|
||||
const jumboEmojiClass = MessageTextBody.classNames.variants.jumboEmoji.true;
|
||||
|
||||
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
|
||||
height: '1em',
|
||||
padding: 0,
|
||||
overflow: 'visible',
|
||||
verticalAlign: 'middle',
|
||||
});
|
||||
|
||||
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
|
||||
fontSize: '1em',
|
||||
height: '1em',
|
||||
minWidth: '1em',
|
||||
lineHeight: 1,
|
||||
position: 'static',
|
||||
top: 0,
|
||||
overflow: 'visible',
|
||||
});
|
||||
|
||||
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonImg}`, {
|
||||
height: '1em',
|
||||
width: '1em',
|
||||
maxHeight: 'none',
|
||||
objectFit: 'contain',
|
||||
});
|
||||
|
||||
@@ -7,5 +7,10 @@ type NavCategoryProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
|
||||
<div className={classNames(css.NavCategory, className)} {...props} ref={ref} />
|
||||
<div
|
||||
className={classNames(css.NavCategory, className)}
|
||||
data-nav-category=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -13,6 +13,7 @@ export const NavItem = as<
|
||||
<AsNavItem
|
||||
className={classNames(css.NavItem({ variant, radii }), className)}
|
||||
data-highlight={highlight}
|
||||
data-nav-item=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as css from './style.css';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
|
||||
type PageRootProps = {
|
||||
nav: ReactNode;
|
||||
@@ -43,6 +44,7 @@ export function PageNav({
|
||||
grow={isMobile ? 'Yes' : undefined}
|
||||
className={classNames(css.PageNav({ size }), className)}
|
||||
shrink={isMobile ? 'Yes' : 'No'}
|
||||
data-page-nav=""
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{children}
|
||||
@@ -58,6 +60,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
||||
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||
variant="Background"
|
||||
size="600"
|
||||
data-folder-tab="nav"
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -73,13 +76,16 @@ export function PageNavContent({
|
||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const hideScrollbar = isStationeryTheme(theme);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
variant="Background"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
size={hideScrollbar ? '0' : '300'}
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
{...scrollProps}
|
||||
@@ -95,6 +101,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||
data-page=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -106,6 +113,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
|
||||
as="header"
|
||||
size="600"
|
||||
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||
data-folder-tab-bar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { JoinRule } from 'matrix-js-sdk';
|
||||
import { AvatarFallback, AvatarImage, color } from 'folds';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
|
||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useEffect, useState } from 'react';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import * as css from './RoomAvatar.css';
|
||||
import { joinRuleToIconSrc } from '../../utils/room';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
||||
import {
|
||||
getCachedAuthenticatedMediaUrl,
|
||||
isAuthenticatedMediaUrl,
|
||||
} from '../../utils/authenticatedMediaCache';
|
||||
import {
|
||||
getMediaBlurHash,
|
||||
getMediaDimensions,
|
||||
rememberMediaBlurHash,
|
||||
} from '../../state/mediaDimensionCache';
|
||||
|
||||
type RoomAvatarProps = {
|
||||
roomId: string;
|
||||
@@ -17,19 +27,36 @@ type RoomAvatarProps = {
|
||||
};
|
||||
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
|
||||
const [error, setError] = useState(false);
|
||||
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
const blurHash = getMediaBlurHash(src ?? '');
|
||||
const dims = getMediaDimensions(src ?? '');
|
||||
const blobCached =
|
||||
!!src &&
|
||||
(!useAuthentication ||
|
||||
!isAuthenticatedMediaUrl(src) ||
|
||||
!!getCachedAuthenticatedMediaUrl(src));
|
||||
const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
authenticatedSrc &&
|
||||
(isAvatarCached(src) ||
|
||||
(src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
|
||||
) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [authenticatedSrc, src, useAuthentication]);
|
||||
|
||||
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
||||
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
||||
setLoaded(true);
|
||||
cacheAvatar(src);
|
||||
pruneAvatarCache();
|
||||
rememberMediaBlurHash(src ?? '', evt.currentTarget);
|
||||
};
|
||||
|
||||
// No src or error - show fallback only
|
||||
if (!authenticatedSrc || error) {
|
||||
if (!src || error) {
|
||||
return (
|
||||
<AvatarFallback
|
||||
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
|
||||
@@ -40,8 +67,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
||||
);
|
||||
}
|
||||
|
||||
// If already cached, show image directly without fallback flash
|
||||
if (loaded) {
|
||||
if (authenticatedSrc && loaded) {
|
||||
return (
|
||||
<AvatarImage
|
||||
className={css.RoomAvatar}
|
||||
@@ -55,12 +81,17 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state - render fallback with image overlay
|
||||
const blurResolutionX = 32;
|
||||
const blurResolutionY =
|
||||
dims?.w && dims?.h && dims.w > 0
|
||||
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
|
||||
: 32;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: colorMXID(roomId ?? ''),
|
||||
style={{
|
||||
backgroundColor: colorMXID(roomId ?? ''),
|
||||
color: color.Surface.Container,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -69,15 +100,39 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
||||
>
|
||||
{renderFallback()}
|
||||
</AvatarFallback>
|
||||
<AvatarImage
|
||||
className={css.RoomAvatar}
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
src={authenticatedSrc}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
onLoad={handleLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
{blurHash && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 'inherit',
|
||||
filter: 'blur(6px)',
|
||||
transform: 'scale(1.08)',
|
||||
}}
|
||||
>
|
||||
<Blurhash
|
||||
hash={blurHash}
|
||||
width="100%"
|
||||
height="100%"
|
||||
resolutionX={blurResolutionX}
|
||||
resolutionY={blurResolutionY}
|
||||
punch={1.1}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{authenticatedSrc && (
|
||||
<AvatarImage
|
||||
className={css.RoomAvatar}
|
||||
style={{ position: 'relative', zIndex: 1, opacity: loaded ? 1 : 0 }}
|
||||
src={authenticatedSrc}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
onLoad={handleLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,10 @@ import React from 'react';
|
||||
import * as css from './Sidebar.css';
|
||||
|
||||
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
|
||||
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
|
||||
<AsSidebar
|
||||
className={classNames(css.Sidebar, className)}
|
||||
data-sidebar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -8,10 +8,10 @@ type SidebarContentProps = {
|
||||
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
|
||||
return (
|
||||
<>
|
||||
<Box direction="Column" grow="Yes">
|
||||
<Box direction="Column" grow="Yes" data-sidebar-scroll-region="" style={{ overflow: 'visible', minWidth: 0 }}>
|
||||
{scrollable}
|
||||
</Box>
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box direction="Column" shrink="No" data-sidebar-sticky="" style={{ overflow: 'visible' }}>
|
||||
{sticky}
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const SidebarItem = as<'div', css.SidebarItemVariants>(
|
||||
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
|
||||
<AsSidebarAvatarBox
|
||||
className={classNames(css.SidebarItem({ active }), className)}
|
||||
data-sidebar-item=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -54,6 +55,7 @@ export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProp
|
||||
<Avatar
|
||||
className={classNames(css.SidebarAvatar({ size, outlined }), className)}
|
||||
radii={radii}
|
||||
data-sidebar-avatar=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
@@ -64,6 +66,7 @@ export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
|
||||
({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
|
||||
<AsSidebarFolder
|
||||
className={classNames(css.SidebarFolder({ state }), className)}
|
||||
data-sidebar-folder=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -1,95 +1,149 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const CheckButtonContainer = style({
|
||||
const indeterminate = keyframes({
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(250%)' },
|
||||
});
|
||||
|
||||
export const IdleSlot = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
height: '100%',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const CheckButton = style({
|
||||
export const GhostCheck = style({
|
||||
all: 'unset',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
width: toRem(28),
|
||||
height: toRem(28),
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
color: color.Secondary.Main,
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'opacity 0.2s, background-color 0.15s',
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s ease, background-color 0.15s ease',
|
||||
selectors: {
|
||||
'&[data-visible="true"]': {
|
||||
opacity: 0.7,
|
||||
opacity: 0.65,
|
||||
},
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
},
|
||||
|
||||
':hover': {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
':active': {
|
||||
backgroundColor: color.Surface.ContainerActive,
|
||||
},
|
||||
|
||||
':focus-visible': {
|
||||
outline: `2px solid ${color.Secondary.Main}`,
|
||||
outlineOffset: '2px',
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const UpdateButton = style({
|
||||
all: 'unset',
|
||||
export const Bar = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
gap: config.space.S200,
|
||||
height: toRem(22),
|
||||
minWidth: toRem(120),
|
||||
maxWidth: toRem(200),
|
||||
padding: `0 ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
color: color.Success.Main,
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'background-color 0.15s',
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
':hover': {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
export const BarTrack = style({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
height: toRem(4),
|
||||
borderRadius: toRem(2),
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.Surface.ContainerLine,
|
||||
minWidth: toRem(64),
|
||||
});
|
||||
|
||||
':active': {
|
||||
backgroundColor: color.Surface.ContainerActive,
|
||||
},
|
||||
export const BarFill = style({
|
||||
position: 'absolute',
|
||||
inset: '0 auto 0 0',
|
||||
height: '100%',
|
||||
borderRadius: 'inherit',
|
||||
backgroundColor: color.Success.Main,
|
||||
transition: 'width 120ms linear',
|
||||
});
|
||||
|
||||
':focus-visible': {
|
||||
outline: `2px solid ${color.Success.Main}`,
|
||||
outlineOffset: '2px',
|
||||
export const BarIndeterminate = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '40%',
|
||||
borderRadius: 'inherit',
|
||||
backgroundColor: color.Secondary.Main,
|
||||
animation: `${indeterminate} 1s ease-in-out infinite`,
|
||||
});
|
||||
|
||||
export const BarLabel = style({
|
||||
flexShrink: 0,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
opacity: 0.9,
|
||||
minWidth: toRem(28),
|
||||
textAlign: 'right',
|
||||
});
|
||||
|
||||
export const Chip = style({
|
||||
height: toRem(22),
|
||||
maxWidth: toRem(260),
|
||||
padding: `0 ${config.space.S100} 0 ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.Success.Container,
|
||||
color: color.Success.OnContainer,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const ChipText = style({
|
||||
maxWidth: toRem(140),
|
||||
});
|
||||
|
||||
export const ChipAction = style({
|
||||
all: 'unset',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
height: toRem(18),
|
||||
padding: `0 ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.Success.Main,
|
||||
color: color.Success.OnMain,
|
||||
fontSize: toRem(11),
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
filter: 'brightness(1.05)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const UpdateMenu = style({
|
||||
minWidth: toRem(280),
|
||||
maxWidth: toRem(320),
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
boxShadow: config.shadow.E400,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const ProgressText = style({
|
||||
color: color.Success.Main,
|
||||
fontWeight: 500,
|
||||
minWidth: toRem(35),
|
||||
textAlign: 'center',
|
||||
export const ChipDismiss = style({
|
||||
all: 'unset',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(18),
|
||||
height: toRem(18),
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
fontSize: toRem(14),
|
||||
lineHeight: 1,
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,138 +1,136 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import * as css from './UpdateNotification.css';
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string;
|
||||
releaseNotes?: string;
|
||||
releaseDate?: string;
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
percent: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'ready';
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [phase, setPhase] = useState<UpdaterPhase>('idle');
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [updateReady, setUpdateReady] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
|
||||
const [isMock, setIsMock] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're in Electron environment
|
||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
||||
return;
|
||||
const electron = (window as any).electron;
|
||||
if (!electron?.updater) return undefined;
|
||||
|
||||
const { updater } = electron;
|
||||
|
||||
updater.isMock?.().then((result: { success?: boolean; data?: { mock?: boolean } }) => {
|
||||
if (result?.data?.mock) setIsMock(true);
|
||||
}).catch(() => {});
|
||||
|
||||
updater.onUpdateAvailable((info: UpdateInfo) => {
|
||||
setUpdateInfo(info);
|
||||
if (info.mock) setIsMock(true);
|
||||
setPhase('available');
|
||||
setDownloadProgress(0);
|
||||
});
|
||||
|
||||
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
||||
setPhase('downloading');
|
||||
setDownloadProgress(Math.min(100, Math.round(progress.percent)));
|
||||
});
|
||||
|
||||
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
||||
setUpdateInfo(info);
|
||||
setPhase('ready');
|
||||
setDownloadProgress(100);
|
||||
});
|
||||
|
||||
const onNotAvailable = () => {
|
||||
setPhase('idle');
|
||||
setUpdateInfo(null);
|
||||
setDownloadProgress(0);
|
||||
};
|
||||
// Optional channel — ignore if preload doesn't expose a dedicated listener
|
||||
if (electron.updater.onUpdateNotAvailable) {
|
||||
electron.updater.onUpdateNotAvailable(onNotAvailable);
|
||||
}
|
||||
|
||||
const { updater } = (window as any).electron;
|
||||
|
||||
// Listen for update available
|
||||
updater.onUpdateAvailable((info: UpdateInfo) => {
|
||||
console.log('Update available:', info.version);
|
||||
setUpdateAvailable(true);
|
||||
setUpdateInfo(info);
|
||||
setDownloading(false);
|
||||
setUpdateReady(false);
|
||||
setChecking(false);
|
||||
});
|
||||
|
||||
// Listen for download progress
|
||||
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
||||
setDownloadProgress(Math.round(progress.percent));
|
||||
});
|
||||
|
||||
// Listen for update downloaded
|
||||
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
||||
console.log('Update downloaded:', info.version);
|
||||
setDownloading(false);
|
||||
setUpdateReady(true);
|
||||
});
|
||||
|
||||
// Cleanup - IPC listeners don't need manual cleanup in this case
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
setChecking(true);
|
||||
const handleCheck = useCallback(async () => {
|
||||
setPhase('checking');
|
||||
try {
|
||||
const result = await (window as any).electron.updater.checkForUpdates();
|
||||
console.log('Update check result:', result);
|
||||
|
||||
// Handle error response (including dev mode error)
|
||||
if (!result.success) {
|
||||
console.warn('Update check failed:', result.error);
|
||||
setChecking(false);
|
||||
return;
|
||||
if (!result?.success) {
|
||||
setPhase('idle');
|
||||
}
|
||||
|
||||
// If no update found, show feedback briefly
|
||||
setTimeout(() => {
|
||||
if (!updateAvailable) {
|
||||
setChecking(false);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
setChecking(false);
|
||||
// available event will advance phase; if nothing comes, fall back
|
||||
window.setTimeout(() => {
|
||||
setPhase((current) => (current === 'checking' ? 'idle' : current));
|
||||
}, 4000);
|
||||
} catch {
|
||||
setPhase('idle');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
const handleDownload = useCallback(async () => {
|
||||
setPhase('downloading');
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
await (window as any).electron.updater.downloadUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to download update:', error);
|
||||
setDownloading(false);
|
||||
} catch {
|
||||
setPhase('available');
|
||||
}
|
||||
setMenuAnchor(undefined);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
const handleInstall = useCallback(async () => {
|
||||
try {
|
||||
await (window as any).electron.updater.installUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to install update:', error);
|
||||
if (isMock) {
|
||||
setPhase('idle');
|
||||
setUpdateInfo(null);
|
||||
setDownloadProgress(0);
|
||||
}
|
||||
} catch {
|
||||
// keep ready state
|
||||
}
|
||||
};
|
||||
}, [isMock]);
|
||||
|
||||
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (menuAnchor) {
|
||||
setMenuAnchor(undefined);
|
||||
} else {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
|
||||
}
|
||||
};
|
||||
const handleDismiss = useCallback(() => {
|
||||
setPhase('idle');
|
||||
setUpdateInfo(null);
|
||||
setDownloadProgress(0);
|
||||
}, []);
|
||||
|
||||
// Don't render anything if not in Electron
|
||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show check button if no update status
|
||||
if (!updateAvailable && !updateReady && !checking) {
|
||||
if (phase === 'idle') {
|
||||
return (
|
||||
<div
|
||||
className={css.CheckButtonContainer}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={css.IdleSlot}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<button
|
||||
className={css.CheckButton}
|
||||
data-visible={isHovered}
|
||||
onClick={handleCheckForUpdates}
|
||||
aria-label="Check for updates"
|
||||
title="Check for updates"
|
||||
type="button"
|
||||
className={css.GhostCheck}
|
||||
data-visible={hovered || isMock ? 'true' : undefined}
|
||||
onClick={handleCheck}
|
||||
title={isMock ? 'Check for updates (mock)' : 'Check for updates'}
|
||||
aria-label="Check for updates"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
||||
stroke="currentColor"
|
||||
@@ -140,109 +138,75 @@ export function UpdateNotification() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 14H13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show checking state
|
||||
if (checking) {
|
||||
if (phase === 'checking') {
|
||||
return (
|
||||
<button className={css.UpdateButton} disabled type="button">
|
||||
<Spinner variant="Secondary" size="50" />
|
||||
</button>
|
||||
<div className={css.Bar} title="Checking for updates…">
|
||||
<div className={css.BarTrack}>
|
||||
<div className={css.BarIndeterminate} />
|
||||
</div>
|
||||
<Text className={css.BarLabel} size="L400">
|
||||
Checking…
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show update available/ready state
|
||||
if (!updateAvailable && !updateReady) {
|
||||
return null;
|
||||
if (phase === 'downloading') {
|
||||
return (
|
||||
<div
|
||||
className={css.Bar}
|
||||
title={`Downloading update${updateInfo ? ` ${updateInfo.version}` : ''}… ${downloadProgress}%`}
|
||||
>
|
||||
<div className={css.BarTrack}>
|
||||
<div className={css.BarFill} style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<Text className={css.BarLabel} size="L400">
|
||||
{downloadProgress}%
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={css.UpdateButton}
|
||||
onClick={handleMenuToggle}
|
||||
aria-label={updateReady ? 'Update ready' : 'Update available'}
|
||||
title={updateReady ? 'Update downloaded - click to install' : 'New version available'}
|
||||
type="button"
|
||||
>
|
||||
{downloading ? (
|
||||
<Spinner variant="Secondary" size="50" />
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 14H13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
if (phase === 'ready') {
|
||||
return (
|
||||
<Box className={css.Chip} alignItems="Center" gap="100">
|
||||
<Text className={css.ChipText} size="L400" truncate>
|
||||
{isMock ? 'Mock ready' : 'Update ready'}
|
||||
{updateInfo ? ` · ${updateInfo.version}` : ''}
|
||||
</Text>
|
||||
<button type="button" className={css.ChipAction} onClick={handleInstall}>
|
||||
Restart
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={8}
|
||||
content={
|
||||
<Menu className={css.UpdateMenu}>
|
||||
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="H5" priority="400">
|
||||
{updateReady ? 'Update Ready' : 'Update Available'}
|
||||
</Text>
|
||||
{updateInfo && (
|
||||
<Text size="T200" priority="300">
|
||||
Version {updateInfo.version}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{updateReady ? (
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="400"
|
||||
onClick={handleInstall}
|
||||
fill="Solid"
|
||||
>
|
||||
<Text size="B400">Install and Restart</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="400"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
fill="Solid"
|
||||
>
|
||||
<Text size="B400">
|
||||
{downloading ? `Downloading ${downloadProgress}%` : 'Download Update'}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Menu>
|
||||
}
|
||||
// available
|
||||
return (
|
||||
<Box className={css.Chip} alignItems="Center" gap="100">
|
||||
<Text className={css.ChipText} size="L400" truncate>
|
||||
{isMock ? 'Mock update' : 'Update'}
|
||||
{updateInfo ? ` ${updateInfo.version}` : ''}
|
||||
</Text>
|
||||
<button type="button" className={css.ChipAction} onClick={handleDownload}>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ChipDismiss}
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss update"
|
||||
title="Dismiss"
|
||||
>
|
||||
{null}
|
||||
</PopOut>
|
||||
</>
|
||||
×
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { AvatarFallback, AvatarImage, color } from 'folds';
|
||||
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import * as css from './UserAvatar.css';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
||||
|
||||
// Check if image is already in browser cache
|
||||
function isImageInBrowserCache(url: string): boolean {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
return img.complete && img.naturalWidth > 0;
|
||||
}
|
||||
import {
|
||||
getCachedAuthenticatedMediaUrl,
|
||||
isAuthenticatedMediaUrl,
|
||||
} from '../../utils/authenticatedMediaCache';
|
||||
import {
|
||||
getMediaBlurHash,
|
||||
getMediaDimensions,
|
||||
rememberMediaBlurHash,
|
||||
} from '../../state/mediaDimensionCache';
|
||||
|
||||
type UserAvatarProps = {
|
||||
className?: string;
|
||||
@@ -25,20 +28,28 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
||||
const [error, setError] = useState(false);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
|
||||
// Check both our cache and browser's native cache
|
||||
const [loaded, setLoaded] = useState(() => {
|
||||
if (isAvatarCached(src)) return true;
|
||||
if (authenticatedSrc && isImageInBrowserCache(authenticatedSrc)) {
|
||||
cacheAvatar(src);
|
||||
return true;
|
||||
const blurHash = getMediaBlurHash(src ?? '');
|
||||
const dims = getMediaDimensions(src ?? '');
|
||||
const blobCached =
|
||||
!!src &&
|
||||
(!useAuthentication ||
|
||||
!isAuthenticatedMediaUrl(src) ||
|
||||
!!getCachedAuthenticatedMediaUrl(src));
|
||||
|
||||
const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
authenticatedSrc &&
|
||||
(isAvatarCached(src) ||
|
||||
(src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
|
||||
) {
|
||||
setLoaded(true);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
}, [authenticatedSrc, src, useAuthentication]);
|
||||
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Also check on mount if image is already complete (browser cached)
|
||||
useEffect(() => {
|
||||
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||
setLoaded(true);
|
||||
@@ -51,10 +62,10 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
||||
setLoaded(true);
|
||||
cacheAvatar(src);
|
||||
pruneAvatarCache();
|
||||
rememberMediaBlurHash(src ?? '', evt.currentTarget);
|
||||
};
|
||||
|
||||
// No src or error - show fallback only
|
||||
if (!authenticatedSrc || error) {
|
||||
if (!src || error) {
|
||||
return (
|
||||
<AvatarFallback
|
||||
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
|
||||
@@ -65,8 +76,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
||||
);
|
||||
}
|
||||
|
||||
// If already cached, show image directly without fallback flash
|
||||
if (loaded) {
|
||||
if (authenticatedSrc && loaded) {
|
||||
return (
|
||||
<AvatarImage
|
||||
className={classNames(css.UserAvatar, className)}
|
||||
@@ -80,13 +90,17 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state - render image with hidden fallback behind it
|
||||
// Image loads invisibly, then we show it once complete
|
||||
const blurResolutionX = 32;
|
||||
const blurResolutionY =
|
||||
dims?.w && dims?.h && dims.w > 0
|
||||
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
|
||||
: 32;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: colorMXID(userId),
|
||||
style={{
|
||||
backgroundColor: colorMXID(userId),
|
||||
color: color.Surface.Container,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -95,16 +109,40 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
||||
>
|
||||
{renderFallback()}
|
||||
</AvatarFallback>
|
||||
<AvatarImage
|
||||
ref={imgRef}
|
||||
className={classNames(css.UserAvatar, className)}
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
src={authenticatedSrc}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
onLoad={handleLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
{blurHash && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 'inherit',
|
||||
filter: 'blur(6px)',
|
||||
transform: 'scale(1.08)',
|
||||
}}
|
||||
>
|
||||
<Blurhash
|
||||
hash={blurHash}
|
||||
width="100%"
|
||||
height="100%"
|
||||
resolutionX={blurResolutionX}
|
||||
resolutionY={blurResolutionY}
|
||||
punch={1.1}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{authenticatedSrc && (
|
||||
<AvatarImage
|
||||
ref={imgRef}
|
||||
className={classNames(css.UserAvatar, className)}
|
||||
style={{ position: 'relative', zIndex: 1, opacity: loaded ? 1 : 0 }}
|
||||
src={authenticatedSrc}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
onLoad={handleLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const VirtualTile = as<'div', VirtualTileProps>(
|
||||
({ className, virtualItem, style, ...props }, ref) => (
|
||||
<div
|
||||
className={classNames(css.VirtualTile, className)}
|
||||
style={{ top: virtualItem.start, ...style }}
|
||||
style={{ top: virtualItem.start, zIndex: virtualItem.index, ...style }}
|
||||
data-index={virtualItem.index}
|
||||
{...props}
|
||||
ref={ref}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||
import { useRooms } from '../../state/hooks/roomList';
|
||||
import { useRooms, useDirects } from '../../state/hooks/roomList';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||
@@ -53,7 +53,13 @@ export function MessageSearch({
|
||||
}: MessageSearchProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const nonDirectRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const directRooms = useDirects(mx, allRoomsAtom, mDirects);
|
||||
// Include DMs — previously useRooms alone stripped them and broke DM search
|
||||
const allRooms = useMemo(
|
||||
() => [...nonDirectRooms, ...directRooms],
|
||||
[nonDirectRooms, directRooms]
|
||||
);
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
ISearchRequestBody,
|
||||
ISearchResponse,
|
||||
ISearchResult,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
SearchOrderBy,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
@@ -26,6 +29,12 @@ export type SearchResult = {
|
||||
groups: ResultGroup[];
|
||||
};
|
||||
|
||||
const EMPTY_CONTEXT: IResultContext = {
|
||||
events_before: [],
|
||||
events_after: [],
|
||||
profile_info: {},
|
||||
};
|
||||
|
||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
||||
const roomEvents = result.search_categories.room_events;
|
||||
|
||||
const searchResult: SearchResult = {
|
||||
return {
|
||||
nextToken: roomEvents?.next_batch,
|
||||
highlights: roomEvents?.highlights ?? [],
|
||||
groups: groupSearchResult(roomEvents?.results ?? []),
|
||||
};
|
||||
};
|
||||
|
||||
return searchResult;
|
||||
const eventToSearchEvent = (event: MatrixEvent, roomId: string): IEventWithRoomId | undefined => {
|
||||
const eventId = event.getId();
|
||||
if (!eventId) return undefined;
|
||||
|
||||
const content = event.getClearContent() ?? event.getContent();
|
||||
return {
|
||||
event_id: eventId,
|
||||
type: event.getWireType() === 'm.room.encrypted' ? 'm.room.message' : event.getType(),
|
||||
sender: event.getSender() ?? '',
|
||||
origin_server_ts: event.getTs(),
|
||||
content,
|
||||
room_id: roomId,
|
||||
unsigned: event.getUnsigned(),
|
||||
};
|
||||
};
|
||||
|
||||
const getSearchableBody = (event: MatrixEvent): string | undefined => {
|
||||
if (event.isRedacted()) return undefined;
|
||||
|
||||
// After decryption, clear content is available even if wire type was encrypted
|
||||
const content = event.getClearContent() ?? event.getContent();
|
||||
if (!content || typeof content !== 'object') return undefined;
|
||||
|
||||
// Skip still-encrypted payloads
|
||||
if (event.isEncrypted() && !event.isDecryptionFailure() && !event.getClearContent()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const msgType = content.msgtype;
|
||||
if (msgType && msgType !== 'm.text' && msgType !== 'm.notice' && msgType !== 'm.emote') {
|
||||
// Still allow filename / body on media
|
||||
}
|
||||
|
||||
const body = typeof content.body === 'string' ? content.body : undefined;
|
||||
const formatted =
|
||||
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
|
||||
return [body, formatted].filter(Boolean).join('\n') || undefined;
|
||||
};
|
||||
|
||||
const highlightsFromTerm = (term: string): string[] =>
|
||||
term
|
||||
.split(/\s+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 1);
|
||||
|
||||
/**
|
||||
* Search decrypted timeline events already loaded for a room.
|
||||
* Server-side search cannot match E2EE message bodies.
|
||||
*/
|
||||
const searchLocalRoomTimeline = (room: Room, term: string): ResultItem[] => {
|
||||
const needle = term.trim().toLowerCase();
|
||||
if (!needle) return [];
|
||||
|
||||
const events = room.getLiveTimeline().getEvents();
|
||||
const matches: ResultItem[] = [];
|
||||
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const event = events[i];
|
||||
const haystack = getSearchableBody(event);
|
||||
if (!haystack || !haystack.toLowerCase().includes(needle)) continue;
|
||||
|
||||
const searchEvent = eventToSearchEvent(event, room.roomId);
|
||||
if (!searchEvent) continue;
|
||||
|
||||
matches.push({
|
||||
rank: 1,
|
||||
event: searchEvent,
|
||||
context: EMPTY_CONTEXT,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
const isEncryptedRoom = (mx: MatrixClient, roomId: string): boolean => {
|
||||
const room = mx.getRoom(roomId);
|
||||
return !!room?.hasEncryptionStateEvent();
|
||||
};
|
||||
|
||||
const searchLocalRooms = (mx: MatrixClient, roomIds: string[], term: string): SearchResult => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
roomIds.forEach((roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
const items = searchLocalRoomTimeline(room, term);
|
||||
if (items.length > 0) {
|
||||
groups.push({ roomId, items });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
highlights: highlightsFromTerm(term),
|
||||
groups,
|
||||
};
|
||||
};
|
||||
|
||||
export type MessageSearchParams = {
|
||||
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
|
||||
rooms?: string[];
|
||||
senders?: string[];
|
||||
};
|
||||
|
||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
const mx = useMatrixClient();
|
||||
const { term, order, rooms, senders } = params;
|
||||
|
||||
const searchMessages = useCallback(
|
||||
async (nextBatch?: string) => {
|
||||
if (!term)
|
||||
if (!term) {
|
||||
return {
|
||||
highlights: [],
|
||||
groups: [],
|
||||
};
|
||||
const limit = 20;
|
||||
}
|
||||
|
||||
const scopedRooms = rooms?.filter(Boolean) ?? [];
|
||||
const encryptedScoped =
|
||||
scopedRooms.length > 0 && scopedRooms.every((roomId) => isEncryptedRoom(mx, roomId));
|
||||
|
||||
// Encrypted-only scope: server cannot see bodies — search loaded timeline locally
|
||||
if (encryptedScoped && !nextBatch) {
|
||||
return searchLocalRooms(mx, scopedRooms, term);
|
||||
}
|
||||
|
||||
const limit = 20;
|
||||
const requestBody: ISearchRequestBody = {
|
||||
search_categories: {
|
||||
room_events: {
|
||||
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
},
|
||||
};
|
||||
|
||||
const r = await mx.search({
|
||||
body: requestBody,
|
||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||
});
|
||||
return parseSearchResult(r);
|
||||
try {
|
||||
const r = await mx.search({
|
||||
body: requestBody,
|
||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||
});
|
||||
const parsed = parseSearchResult(r);
|
||||
|
||||
if (
|
||||
parsed.groups.length === 0 &&
|
||||
!nextBatch &&
|
||||
scopedRooms.length > 0 &&
|
||||
scopedRooms.some((roomId) => isEncryptedRoom(mx, roomId))
|
||||
) {
|
||||
return searchLocalRooms(
|
||||
mx,
|
||||
scopedRooms.filter((id) => isEncryptedRoom(mx, id)),
|
||||
term
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
if (!nextBatch && scopedRooms.length > 0) {
|
||||
return searchLocalRooms(mx, scopedRooms, term);
|
||||
}
|
||||
throw new Error('Message search failed');
|
||||
}
|
||||
},
|
||||
[mx, term, order, rooms, senders]
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
|
||||
className={classNames(css.CategoryButton, className)}
|
||||
variant="Background"
|
||||
radii="Pill"
|
||||
data-nav-category-btn=""
|
||||
before={
|
||||
<Icon
|
||||
className={css.CategoryButtonIcon}
|
||||
|
||||
@@ -41,7 +41,13 @@ export function UnjoinedSubRoomItem({ roomId, depth, isLast = false }: UnjoinedS
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{ paddingLeft, padding: `${config.space.S100} ${config.space.S200}`, minHeight: '1.5rem' }}
|
||||
data-nav-depth={depth}
|
||||
style={{
|
||||
paddingLeft,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
minHeight: '1.5rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
>
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Box, Line } from 'folds';
|
||||
import { decodeRouteParam } from '../../pages/pathUtils';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import { RoomView } from './RoomView';
|
||||
import { MembersDrawer } from './MembersDrawer';
|
||||
import { MediaDrawer } from './room-media-menu';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -16,6 +17,7 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||
import { isForum } from '../../utils/room';
|
||||
import { ForumRoomView } from './ForumRoomView';
|
||||
|
||||
@@ -26,13 +28,18 @@ export function Room() {
|
||||
const mx = useMatrixClient();
|
||||
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [isPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [isMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const markAsRead = useMarkAsRead(mx);
|
||||
const forumRoom = isForum(room);
|
||||
const skinny = screenSize !== ScreenSize.Desktop;
|
||||
const showMediaSolo = skinny && isMediaDrawer;
|
||||
const showRightDrawer =
|
||||
!skinny && (isPeopleDrawer || isMediaDrawer);
|
||||
|
||||
// Update titlebar with current room ID
|
||||
useEffect(() => {
|
||||
@@ -55,11 +62,21 @@ export function Room() {
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<Box grow="Yes">
|
||||
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
|
||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
{!showMediaSolo &&
|
||||
(forumRoom ? (
|
||||
<ForumRoomView room={room} eventId={eventId} />
|
||||
) : (
|
||||
<RoomView room={room} eventId={eventId} />
|
||||
))}
|
||||
{showMediaSolo && <MediaDrawer room={room} solo />}
|
||||
{!showMediaSolo && showRightDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
{isMediaDrawer ? (
|
||||
<MediaDrawer room={room} />
|
||||
) : (
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
17
src/app/features/room/RoomInput.css.ts
Normal file
17
src/app/features/room/RoomInput.css.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config } from 'folds';
|
||||
import * as editorCss from '../../components/editor/Editor.css';
|
||||
|
||||
export const RoomInputWrap = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
/* Notebook-flat composer chrome — Stationery only (other themes keep Editor inset border) */
|
||||
globalStyle(`.stationery ${RoomInputWrap} .${editorCss.Editor}`, {
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none',
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderBottom: 'none',
|
||||
});
|
||||
@@ -45,7 +45,6 @@ import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import {
|
||||
TUploadContent,
|
||||
encryptFile,
|
||||
getImageInfo,
|
||||
getMxIdLocalPart,
|
||||
mxcUrlToHttp,
|
||||
@@ -54,6 +53,7 @@ import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||
import { useFileDropZone } from '../../hooks/useFileDrop';
|
||||
import { useRoomUploadFiles } from '../../hooks/useRoomUploadFiles';
|
||||
import {
|
||||
TUploadItem,
|
||||
TUploadMetadata,
|
||||
@@ -76,7 +76,6 @@ import {
|
||||
createUploadFamilyObserverAtom,
|
||||
} from '../../state/upload';
|
||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||
import { safeFile } from '../../utils/mimeTypes';
|
||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -101,13 +100,25 @@ import colorMXID from '../../../util/colorMXID';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||
import { getMemberAvatarMxc } from '../../utils/room';
|
||||
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
|
||||
import * as css from './RoomInput.css';
|
||||
|
||||
/**
|
||||
* Creates a UUID used to group image events into one carousel.
|
||||
*/
|
||||
const createCarouselUuid = (): string => {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
interface RoomInputProps {
|
||||
editor: Editor;
|
||||
@@ -212,42 +223,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
});
|
||||
}, [commands]);
|
||||
|
||||
const { handleFiles: enqueueFiles } = useRoomUploadFiles(room);
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setUploadBoard(true);
|
||||
const safeFiles = files.map(safeFile);
|
||||
const fileItems: TUploadItem[] = [];
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||
);
|
||||
encryptFiles.forEach((ef) =>
|
||||
fileItems.push({
|
||||
...ef,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
fileItems.push({
|
||||
file: f,
|
||||
originalFile: f,
|
||||
encInfo: undefined,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
setSelectedFiles({
|
||||
type: 'PUT',
|
||||
item: fileItems,
|
||||
});
|
||||
await enqueueFiles(files);
|
||||
},
|
||||
[setSelectedFiles, room]
|
||||
[enqueueFiles]
|
||||
);
|
||||
const pickFile = useFilePicker(handleFiles, true);
|
||||
const handlePaste = useFilePasteHandler(handleFiles);
|
||||
@@ -306,12 +288,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
};
|
||||
|
||||
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
||||
const imageUploads = uploads.filter((upload) => {
|
||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||
return !!fileItem && fileItem.file.type.startsWith('image');
|
||||
});
|
||||
const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined;
|
||||
const imageUploadIndexByFile = new Map(
|
||||
imageUploads.map((upload, index) => [upload.file, index] as const)
|
||||
);
|
||||
|
||||
const contentsPromises = uploads.map(async (upload) => {
|
||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||
if (!fileItem) throw new Error('Broken upload');
|
||||
|
||||
if (fileItem.file.type.startsWith('image')) {
|
||||
return getImageMsgContent(mx, fileItem, upload.mxc);
|
||||
const imageIndex = imageUploadIndexByFile.get(upload.file);
|
||||
|
||||
return getImageMsgContent(
|
||||
mx,
|
||||
fileItem,
|
||||
upload.mxc,
|
||||
carouselUuid !== undefined && imageIndex !== undefined
|
||||
? {
|
||||
uuid: carouselUuid,
|
||||
index: imageIndex,
|
||||
total: imageUploads.length,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
if (fileItem.file.type.startsWith('video')) {
|
||||
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
||||
@@ -584,7 +588,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div ref={ref} className={css.RoomInputWrap}>
|
||||
{selectedFiles.length > 0 && (
|
||||
<UploadBoard
|
||||
header={
|
||||
@@ -690,8 +694,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderBottom: 'none',
|
||||
borderTopLeftRadius: config.radii.R400,
|
||||
borderTopRightRadius: config.radii.R400,
|
||||
borderRadius: isStationeryTheme(theme) ? 0 : undefined,
|
||||
borderTopLeftRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||
borderTopRightRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||
marginBottom: config.space.S100,
|
||||
}}
|
||||
direction="Column"
|
||||
|
||||
@@ -5,6 +5,6 @@ export const RoomInputPlaceholder = style({
|
||||
minHeight: toRem(48),
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R400,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: 0,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ export const TimelineFloat = recipe({
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
// Above timeline media carousels / sticky message chrome
|
||||
zIndex: 10,
|
||||
minWidth: 'max-content',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -85,7 +85,6 @@ import {
|
||||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import * as css from './RoomTimeline.css';
|
||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||
@@ -103,6 +102,14 @@ import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalSc
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import {
|
||||
clearRoomReturnAnchor,
|
||||
clearRoomScrollState,
|
||||
getRoomReturnAnchor,
|
||||
getRoomScrollState,
|
||||
saveRoomScrollState,
|
||||
setRoomReturnAnchor,
|
||||
} from '../../state/roomScrollCache';
|
||||
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
@@ -620,6 +627,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const location = useLocation();
|
||||
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
|
||||
|
||||
const savedScrollRef = useRef(
|
||||
eventId || scrollToLatest ? undefined : getRoomScrollState(room.roomId)
|
||||
);
|
||||
|
||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||
|
||||
const [unreadInfo, setUnreadInfo] = useState(() =>
|
||||
@@ -631,7 +642,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}
|
||||
|
||||
const atBottomAnchorRef = useRef<HTMLElement>(null);
|
||||
const [atBottom, setAtBottom] = useState<boolean>(true);
|
||||
const [atBottom, setAtBottom] = useState<boolean>(
|
||||
() => savedScrollRef.current?.atBottom ?? true
|
||||
);
|
||||
const atBottomRef = useRef(atBottom);
|
||||
atBottomRef.current = atBottom;
|
||||
|
||||
@@ -639,16 +652,54 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
sender: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const [returnToEventId, setReturnToEventIdState] = useState<string | null>(
|
||||
() => getRoomReturnAnchor(room.roomId) ?? null
|
||||
);
|
||||
|
||||
const setReturnToEventId = useCallback(
|
||||
(eventId: string | null) => {
|
||||
if (eventId) setRoomReturnAnchor(room.roomId, eventId);
|
||||
else clearRoomReturnAnchor(room.roomId);
|
||||
setReturnToEventIdState(eventId);
|
||||
},
|
||||
[room.roomId]
|
||||
);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const timelineContentRef = useRef<HTMLDivElement>(null);
|
||||
const timelineContentHeightRef = useRef(0);
|
||||
const restoringScrollRef = useRef(false);
|
||||
const scrollToBottomRef = useRef({
|
||||
count: 0,
|
||||
smooth: true,
|
||||
force: false,
|
||||
});
|
||||
|
||||
const getViewportAnchorEventId = useCallback((): string | undefined => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return undefined;
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const centerY = scrollRect.top + scrollRect.height / 2;
|
||||
const items = scrollEl.querySelectorAll<HTMLElement>('[data-message-id]');
|
||||
|
||||
let bestId: string | undefined;
|
||||
let bestDist = Infinity;
|
||||
|
||||
items.forEach((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) return;
|
||||
const mid = (rect.top + rect.bottom) / 2;
|
||||
const dist = Math.abs(mid - centerY);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestId = el.getAttribute('data-message-id') ?? undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return bestId;
|
||||
}, []);
|
||||
|
||||
const [focusItem, setFocusItem] = useState<
|
||||
| {
|
||||
index: number;
|
||||
@@ -680,9 +731,49 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
);
|
||||
const parseMemberEvent = useMemberEventParser();
|
||||
|
||||
const [timeline, setTimeline] = useState<Timeline>(() =>
|
||||
eventId ? getEmptyTimeline() : getInitialTimeline(room)
|
||||
const [timeline, setTimeline] = useState<Timeline>(() => {
|
||||
if (eventId) return getEmptyTimeline();
|
||||
const initial = getInitialTimeline(room);
|
||||
const saved = savedScrollRef.current;
|
||||
if (!saved?.range) return initial;
|
||||
|
||||
const evLength = getTimelinesEventsCount(initial.linkedTimelines);
|
||||
if (evLength === 0) return initial;
|
||||
|
||||
const start = Math.min(saved.range.start, Math.max(0, evLength - 1));
|
||||
const end = Math.min(Math.max(start + 1, saved.range.end), evLength);
|
||||
|
||||
return { ...initial, range: { start, end } };
|
||||
});
|
||||
const timelineRef = useRef(timeline);
|
||||
timelineRef.current = timeline;
|
||||
|
||||
const getRangeCenterEventId = useCallback((): string | undefined => {
|
||||
const { linkedTimelines, range } = timelineRef.current;
|
||||
if (range.end <= range.start) return undefined;
|
||||
const mid = Math.floor((range.start + range.end - 1) / 2);
|
||||
const [tm, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, mid);
|
||||
if (!tm) return undefined;
|
||||
return getTimelineEvent(tm, getTimelineRelativeIndex(mid, baseIndex))?.getId();
|
||||
}, []);
|
||||
|
||||
const persistTimelineScroll = useCallback(
|
||||
(roomId: string) => {
|
||||
if (eventId) return;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const distanceFromBottom =
|
||||
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
|
||||
saveRoomScrollState(roomId, {
|
||||
top: scrollEl.scrollTop,
|
||||
atBottom: distanceFromBottom <= 50,
|
||||
range: timelineRef.current.range,
|
||||
});
|
||||
},
|
||||
[eventId]
|
||||
);
|
||||
|
||||
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
|
||||
const liveTimelineLinked =
|
||||
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
|
||||
@@ -693,6 +784,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
|
||||
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
||||
|
||||
// Historical / non-live windows are never "at bottom" of the room.
|
||||
useEffect(() => {
|
||||
if (!liveTimelineLinked || !rangeAtEnd) {
|
||||
setAtBottom(false);
|
||||
}
|
||||
}, [liveTimelineLinked, rangeAtEnd]);
|
||||
|
||||
const handleTimelinePagination = useTimelinePagination(
|
||||
mx,
|
||||
timeline,
|
||||
@@ -757,6 +855,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||
const isBottomPinnedEvent =
|
||||
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
||||
const wasAwayFromBottom = !atBottomRef.current;
|
||||
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
||||
|
||||
// Always update timeline with new message
|
||||
@@ -777,6 +876,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||
}
|
||||
}
|
||||
// Sending while scrolled up jumps to latest — offer return to previous spot
|
||||
if (wasAwayFromBottom && isBottomPinnedEvent) {
|
||||
const anchorEventId =
|
||||
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||
if (anchorEventId) {
|
||||
setReturnToEventId(anchorEventId);
|
||||
}
|
||||
}
|
||||
} else if (!document.hasFocus() && !unreadInfo) {
|
||||
// Show unread notification for other users' messages when unfocused
|
||||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
@@ -803,7 +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();
|
||||
if (!editorBaseEntry || !scrollElement) return;
|
||||
|
||||
if (restoringScrollRef.current) return;
|
||||
|
||||
if (isScrolledToBottom(scrollElement)) {
|
||||
autoScrollToBottom(scrollElement, false, true);
|
||||
}
|
||||
@@ -882,6 +991,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const previousHeight = timelineContentHeightRef.current;
|
||||
timelineContentHeightRef.current = nextHeight;
|
||||
|
||||
if (restoringScrollRef.current) {
|
||||
const saved = savedScrollRef.current;
|
||||
if (saved) {
|
||||
if (saved.atBottom) {
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
} else {
|
||||
scrollElement.scrollTop = saved.top;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
|
||||
return;
|
||||
}
|
||||
@@ -904,30 +1025,27 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}
|
||||
}, [room, hideActivity, markAsRead]);
|
||||
|
||||
const debounceSetAtBottom = useDebounce(
|
||||
useCallback((entry: IntersectionObserverEntry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
setAtBottom(false);
|
||||
}
|
||||
}, []),
|
||||
{ wait: 1000 }
|
||||
);
|
||||
useIntersectionObserver(
|
||||
useCallback(
|
||||
(entries) => {
|
||||
const target = atBottomAnchorRef.current;
|
||||
if (!target) return;
|
||||
const targetEntry = getIntersectionObserverEntry(target, entries);
|
||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
||||
setAtBottom(true);
|
||||
setLatestUnreadMessage(null);
|
||||
if (document.hasFocus()) {
|
||||
tryAutoMarkAsRead();
|
||||
}
|
||||
if (!targetEntry) return;
|
||||
|
||||
// Leave the live bottom immediately so Jump to Latest stays available.
|
||||
if (!targetEntry.isIntersecting || !atLiveEndRef.current) {
|
||||
setAtBottom(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAtBottom(true);
|
||||
setLatestUnreadMessage(null);
|
||||
if (document.hasFocus()) {
|
||||
tryAutoMarkAsRead();
|
||||
}
|
||||
},
|
||||
[debounceSetAtBottom, tryAutoMarkAsRead]
|
||||
[tryAutoMarkAsRead]
|
||||
),
|
||||
useCallback(
|
||||
() => ({
|
||||
@@ -991,9 +1109,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}
|
||||
}, [eventId, loadEventTimeline]);
|
||||
|
||||
// Restore return-to-previous when switching rooms (survives remount via cache).
|
||||
useEffect(() => {
|
||||
setReturnToEventIdState(getRoomReturnAnchor(room.roomId) ?? null);
|
||||
}, [room.roomId]);
|
||||
|
||||
// Scroll to latest when clicking on already-selected room
|
||||
useEffect(() => {
|
||||
if (scrollToLatest) {
|
||||
clearRoomScrollState(room.roomId);
|
||||
setLatestUnreadMessage(null);
|
||||
setUnreadInfo(undefined);
|
||||
setTimeline(getInitialTimeline(room));
|
||||
@@ -1003,12 +1127,78 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}
|
||||
}, [scrollToLatest, room]);
|
||||
|
||||
// Scroll to bottom on initial timeline load
|
||||
// Remember scroll position when leaving a room (switch DM, nav away, etc.).
|
||||
useLayoutEffect(() => {
|
||||
const roomId = room.roomId;
|
||||
return () => {
|
||||
persistTimelineScroll(roomId);
|
||||
};
|
||||
}, [room.roomId, persistTimelineScroll]);
|
||||
|
||||
// Keep scroll cache updated while reading (useEffect cleanup runs after DOM teardown).
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl || eventId) return undefined;
|
||||
|
||||
const roomId = room.roomId;
|
||||
let raf = 0;
|
||||
const onScroll = () => {
|
||||
if (restoringScrollRef.current) return;
|
||||
window.cancelAnimationFrame(raf);
|
||||
raf = window.requestAnimationFrame(() => persistTimelineScroll(roomId));
|
||||
};
|
||||
|
||||
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
scrollEl.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [room.roomId, eventId, persistTimelineScroll]);
|
||||
|
||||
// Restore saved scroll or default to bottom on first open.
|
||||
useLayoutEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl) {
|
||||
if (!scrollEl) return undefined;
|
||||
|
||||
if (scrollToLatest) {
|
||||
autoScrollToBottom(scrollEl, false, true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const saved = savedScrollRef.current;
|
||||
if (!saved) {
|
||||
autoScrollToBottom(scrollEl, false, true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
restoringScrollRef.current = true;
|
||||
setAtBottom(saved.atBottom);
|
||||
|
||||
const restore = () => {
|
||||
if (saved.atBottom) {
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
} else {
|
||||
scrollEl.scrollTop = saved.top;
|
||||
}
|
||||
};
|
||||
|
||||
restore();
|
||||
const raf1 = window.requestAnimationFrame(() => {
|
||||
restore();
|
||||
window.requestAnimationFrame(() => {
|
||||
restore();
|
||||
window.setTimeout(() => {
|
||||
restore();
|
||||
window.setTimeout(() => {
|
||||
restoringScrollRef.current = false;
|
||||
}, 150);
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf1);
|
||||
restoringScrollRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// if live timeline is linked and unreadInfo change
|
||||
@@ -1052,7 +1242,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// scroll to bottom of timeline
|
||||
const scrollToBottomCount = scrollToBottomRef.current.count;
|
||||
useLayoutEffect(() => {
|
||||
if (scrollToBottomCount > 0) {
|
||||
if (scrollToBottomCount > 0 && !restoringScrollRef.current) {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl) {
|
||||
autoScrollToBottom(
|
||||
@@ -1110,16 +1300,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}, [room, hour24Clock]);
|
||||
|
||||
const handleJumpToLatest = () => {
|
||||
const anchorEventId =
|
||||
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||
if (anchorEventId) {
|
||||
setReturnToEventId(anchorEventId);
|
||||
}
|
||||
|
||||
if (eventId) {
|
||||
navigateRoom(room.roomId, undefined, { replace: true });
|
||||
}
|
||||
clearRoomScrollState(room.roomId);
|
||||
setLatestUnreadMessage(null);
|
||||
setTimeline(getInitialTimeline(room));
|
||||
|
||||
// Already on the live timeline: only snap the virtual window to the end.
|
||||
// A full getInitialTimeline() rebuild feels like a chat reload when far back.
|
||||
if (!eventId && liveTimelineLinked) {
|
||||
setTimeline((ct) => {
|
||||
const evLength = getTimelinesEventsCount(ct.linkedTimelines);
|
||||
return {
|
||||
linkedTimelines: ct.linkedTimelines,
|
||||
range: {
|
||||
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||
end: evLength,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setTimeline(getInitialTimeline(room));
|
||||
}
|
||||
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
scrollToBottomRef.current.force = true;
|
||||
};
|
||||
|
||||
const handleReturnToPrevious = () => {
|
||||
if (!returnToEventId) return;
|
||||
const targetId = returnToEventId;
|
||||
setReturnToEventId(null);
|
||||
handleOpenEvent(targetId);
|
||||
};
|
||||
|
||||
const handleClearReturnToPrevious = () => {
|
||||
setReturnToEventId(null);
|
||||
};
|
||||
|
||||
const handleJumpToUnread = () => {
|
||||
if (unreadInfo?.readUptoEventId) {
|
||||
setTimeline(getEmptyTimeline());
|
||||
@@ -2266,36 +2491,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
return callSummaryJSX || eventJSX;
|
||||
};
|
||||
|
||||
const showUnreadTop =
|
||||
!!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
|
||||
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
||||
|
||||
return (
|
||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
||||
<TimelineFloat position="Top">
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.MessageUnread} />}
|
||||
onClick={handleJumpToUnread}
|
||||
>
|
||||
<Text size="L400">Jump to Unread</Text>
|
||||
</Chip>
|
||||
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.CheckTwice} />}
|
||||
onClick={handleMarkAsRead}
|
||||
>
|
||||
<Text size="L400">Mark as Read</Text>
|
||||
</Chip>
|
||||
</TimelineFloat>
|
||||
)}
|
||||
<Scroll ref={scrollRef} visibility="Hover">
|
||||
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
||||
<Box
|
||||
ref={timelineContentRef}
|
||||
direction="Column"
|
||||
justifyContent="End"
|
||||
data-chatroll=""
|
||||
style={{
|
||||
minHeight: '100%',
|
||||
padding: `${config.space.S600} 0`,
|
||||
@@ -2384,7 +2591,57 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
{!atBottom && (
|
||||
{(showUnreadTop || returnToEventId) && (
|
||||
<TimelineFloat position="Top">
|
||||
{returnToEventId && (
|
||||
<>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowTop} />}
|
||||
onClick={handleReturnToPrevious}
|
||||
>
|
||||
<Text size="L400">Return to Previous</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss return to previous"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClearReturnToPrevious}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
{showUnreadTop && (
|
||||
<>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.MessageUnread} />}
|
||||
onClick={handleJumpToUnread}
|
||||
>
|
||||
<Text size="L400">Jump to Unread</Text>
|
||||
</Chip>
|
||||
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.CheckTwice} />}
|
||||
onClick={handleMarkAsRead}
|
||||
>
|
||||
<Text size="L400">Mark as Read</Text>
|
||||
</Chip>
|
||||
</>
|
||||
)}
|
||||
</TimelineFloat>
|
||||
)}
|
||||
{!atLiveBottom && (
|
||||
<TimelineFloat position="Bottom">
|
||||
<Box direction="Column" gap="200" alignItems="Center">
|
||||
{latestUnreadMessage && (
|
||||
|
||||
@@ -14,7 +14,8 @@ import { RoomTimeline } from './RoomTimeline';
|
||||
import { RoomViewTyping } from './RoomViewTyping';
|
||||
import { RoomTombstone } from './RoomTombstone';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||
import { RoomViewFollowing } from './RoomViewFollowing';
|
||||
import * as roomViewCss from './RoomViewFollowing.css';
|
||||
import { Page } from '../../components/page';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
@@ -109,7 +110,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
<ThreadView room={room} threadRootId={activeThreadId} />
|
||||
) : (
|
||||
<>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
@@ -117,40 +118,42 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
<div className={roomViewCss.RoomViewBottomFloat}>
|
||||
<RoomViewTyping room={room} />
|
||||
{!hideActivity && <RoomViewFollowing room={room} />}
|
||||
</div>
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
{tombstoneEvent ? (
|
||||
{tombstoneEvent ? (
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,20 @@ import { style } from '@vanilla-extract/css';
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
/** Floats typing + read receipts over the bottom of the timeline. */
|
||||
export const RoomViewBottomFloat = style({
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const RoomViewFollowingPlaceholder = style([
|
||||
DefaultReset,
|
||||
{
|
||||
@@ -15,10 +29,12 @@ export const RoomViewFollowing = recipe({
|
||||
{
|
||||
minHeight: toRem(28),
|
||||
padding: `0 ${config.space.S400}`,
|
||||
width: '100%',
|
||||
backgroundColor: color.Surface.Container,
|
||||
marginLeft: 'auto',
|
||||
maxWidth: '50%',
|
||||
backgroundColor: 'transparent',
|
||||
color: color.Surface.OnContainer,
|
||||
outline: 'none',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
|
||||
@@ -35,6 +35,10 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
||||
|
||||
const eventId = latestEvent?.getId();
|
||||
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{eventId && (
|
||||
@@ -56,64 +60,58 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
||||
</Overlay>
|
||||
)}
|
||||
<Box
|
||||
as={names.length > 0 ? 'button' : 'div'}
|
||||
onClick={names.length > 0 ? () => setOpen(true) : undefined}
|
||||
className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
|
||||
as="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={classNames(css.RoomViewFollowing({ clickable: true }), className)}
|
||||
alignItems="Center"
|
||||
justifyContent="End"
|
||||
gap="200"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{names.length > 0 && (
|
||||
<>
|
||||
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
|
||||
<Text size="T300" truncate>
|
||||
{names.length === 1 && (
|
||||
<b>{names[0]}</b>
|
||||
)}
|
||||
{names.length === 2 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
</>
|
||||
)}
|
||||
{names.length === 3 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names[2]}</b>
|
||||
</>
|
||||
)}
|
||||
{names.length > 3 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names.length - 3} others</b>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
|
||||
<Text size="T300" truncate>
|
||||
{names.length === 1 && <b>{names[0]}</b>}
|
||||
{names.length === 2 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
</>
|
||||
)}
|
||||
{names.length === 3 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names[2]}</b>
|
||||
</>
|
||||
)}
|
||||
{names.length > 3 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names.length - 3} others</b>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,19 +7,19 @@ import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { getHomeSearchPath, getSpaceSearchPath, getDirectSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||
import * as css from './RoomViewHeader.css';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
@@ -41,6 +41,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
||||
import { RoomPinMenu } from './room-pin-menu';
|
||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
@@ -56,6 +57,8 @@ import { useRoomCall, useRoomCallMembers } from '../call';
|
||||
import { CallState, CallType } from '../call/types';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
||||
import { useDirectSelected } from '../../hooks/router/useDirectSelected';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
@@ -469,18 +472,31 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const onDirectPath = useDirectSelected();
|
||||
const theme = useTheme();
|
||||
const stationery = isStationeryTheme(theme);
|
||||
|
||||
const pinnedEvents = useRoomPinnedEvents(room);
|
||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||
const ecryptedRoom = !!encryptionEvent;
|
||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
||||
const isDirect = mDirects.has(room.roomId);
|
||||
const avatarMxc = useRoomAvatar(room, isDirect);
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined;
|
||||
const dmAvatarMxc =
|
||||
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
||||
(isDirect ? avatarMxc : undefined);
|
||||
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
|
||||
const dmFolderTabColor =
|
||||
stationery && isDirect
|
||||
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
||||
: undefined;
|
||||
|
||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [isMediaDrawer, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
||||
|
||||
const handleSearchClick = () => {
|
||||
@@ -489,7 +505,9 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
};
|
||||
const path = space
|
||||
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
||||
: getHomeSearchPath();
|
||||
: isDirect || onDirectPath
|
||||
? getDirectSearchPath()
|
||||
: getHomeSearchPath();
|
||||
navigate(withSearchParam(path, searchParams));
|
||||
};
|
||||
|
||||
@@ -501,6 +519,22 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleTogglePeopleDrawer = () => {
|
||||
setPeopleDrawer((drawer) => {
|
||||
const next = !drawer;
|
||||
if (next) setMediaDrawer(false);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleMediaDrawer = () => {
|
||||
setMediaDrawer((open) => {
|
||||
const next = !open;
|
||||
if (next) setPeopleDrawer(false);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader balance={showInPageHeader}>
|
||||
<Box grow="Yes" gap="300">
|
||||
@@ -511,7 +545,24 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
<Box grow="Yes" alignItems="Center" gap="300">
|
||||
<Box
|
||||
data-folder-tab="room"
|
||||
data-folder-tab-dm={isDirect ? '' : undefined}
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
style={{
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
...(dmFolderTabColor
|
||||
? {
|
||||
['--folder-tab-fill' as string]: dmFolderTabColor,
|
||||
background: dmFolderTabColor,
|
||||
backgroundColor: dmFolderTabColor,
|
||||
}
|
||||
: undefined),
|
||||
}}
|
||||
>
|
||||
{!showInPageHeader && (
|
||||
<Avatar size="300">
|
||||
<RoomAvatar
|
||||
@@ -528,7 +579,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
/>
|
||||
</Avatar>
|
||||
)}
|
||||
<Box direction="Column">
|
||||
<Box direction="Column" style={{ minWidth: 0 }}>
|
||||
<Box alignItems="Center" gap="100">
|
||||
<Text size={topic ? 'H5' : 'H3'} truncate>
|
||||
{name}
|
||||
@@ -578,26 +629,25 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box grow="Yes" />
|
||||
<Box shrink="No" alignItems="Center" gap="100">
|
||||
<CallIndicator roomId={room.roomId} />
|
||||
<RoomCallButtons roomId={room.roomId} />
|
||||
{!ecryptedRoom && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Search</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||
<Icon size="300" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Search</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||
<Icon size="300" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
@@ -665,8 +715,12 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
||||
<Icon size="300" src={Icons.User} />
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleTogglePeopleDrawer}
|
||||
aria-pressed={peopleDrawer}
|
||||
>
|
||||
<Icon size="300" src={Icons.User} filled={peopleDrawer} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -690,6 +744,26 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{isMediaDrawer ? 'Hide Shared Media' : 'Show Shared Media'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleToggleMediaDrawer}
|
||||
aria-pressed={isMediaDrawer}
|
||||
aria-label="Shared Media"
|
||||
>
|
||||
<Icon size="300" src={Icons.Photo} filled={isMediaDrawer} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PluginButtonSlot location="room-header" />
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config } from 'folds';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
const SlideUpAnime = keyframes({
|
||||
from: {
|
||||
@@ -13,15 +13,17 @@ const SlideUpAnime = keyframes({
|
||||
export const RoomViewTyping = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: `0 ${config.space.S500}`,
|
||||
width: '100%',
|
||||
backgroundColor: color.Surface.Container,
|
||||
minHeight: toRem(28),
|
||||
padding: `0 ${config.space.S400}`,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
color: color.Surface.OnContainer,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
pointerEvents: 'auto',
|
||||
animation: `${SlideUpAnime} 100ms ease-in-out`,
|
||||
},
|
||||
]);
|
||||
|
||||
export const TypingText = style({
|
||||
flexGrow: 1,
|
||||
overflow: 'clip',
|
||||
|
||||
@@ -45,78 +45,76 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Box
|
||||
className={classNames(css.RoomViewTyping, className)}
|
||||
alignItems="Center"
|
||||
gap="400"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<TypingIndicator />
|
||||
<Text className={css.TypingText} size="T300" truncate>
|
||||
{typingNames.length === 1 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' is typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{typingNames.length === 2 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{typingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{typingNames.length === 3 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{typingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{typingNames[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{typingNames.length > 3 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{typingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{typingNames[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{typingNames.length - 3} others</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</div>
|
||||
<Box
|
||||
className={classNames(css.RoomViewTyping, className)}
|
||||
alignItems="Center"
|
||||
gap="400"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<TypingIndicator />
|
||||
<Text className={css.TypingText} size="T300" truncate>
|
||||
{typingNames.length === 1 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' is typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{typingNames.length === 2 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{typingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{typingNames.length === 3 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{typingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{typingNames[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{typingNames.length > 3 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{typingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{typingNames[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{typingNames.length - 3} others</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -60,7 +60,8 @@ import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { RoomViewTyping } from './RoomViewTyping';
|
||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||
import { RoomViewFollowing } from './RoomViewFollowing';
|
||||
import * as roomViewCss from './RoomViewFollowing.css';
|
||||
import { Message, Reactions, EncryptedContent } from './message';
|
||||
import { Reply } from '../../components/message';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
@@ -731,8 +732,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
||||
</Box>
|
||||
|
||||
{/* Thread messages — grows to fill remaining space */}
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box grow="Yes" style={{ position: 'relative' }} ref={containerRef}>
|
||||
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
|
||||
<Box grow="Yes" style={{ position: 'relative', minHeight: 0 }} ref={containerRef}>
|
||||
<Scroll ref={scrollRef} visibility="Hover">
|
||||
<Box
|
||||
direction="Column"
|
||||
@@ -759,22 +760,22 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
<RoomViewTyping room={room} />
|
||||
<div className={roomViewCss.RoomViewBottomFloat}>
|
||||
<RoomViewTyping room={room} />
|
||||
{!hideActivity && <RoomViewFollowing room={room} />}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Thread input */}
|
||||
<Box shrink="No" direction="Column">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={room.roomId}
|
||||
fileDropContainerRef={containerRef}
|
||||
threadRootId={threadRootId}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={room.roomId}
|
||||
fileDropContainerRef={containerRef}
|
||||
threadRootId={threadRootId}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -802,8 +802,9 @@ export const Message = as<'div', MessageProps>(
|
||||
gap="300"
|
||||
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
||||
justifyContent="SpaceBetween"
|
||||
alignItems="Baseline"
|
||||
alignItems="Center"
|
||||
grow="Yes"
|
||||
data-message-header=""
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Username
|
||||
|
||||
@@ -55,6 +55,7 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
className={classNames(css.ReactionsContainer, className)}
|
||||
gap="200"
|
||||
wrap="Wrap"
|
||||
data-reactions=""
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
IThumbnailContent,
|
||||
MATRIX_BLUR_HASH_PROPERTY_NAME,
|
||||
MATRIX_SPOILER_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
||||
} from '../../../types/matrix/common';
|
||||
import {
|
||||
getImageFileUrl,
|
||||
@@ -18,6 +21,12 @@ import { TUploadItem } from '../../state/room/roomInputDrafts';
|
||||
import { encodeBlurHash } from '../../utils/blurHash';
|
||||
import { scaleYDimension } from '../../utils/common';
|
||||
|
||||
type CarouselMetadata = {
|
||||
uuid: string;
|
||||
index: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
const generateThumbnailContent = async (
|
||||
mx: MatrixClient,
|
||||
img: HTMLImageElement | HTMLVideoElement,
|
||||
@@ -46,7 +55,8 @@ const generateThumbnailContent = async (
|
||||
export const getImageMsgContent = async (
|
||||
mx: MatrixClient,
|
||||
item: TUploadItem,
|
||||
mxc: string
|
||||
mxc: string,
|
||||
carouselMetadata?: CarouselMetadata
|
||||
): Promise<IContent> => {
|
||||
const { file, originalFile, encInfo, metadata } = item;
|
||||
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
|
||||
@@ -59,10 +69,14 @@ export const getImageMsgContent = async (
|
||||
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
|
||||
};
|
||||
if (imgEl) {
|
||||
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
|
||||
const naturalW = imgEl.naturalWidth || imgEl.width;
|
||||
const naturalH = imgEl.naturalHeight || imgEl.height;
|
||||
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(naturalW, 512, naturalH));
|
||||
|
||||
content.info = {
|
||||
...getImageInfo(imgEl, file),
|
||||
w: naturalW,
|
||||
h: naturalH,
|
||||
[MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
|
||||
};
|
||||
}
|
||||
@@ -74,6 +88,11 @@ export const getImageMsgContent = async (
|
||||
} else {
|
||||
content.url = mxc;
|
||||
}
|
||||
if (carouselMetadata) {
|
||||
content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME] = carouselMetadata.uuid;
|
||||
content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME] = carouselMetadata.index;
|
||||
content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME] = carouselMetadata.total;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
|
||||
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const MediaDrawer = style({
|
||||
width: toRem(399),
|
||||
});
|
||||
|
||||
export const MediaDrawerSolo = style({
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const MediaDrawerHeader = style({
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const MediaDrawerContentBase = style({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const MediaDrawerContent = style({
|
||||
padding: `${config.space.S100} ${config.space.S200} ${config.space.S200}`,
|
||||
});
|
||||
|
||||
export const MediaMonthSection = style({
|
||||
marginBottom: config.space.S300,
|
||||
});
|
||||
|
||||
export const MediaMonthHeader = style({
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
margin: '5px 0',
|
||||
padding: `0 ${config.space.S100}`,
|
||||
lineHeight: 1.2,
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
});
|
||||
|
||||
export const MediaDaySection = style({
|
||||
marginBottom: config.space.S300,
|
||||
});
|
||||
|
||||
export const MediaDayHeader = style({
|
||||
padding: `${config.space.S100} ${config.space.S100} ${config.space.S200}`,
|
||||
color: color.Background.OnContainer,
|
||||
});
|
||||
|
||||
export const MediaGrid = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: config.space.S200,
|
||||
});
|
||||
|
||||
export const MediaTile = style({
|
||||
position: 'relative',
|
||||
aspectRatio: '1',
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
});
|
||||
|
||||
export const MediaTileImg = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
export const MediaTileBadge = style({
|
||||
position: 'absolute',
|
||||
right: config.space.S100,
|
||||
bottom: config.space.S100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(24),
|
||||
height: toRem(24),
|
||||
borderRadius: config.radii.R400,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||
color: '#fff',
|
||||
});
|
||||
|
||||
export const MediaEmpty = style({
|
||||
padding: config.space.S400,
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
export const MediaFooter = style({
|
||||
padding: config.space.S200,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const MediaUserFiltersScroll = style({
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${color.SurfaceVariant.ContainerLine} ${color.SurfaceVariant.Container}`,
|
||||
selectors: {
|
||||
'&::-webkit-scrollbar': {
|
||||
height: toRem(8),
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.Container}`,
|
||||
backgroundClip: 'padding-box',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
backgroundColor: color.Surface.ContainerLine,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const MediaUserFilters = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S500,
|
||||
padding: `${config.space.S200} ${config.space.S200}`,
|
||||
width: 'max-content',
|
||||
minWidth: '100%',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const MediaUserChip = style({
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const MediaUserChipLabel = style({
|
||||
maxWidth: toRem(72),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Header,
|
||||
IconButton,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
as,
|
||||
config,
|
||||
Chip,
|
||||
Avatar,
|
||||
} from 'folds';
|
||||
import { MsgType, Room } from 'matrix-js-sdk';
|
||||
import { useAtom } from 'jotai';
|
||||
import classNames from 'classnames';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import * as css from './RoomMediaMenu.css';
|
||||
import { RoomMediaItem, useRoomMediaEvents } from '../../../hooks/useRoomMediaEvents';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import {
|
||||
decryptFile,
|
||||
downloadEncryptedMedia,
|
||||
getMxIdLocalPart,
|
||||
isAuthenticatedMediaUrl,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { Image } from '../../../components/media';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
import { VideoFrameThumbnail } from '../../../components/message/content/VideoFrameThumbnail';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import {
|
||||
getMediaDrawerScrollTop,
|
||||
isMediaDrawerAtom,
|
||||
setMediaDrawerScrollTop,
|
||||
} from '../../../state/mediaDrawer';
|
||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||
|
||||
const fetchAuthenticatedMedia = async (
|
||||
url: string,
|
||||
accessToken: string | null
|
||||
): Promise<string> => {
|
||||
try {
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok && response.status === 401 && accessToken) {
|
||||
response = await fetch(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
if (!response.ok) return url;
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
type MediaTileThumbProps = {
|
||||
item: RoomMediaItem;
|
||||
};
|
||||
|
||||
function MediaTileImageThumb({ item }: { item: RoomMediaItem & { thumbMxc: string } }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const accessToken = getCurrentAccessToken();
|
||||
|
||||
if (item.thumbEncInfo) {
|
||||
const mediaUrl = mxcUrlToHttp(mx, item.thumbMxc, useAuthentication) ?? item.thumbMxc;
|
||||
const fileContent = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) =>
|
||||
decryptFile(encBuf, item.thumbMimeType ?? FALLBACK_MIMETYPE, item.thumbEncInfo!),
|
||||
accessToken
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
|
||||
const mediaUrl =
|
||||
mxcUrlToHttp(mx, item.thumbMxc, useAuthentication, 240, 240, 'crop') ?? item.thumbMxc;
|
||||
|
||||
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||
}
|
||||
|
||||
return mediaUrl;
|
||||
}, [mx, item.thumbMxc, item.thumbEncInfo, item.thumbMimeType, useAuthentication])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSrc();
|
||||
}, [loadSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||
const src = srcState.data;
|
||||
if (!src.startsWith('blob:')) return undefined;
|
||||
return () => URL.revokeObjectURL(src);
|
||||
}, [srcState]);
|
||||
|
||||
if (srcState.status === AsyncStatus.Success) {
|
||||
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||
{srcState.status === AsyncStatus.Loading ? (
|
||||
<Spinner size="200" />
|
||||
) : (
|
||||
<Icon src={Icons.Photo} size="400" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTileFullImageThumb({ item }: { item: RoomMediaItem }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const mediaUrl = mxcUrlToHttp(mx, item.fullMxc, useAuthentication) ?? item.fullMxc;
|
||||
|
||||
if (item.fullEncInfo) {
|
||||
const fileContent = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) =>
|
||||
decryptFile(encBuf, item.fullMimeType ?? FALLBACK_MIMETYPE, item.fullEncInfo!),
|
||||
accessToken
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
|
||||
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||
}
|
||||
|
||||
return mediaUrl;
|
||||
}, [mx, item.fullMxc, item.fullEncInfo, item.fullMimeType, useAuthentication])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSrc();
|
||||
}, [loadSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||
const src = srcState.data;
|
||||
if (!src.startsWith('blob:')) return undefined;
|
||||
return () => URL.revokeObjectURL(src);
|
||||
}, [srcState]);
|
||||
|
||||
if (srcState.status === AsyncStatus.Success) {
|
||||
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||
{srcState.status === AsyncStatus.Loading ? (
|
||||
<Spinner size="200" />
|
||||
) : (
|
||||
<Icon src={Icons.Photo} size="400" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTileThumb({ item }: MediaTileThumbProps) {
|
||||
if (item.thumbMxc) {
|
||||
return <MediaTileImageThumb item={{ ...item, thumbMxc: item.thumbMxc }} />;
|
||||
}
|
||||
|
||||
if (item.msgtype === MsgType.Video) {
|
||||
return (
|
||||
<Box style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
>
|
||||
<Spinner size="200" />
|
||||
</Box>
|
||||
<VideoFrameThumbnail
|
||||
mimeType={item.fullMimeType ?? 'video/mp4'}
|
||||
url={item.fullMxc}
|
||||
encInfo={item.fullEncInfo}
|
||||
renderImage={(src) => (
|
||||
<Image className={css.MediaTileImg} src={src} alt={item.body} loading="lazy" />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <MediaTileFullImageThumb item={item} />;
|
||||
}
|
||||
|
||||
type MediaDayGroup = {
|
||||
dayKey: string;
|
||||
dayLabel: string;
|
||||
items: RoomMediaItem[];
|
||||
};
|
||||
|
||||
type MediaMonthGroup = {
|
||||
monthKey: string;
|
||||
monthLabel: string;
|
||||
days: MediaDayGroup[];
|
||||
};
|
||||
|
||||
const monthFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const sameDayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const formatDayLabel = (ts: number): string => {
|
||||
const date = new Date(ts);
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (sameDayFormatter.format(date) === sameDayFormatter.format(today)) return 'Today';
|
||||
if (sameDayFormatter.format(date) === sameDayFormatter.format(yesterday)) return 'Yesterday';
|
||||
return dayFormatter.format(date);
|
||||
};
|
||||
|
||||
const groupMediaByMonthAndDay = (items: RoomMediaItem[]): MediaMonthGroup[] => {
|
||||
const months: MediaMonthGroup[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const date = new Date(item.ts);
|
||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
|
||||
let month = months[months.length - 1];
|
||||
if (!month || month.monthKey !== monthKey) {
|
||||
month = {
|
||||
monthKey,
|
||||
monthLabel: monthFormatter.format(date),
|
||||
days: [],
|
||||
};
|
||||
months.push(month);
|
||||
}
|
||||
|
||||
let day = month.days[month.days.length - 1];
|
||||
if (!day || day.dayKey !== dayKey) {
|
||||
day = {
|
||||
dayKey,
|
||||
dayLabel: formatDayLabel(item.ts),
|
||||
items: [],
|
||||
};
|
||||
month.days.push(day);
|
||||
}
|
||||
|
||||
day.items.push(item);
|
||||
}
|
||||
|
||||
return months;
|
||||
};
|
||||
|
||||
type MediaSenderChip = {
|
||||
userId: string;
|
||||
name: string;
|
||||
avatarMxc?: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type MediaDrawerProps = {
|
||||
room: Room;
|
||||
/** Full-page takeover when the layout is too narrow for a side rail. */
|
||||
solo?: boolean;
|
||||
};
|
||||
|
||||
export const MediaDrawer = as<'div', MediaDrawerProps>(({ room, solo = false, className, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||
const { items, loading, hasMore, loadMore } = useRoomMediaEvents(room);
|
||||
const [excludedSenders, setExcludedSenders] = useState<Set<string>>(() => new Set());
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const restoredScrollRef = useRef(false);
|
||||
|
||||
const senders = useMemo<MediaSenderChip[]>(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of items) {
|
||||
counts.set(item.sender, (counts.get(item.sender) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.map(([userId, count]) => ({
|
||||
userId,
|
||||
count,
|
||||
name: getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
|
||||
avatarMxc: getMemberAvatarMxc(room, userId),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||
}, [items, room]);
|
||||
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter((item) => !excludedSenders.has(item.sender)),
|
||||
[items, excludedSenders]
|
||||
);
|
||||
const sections = useMemo(() => groupMediaByMonthAndDay(filteredItems), [filteredItems]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restoredScrollRef.current = false;
|
||||
}, [room.roomId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoredScrollRef.current || items.length === 0) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const saved = getMediaDrawerScrollTop(room.roomId);
|
||||
if (typeof saved === 'number') {
|
||||
el.scrollTop = saved;
|
||||
}
|
||||
restoredScrollRef.current = true;
|
||||
}, [room.roomId, items.length]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setMediaDrawer(false);
|
||||
}, [setMediaDrawer]);
|
||||
|
||||
const handleSenderClick = useCallback(
|
||||
(evt: React.MouseEvent, userId: string) => {
|
||||
if (evt.ctrlKey || evt.metaKey) {
|
||||
evt.preventDefault();
|
||||
const allIds = senders.map((sender) => sender.userId);
|
||||
setExcludedSenders((prev) => {
|
||||
const enabledIds = allIds.filter((id) => !prev.has(id));
|
||||
const alreadySolo = enabledIds.length === 1 && enabledIds[0] === userId;
|
||||
if (alreadySolo) return new Set();
|
||||
return new Set(allIds.filter((id) => id !== userId));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setExcludedSenders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[senders]
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(eventId: string) => {
|
||||
navigateRoom(room.roomId, eventId);
|
||||
if (solo) setMediaDrawer(false);
|
||||
},
|
||||
[navigateRoom, room.roomId, solo, setMediaDrawer]
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(evt: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = evt.currentTarget;
|
||||
setMediaDrawerScrollTop(room.roomId, el.scrollTop);
|
||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||
if (nearBottom && hasMore && !loading) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
[hasMore, loading, loadMore, room.roomId]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
shrink="No"
|
||||
grow={solo ? 'Yes' : undefined}
|
||||
direction="Column"
|
||||
className={classNames(
|
||||
solo ? css.MediaDrawerSolo : css.MediaDrawer,
|
||||
ContainerColor({ variant: 'Background' }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Header className={css.MediaDrawerHeader} size="600" variant="Background">
|
||||
<Box grow="Yes" alignItems="Center">
|
||||
<Text size="H5" truncate>
|
||||
Shared Media
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
{senders.length > 0 && (
|
||||
<div className={css.MediaUserFiltersScroll}>
|
||||
<div className={css.MediaUserFilters}>
|
||||
{senders.map((sender) => {
|
||||
const enabled = !excludedSenders.has(sender.userId);
|
||||
const avatarUrl = sender.avatarMxc
|
||||
? mxcUrlToHttp(mx, sender.avatarMxc, useAuthentication, 48, 48, 'crop') ??
|
||||
undefined
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
key={sender.userId}
|
||||
className={css.MediaUserChip}
|
||||
variant={enabled ? 'Primary' : 'SurfaceVariant'}
|
||||
outlined
|
||||
radii="Pill"
|
||||
aria-pressed={enabled}
|
||||
onClick={(evt) => handleSenderClick(evt, sender.userId)}
|
||||
before={
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={sender.userId}
|
||||
src={avatarUrl}
|
||||
alt={sender.name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="L400">
|
||||
{sender.name.slice(0, 1).toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Text className={css.MediaUserChipLabel} size="T200" truncate>
|
||||
{sender.name}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box grow="Yes" direction="Column" className={css.MediaDrawerContentBase}>
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
visibility="Hover"
|
||||
hideTrack
|
||||
variant="Background"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className={css.MediaDrawerContent}>
|
||||
{items.length === 0 && !loading && (
|
||||
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||
No shared photos or videos yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{items.length > 0 && filteredItems.length === 0 && (
|
||||
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||
No media from the selected people.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{sections.map((month) => (
|
||||
<section key={month.monthKey} className={css.MediaMonthSection}>
|
||||
<Text className={css.MediaMonthHeader} size="H6" as="h3">
|
||||
{month.monthLabel}
|
||||
</Text>
|
||||
{month.days.map((day) => (
|
||||
<div key={day.dayKey} className={css.MediaDaySection}>
|
||||
<Text className={css.MediaDayHeader} size="T200" priority="300" as="h4">
|
||||
{day.dayLabel}
|
||||
</Text>
|
||||
<div className={css.MediaGrid}>
|
||||
{day.items.map((item) => (
|
||||
<button
|
||||
key={item.eventId}
|
||||
type="button"
|
||||
className={css.MediaTile}
|
||||
onClick={() => handleOpen(item.eventId)}
|
||||
title={item.body}
|
||||
aria-label={item.body}
|
||||
>
|
||||
<MediaTileThumb item={item} />
|
||||
{item.msgtype === MsgType.Video && (
|
||||
<span className={css.MediaTileBadge}>
|
||||
<Icon src={Icons.Play} size="100" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{(loading || hasMore) && (
|
||||
<div className={css.MediaFooter} style={{ minHeight: config.space.S700 }}>
|
||||
{loading && <Spinner size="400" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
1
src/app/features/room/room-media-menu/index.ts
Normal file
1
src/app/features/room/room-media-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './RoomMediaMenu';
|
||||
@@ -145,11 +145,23 @@ export function Search({ requestClose }: SearchProps) {
|
||||
|
||||
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
||||
(roomId: string) => {
|
||||
const roomName = getRoom(roomId)?.name ?? roomId;
|
||||
const room = getRoom(roomId);
|
||||
const roomName = room?.name ?? roomId;
|
||||
if (mDirects.has(roomId)) {
|
||||
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
||||
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
|
||||
if (targetUsername) return [roomName, targetUsername];
|
||||
if (!targetUserId) return roomName;
|
||||
const targetUsername = getMxIdLocalPart(targetUserId);
|
||||
const targetServer = getMxIdServer(targetUserId);
|
||||
const member = room?.getMember(targetUserId);
|
||||
const displayName = member?.name || member?.rawDisplayName;
|
||||
return [
|
||||
roomName,
|
||||
displayName,
|
||||
targetUsername,
|
||||
targetUserId,
|
||||
targetServer ? `@${targetUsername}:${targetServer}` : undefined,
|
||||
targetServer,
|
||||
].filter((s): s is string => typeof s === 'string' && s.length > 0);
|
||||
}
|
||||
return roomName;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
||||
import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '../../pages/pathUtils';
|
||||
|
||||
export const useDirectSelected = (): boolean => {
|
||||
const directMatch = useMatch({
|
||||
@@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => {
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
||||
export const useDirectSearchSelected = (): boolean => {
|
||||
const match = useMatch({
|
||||
path: getDirectSearchPath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
||||
@@ -1,89 +1,57 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getCurrentAccessToken } from '../utils/auth';
|
||||
import {
|
||||
getCachedAuthenticatedMediaUrl,
|
||||
isAuthenticatedMediaUrl,
|
||||
resolveAuthenticatedMediaUrl,
|
||||
} from '../utils/authenticatedMediaCache';
|
||||
|
||||
/**
|
||||
* Fetches media with authentication and returns a blob URL.
|
||||
* This is needed because service workers may not work reliably in Tauri/WebKit environments,
|
||||
* and cross-origin image requests cannot include Authorization headers.
|
||||
* Resolves media URLs for Matrix authenticated media.
|
||||
* Uses a shared blob-URL cache so avatars/thumbs are not re-fetched on every remount.
|
||||
*/
|
||||
export const useAuthenticatedMediaUrl = (
|
||||
src: string | undefined,
|
||||
useAuthentication: boolean
|
||||
): string | undefined => {
|
||||
const mx = useMatrixClient();
|
||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(() => {
|
||||
if (!src) return undefined;
|
||||
if (!useAuthentication || !isAuthenticatedMediaUrl(src)) return src;
|
||||
return getCachedAuthenticatedMediaUrl(src);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setBlobUrl(undefined);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If not using authentication, just return the original URL
|
||||
if (!useAuthentication) {
|
||||
if (!useAuthentication || !isAuthenticatedMediaUrl(src)) {
|
||||
setBlobUrl(src);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if this is an authenticated media URL
|
||||
const isAuthenticatedMediaUrl =
|
||||
src.includes('/_matrix/client/v1/media/download') ||
|
||||
src.includes('/_matrix/client/v1/media/thumbnail') ||
|
||||
(src.includes('/_matrix/media/') &&
|
||||
(src.includes('/download/') || src.includes('/thumbnail/')));
|
||||
|
||||
if (!isAuthenticatedMediaUrl) {
|
||||
setBlobUrl(src);
|
||||
return;
|
||||
const cached = getCachedAuthenticatedMediaUrl(src);
|
||||
if (cached) {
|
||||
setBlobUrl(cached);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | undefined;
|
||||
|
||||
const fetchMedia = async () => {
|
||||
try {
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
let response = await fetch(src, {
|
||||
method: 'GET',
|
||||
headers: accessToken
|
||||
? { Authorization: `Bearer ${accessToken}` }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||
if (!response.ok && response.status === 401 && accessToken) {
|
||||
console.warn('[useAuthenticatedMediaUrl] Auth failed (401), attempting unauthenticated fallback for:', src);
|
||||
response = await fetch(src, { method: 'GET' });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch authenticated media: ${response.status}`);
|
||||
// Fall back to original URL in case server doesn't require auth
|
||||
if (!cancelled) setBlobUrl(src);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (!cancelled) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error fetching authenticated media:', error);
|
||||
// Fall back to original URL
|
||||
resolveAuthenticatedMediaUrl(src, useAuthentication)
|
||||
.then((url) => {
|
||||
if (!cancelled) setBlobUrl(url);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[useAuthenticatedMediaUrl] Error fetching authenticated media:', error);
|
||||
if (!cancelled) setBlobUrl(src);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMedia();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
// Shared cache owns blob URLs — do not revoke on unmount.
|
||||
};
|
||||
}, [src, useAuthentication, mx]);
|
||||
|
||||
@@ -98,42 +66,11 @@ export const useAuthenticatedMediaFetch = () => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return useCallback(
|
||||
async (src: string): Promise<string> => {
|
||||
const isAuthenticatedMediaUrl =
|
||||
src.includes('/_matrix/client/v1/media/download') ||
|
||||
src.includes('/_matrix/client/v1/media/thumbnail') ||
|
||||
(src.includes('/_matrix/media/') &&
|
||||
(src.includes('/download/') || src.includes('/thumbnail/')));
|
||||
|
||||
if (!isAuthenticatedMediaUrl) {
|
||||
return src;
|
||||
}
|
||||
|
||||
async (src: string, useAuthentication = true): Promise<string> => {
|
||||
try {
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
let response = await fetch(src, {
|
||||
method: 'GET',
|
||||
headers: accessToken
|
||||
? { Authorization: `Bearer ${accessToken}` }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||
if (!response.ok && response.status === 401 && accessToken) {
|
||||
console.warn('[useAuthenticatedMediaFetch] Auth failed (401), attempting unauthenticated fallback');
|
||||
response = await fetch(src, { method: 'GET' });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch authenticated media: ${response.status}`);
|
||||
return src;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
return await resolveAuthenticatedMediaUrl(src, useAuthentication);
|
||||
} catch (error) {
|
||||
console.warn('Error fetching authenticated media:', error);
|
||||
console.warn('[useAuthenticatedMediaFetch] Error fetching authenticated media:', error);
|
||||
return src;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useClientConfig } from './useClientConfig';
|
||||
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
||||
import { isElectron } from '../utils/tauri';
|
||||
|
||||
/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
|
||||
export const ELECTRON_PROTOCOL_HOST = 'desktop';
|
||||
|
||||
/**
|
||||
* Build an absolute URL for the given in-app path.
|
||||
*
|
||||
* In Electron, SSO (and other browser handoffs) must return via paarrot://desktop/...
|
||||
* — a named host so consent UI identifies the desktop app, and a real pathname
|
||||
* (not a hash) because browsers strip fragments when launching custom protocols.
|
||||
*/
|
||||
export const usePathWithOrigin = (path: string): string => {
|
||||
const { hashRouter } = useClientConfig();
|
||||
const { origin } = window.location;
|
||||
|
||||
const pathWithOrigin = useMemo(() => {
|
||||
if (isElectron()) {
|
||||
const basename = trimSlash(hashRouter?.basename ?? '');
|
||||
const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
|
||||
const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
|
||||
return `paarrot://${ELECTRON_PROTOCOL_HOST}${route}`;
|
||||
}
|
||||
|
||||
let url: string = trimSlash(origin);
|
||||
|
||||
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
||||
|
||||
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import to from 'await-to-js';
|
||||
import {
|
||||
Direction,
|
||||
IRoomEvent,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
RelationType,
|
||||
Room,
|
||||
} from 'matrix-js-sdk';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { IEncryptedFile } from '../../types/matrix/common';
|
||||
|
||||
export type RoomMediaItem = {
|
||||
eventId: string;
|
||||
msgtype: typeof MsgType.Image | typeof MsgType.Video;
|
||||
/** MXC used for an image thumbnail when available. */
|
||||
thumbMxc?: string;
|
||||
thumbMimeType?: string;
|
||||
thumbEncInfo?: EncryptedAttachmentInfo;
|
||||
/** Full media MXC — used to extract a video frame when no image thumb exists. */
|
||||
fullMxc: string;
|
||||
fullMimeType?: string;
|
||||
fullEncInfo?: EncryptedAttachmentInfo;
|
||||
body: string;
|
||||
ts: number;
|
||||
sender: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const TARGET_BATCH = 24;
|
||||
const MAX_PAGES_PER_LOAD = 8;
|
||||
|
||||
type RoomMediaCache = {
|
||||
items: RoomMediaItem[];
|
||||
hasMore: boolean;
|
||||
nextToken: string | null;
|
||||
seenIds: Set<string>;
|
||||
};
|
||||
|
||||
/** Survives MediaDrawer remounts within the same room/session. */
|
||||
const mediaCache = new Map<string, RoomMediaCache>();
|
||||
|
||||
const getOrCreateCache = (roomId: string): RoomMediaCache => {
|
||||
const existing = mediaCache.get(roomId);
|
||||
if (existing) return existing;
|
||||
const created: RoomMediaCache = {
|
||||
items: [],
|
||||
hasMore: true,
|
||||
nextToken: null,
|
||||
seenIds: new Set(),
|
||||
};
|
||||
mediaCache.set(roomId, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const toMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => {
|
||||
if (mEvent.isRedacted()) return undefined;
|
||||
|
||||
const relation = mEvent.getRelation();
|
||||
if (relation?.rel_type === RelationType.Replace) return undefined;
|
||||
|
||||
const content = mEvent.getContent();
|
||||
const msgtype = content.msgtype;
|
||||
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video) return undefined;
|
||||
|
||||
const file = content.file as IEncryptedFile | undefined;
|
||||
const info = content.info as
|
||||
| {
|
||||
mimetype?: string;
|
||||
thumbnail_url?: string;
|
||||
thumbnail_info?: { mimetype?: string };
|
||||
thumbnail_file?: IEncryptedFile;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const fullMxc =
|
||||
(typeof content.url === 'string' && content.url) ||
|
||||
(typeof file?.url === 'string' && file.url) ||
|
||||
undefined;
|
||||
if (!fullMxc) return undefined;
|
||||
|
||||
const thumbFile = info?.thumbnail_file;
|
||||
const thumbMxc =
|
||||
(typeof info?.thumbnail_url === 'string' && info.thumbnail_url) ||
|
||||
(typeof thumbFile?.url === 'string' && thumbFile.url) ||
|
||||
undefined;
|
||||
|
||||
const eventId = mEvent.getId();
|
||||
const sender = mEvent.getSender();
|
||||
if (!eventId || !sender) return undefined;
|
||||
|
||||
const usingThumbEnc = !!thumbMxc && thumbMxc === thumbFile?.url;
|
||||
|
||||
return {
|
||||
eventId,
|
||||
msgtype,
|
||||
thumbMxc,
|
||||
thumbMimeType: usingThumbEnc
|
||||
? info?.thumbnail_info?.mimetype
|
||||
: thumbMxc
|
||||
? info?.thumbnail_info?.mimetype
|
||||
: undefined,
|
||||
thumbEncInfo: usingThumbEnc ? thumbFile : undefined,
|
||||
fullMxc,
|
||||
fullMimeType: info?.mimetype,
|
||||
fullEncInfo: file,
|
||||
body: typeof content.body === 'string' ? content.body : msgtype,
|
||||
ts: mEvent.getTs(),
|
||||
sender,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginates room history for image/video messages without mutating the live timeline.
|
||||
*/
|
||||
export const useRoomMediaEvents = (room: Room) => {
|
||||
const mx = useMatrixClient();
|
||||
const roomId = room.roomId;
|
||||
const cacheRef = useRef(getOrCreateCache(roomId));
|
||||
if (cacheRef.current !== mediaCache.get(roomId)) {
|
||||
cacheRef.current = getOrCreateCache(roomId);
|
||||
}
|
||||
|
||||
const [items, setItems] = useState<RoomMediaItem[]>(() => cacheRef.current.items);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(() => cacheRef.current.hasMore);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const decryptEvent = useCallback(
|
||||
async (raw: IRoomEvent | MatrixEvent) => {
|
||||
const mEvent = raw instanceof MatrixEvent ? raw : new MatrixEvent(raw);
|
||||
if (mEvent.isEncrypted() && mx.getCrypto()) {
|
||||
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
|
||||
}
|
||||
return mEvent;
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
const fetchPages = useCallback(async () => {
|
||||
const cache = cacheRef.current;
|
||||
const collected: RoomMediaItem[] = [];
|
||||
|
||||
for (let page = 0; page < MAX_PAGES_PER_LOAD; page += 1) {
|
||||
const response = await mx.createMessagesRequest(
|
||||
roomId,
|
||||
cache.nextToken,
|
||||
PAGE_SIZE,
|
||||
Direction.Backward
|
||||
);
|
||||
|
||||
cache.nextToken = response.end ?? null;
|
||||
if (!response.end) {
|
||||
cache.hasMore = false;
|
||||
setHasMore(false);
|
||||
}
|
||||
|
||||
for (const raw of response.chunk ?? []) {
|
||||
const mEvent = await decryptEvent(raw);
|
||||
const item = toMediaItem(mEvent);
|
||||
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||
cache.seenIds.add(item.eventId);
|
||||
collected.push(item);
|
||||
}
|
||||
|
||||
if (collected.length >= TARGET_BATCH || !response.end) break;
|
||||
}
|
||||
|
||||
return collected;
|
||||
}, [mx, roomId, decryptEvent]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const cache = cacheRef.current;
|
||||
if (loadingRef.current || !cache.hasMore) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const collected = await fetchPages();
|
||||
if (collected.length > 0) {
|
||||
cache.items = [...cache.items, ...collected];
|
||||
setItems(cache.items);
|
||||
}
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchPages]);
|
||||
|
||||
useEffect(() => {
|
||||
const cache = getOrCreateCache(roomId);
|
||||
cacheRef.current = cache;
|
||||
|
||||
// Already loaded for this room — restore without re-fetching.
|
||||
if (cache.items.length > 0 || cache.nextToken !== null || !cache.hasMore) {
|
||||
setItems(cache.items);
|
||||
setHasMore(cache.hasMore);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const bootstrap = async () => {
|
||||
cache.seenIds = new Set();
|
||||
cache.nextToken = null;
|
||||
cache.hasMore = true;
|
||||
cache.items = [];
|
||||
setItems([]);
|
||||
setHasMore(true);
|
||||
setLoading(true);
|
||||
loadingRef.current = true;
|
||||
|
||||
try {
|
||||
const localFound: RoomMediaItem[] = [];
|
||||
const localEvents = room.getLiveTimeline().getEvents();
|
||||
for (let i = localEvents.length - 1; i >= 0; i -= 1) {
|
||||
const mEvent = await decryptEvent(localEvents[i]);
|
||||
const item = toMediaItem(mEvent);
|
||||
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||
cache.seenIds.add(item.eventId);
|
||||
localFound.push(item);
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
if (localFound.length > 0) {
|
||||
cache.items = localFound;
|
||||
setItems(localFound);
|
||||
}
|
||||
|
||||
const collected = await fetchPages();
|
||||
if (cancelled) return;
|
||||
if (collected.length > 0) {
|
||||
cache.items = [...cache.items, ...collected];
|
||||
setItems(cache.items);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Only re-bootstrap when switching rooms — not when callback identities change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roomId]);
|
||||
|
||||
return { items, loading, hasMore, loadMore };
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { NavigateOptions, useNavigate } from 'react-router-dom';
|
||||
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
||||
import {
|
||||
@@ -7,21 +7,25 @@ import {
|
||||
getHomeRoomPath,
|
||||
getSpacePath,
|
||||
getSpaceRoomPath,
|
||||
withRoomEventId,
|
||||
} from '../pages/pathUtils';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import { useSelectedRoom } from './router/useSelectedRoom';
|
||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
|
||||
export const useRoomNavigate = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const mx = useMatrixClient();
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const spaceSelectedId = useSelectedSpace();
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||
|
||||
const navigateSpace = useCallback(
|
||||
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
|
||||
|
||||
const navigateRoom = useCallback(
|
||||
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
||||
// Already viewing this room — keep the current home/direct/space URL and only
|
||||
// change the event id. Otherwise guessPerfectParent can yank the sidebar to
|
||||
// wherever the room "belongs".
|
||||
if (selectedRoomId === roomId) {
|
||||
navigate(withRoomEventId(location.pathname, eventId), opts);
|
||||
return;
|
||||
}
|
||||
|
||||
const room = mx.getRoom(roomId);
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
||||
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
||||
@@ -68,7 +80,16 @@ export const useRoomNavigate = () => {
|
||||
|
||||
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
||||
},
|
||||
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
|
||||
[
|
||||
mx,
|
||||
navigate,
|
||||
location.pathname,
|
||||
selectedRoomId,
|
||||
spaceSelectedId,
|
||||
roomToParents,
|
||||
mDirects,
|
||||
developerTools,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
roomIdToUploadItemsAtomFamily,
|
||||
TUploadItem,
|
||||
} from '../state/room/roomInputDrafts';
|
||||
import { encryptFile } from '../utils/matrix';
|
||||
import { safeFile } from '../utils/mimeTypes';
|
||||
import { fulfilledPromiseSettledResult } from '../utils/common';
|
||||
import { useFilePicker } from './useFilePicker';
|
||||
|
||||
/**
|
||||
* Enqueues files into the room composer upload board (shared by RoomInput and header media).
|
||||
*/
|
||||
export const useRoomUploadFiles = (room: Room) => {
|
||||
const setSelectedFiles = useSetAtom(roomIdToUploadItemsAtomFamily(room.roomId));
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
const safeFiles = files.map(safeFile);
|
||||
const fileItems: TUploadItem[] = [];
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||
);
|
||||
encryptFiles.forEach((ef) =>
|
||||
fileItems.push({
|
||||
...ef,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
fileItems.push({
|
||||
file: f,
|
||||
originalFile: f,
|
||||
encInfo: undefined,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedFiles({
|
||||
type: 'PUT',
|
||||
item: fileItems,
|
||||
});
|
||||
},
|
||||
[setSelectedFiles, room]
|
||||
);
|
||||
|
||||
const pickFiles = useFilePicker(handleFiles, true);
|
||||
|
||||
return { handleFiles, pickFiles };
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lightTheme } from 'folds';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css';
|
||||
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
||||
@@ -17,6 +17,11 @@ export type Theme = {
|
||||
classNames: string[];
|
||||
};
|
||||
|
||||
export const isStationeryTheme = (theme: Theme | string): boolean => {
|
||||
const id = typeof theme === 'string' ? theme : theme.id;
|
||||
return id === 'stationery-theme' || id === 'stationery-dark-theme';
|
||||
};
|
||||
|
||||
export const LightTheme: Theme = {
|
||||
id: 'light-theme',
|
||||
kind: ThemeKind.Light,
|
||||
@@ -63,6 +68,16 @@ export const CatppuccinMochaTheme: Theme = {
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['catppuccin-mocha-theme', catppuccinMochaTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
export const StationeryTheme: Theme = {
|
||||
id: 'stationery-theme',
|
||||
kind: ThemeKind.Light,
|
||||
classNames: ['stationery-theme', 'stationery', stationeryTheme, onLightFontWeight, 'prism-light'],
|
||||
};
|
||||
export const StationeryDarkTheme: Theme = {
|
||||
id: 'stationery-dark-theme',
|
||||
kind: ThemeKind.Dark,
|
||||
classNames: ['stationery-dark-theme', 'stationery', stationeryDarkTheme, onDarkFontWeight, 'prism-dark'],
|
||||
};
|
||||
|
||||
export const useThemes = (): Theme[] => {
|
||||
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
|
||||
@@ -76,7 +91,7 @@ export const useThemes = (): Theme[] => {
|
||||
}, []);
|
||||
|
||||
const builtInThemes: Theme[] = useMemo(() => [
|
||||
LightTheme, SilverTheme, DarkTheme, ButterTheme,
|
||||
LightTheme, SilverTheme, StationeryTheme, StationeryDarkTheme, DarkTheme, ButterTheme,
|
||||
DiscordTheme, DiscordDarkerTheme, TwilightTheme,
|
||||
MochaTheme, CatppuccinMochaTheme
|
||||
], []);
|
||||
@@ -112,6 +127,8 @@ export const useThemeNames = (): Record<string, string> => {
|
||||
const builtInNames = {
|
||||
[LightTheme.id]: 'Light',
|
||||
[SilverTheme.id]: 'Silver',
|
||||
[StationeryTheme.id]: 'Stationery',
|
||||
[StationeryDarkTheme.id]: 'Stationery Dark',
|
||||
[DarkTheme.id]: 'Dark',
|
||||
[ButterTheme.id]: 'Butter',
|
||||
[DiscordTheme.id]: 'Discord',
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
} from './pathUtils';
|
||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||
import { Direct, DirectCreate, DirectRouteRoomProvider, DirectSearch } from './client/direct';
|
||||
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
|
||||
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
||||
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
|
||||
@@ -223,6 +223,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
>
|
||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
||||
<Route path={_SEARCH_PATH} element={<DirectSearch />} />
|
||||
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
||||
<Route path={_INVITES_PATH} element={<Invites />} />
|
||||
<Route
|
||||
|
||||
@@ -378,6 +378,14 @@ function MessageNotifications() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
|
||||
const notificationType = getNotificationType(mx, room.roomId);
|
||||
const isDm = mDirects.has(room.roomId);
|
||||
const mentionsOnly =
|
||||
notificationType === NotificationType.MentionsAndKeywords ||
|
||||
(notificationType === NotificationType.Default && !isDm);
|
||||
if (mentionsOnly && unreadInfo.highlight === 0) return;
|
||||
|
||||
if (
|
||||
showNotifications &&
|
||||
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||
@@ -399,7 +407,6 @@ function MessageNotifications() {
|
||||
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
||||
}
|
||||
|
||||
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
|
||||
notify({
|
||||
roomName: room.name ?? 'Unknown',
|
||||
roomAvatar: avatarMxc
|
||||
@@ -430,6 +437,7 @@ function MessageNotifications() {
|
||||
notify,
|
||||
selectedRoomId,
|
||||
useAuthentication,
|
||||
mDirects,
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -29,7 +29,20 @@ export function SidebarNav() {
|
||||
<Sidebar>
|
||||
<SidebarContent
|
||||
scrollable={
|
||||
<Scroll ref={scrollRef} variant="Background" size="0">
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
variant="Background"
|
||||
size="0"
|
||||
data-sidebar-scroll=""
|
||||
style={{
|
||||
marginRight: -25,
|
||||
paddingRight: 25,
|
||||
width: 'calc(100% + 25px)',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<SidebarStack>
|
||||
{!homeHidden && <HomeTab />}
|
||||
<DirectTab />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
NavItemContent,
|
||||
NavLink,
|
||||
} from '../../../components/nav';
|
||||
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath } from '../../pathUtils';
|
||||
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath, getDirectSearchPath } from '../../pathUtils';
|
||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { VirtualTile } from '../../../components/virtualizer';
|
||||
@@ -41,10 +41,9 @@ import {
|
||||
getRoomNotificationMode,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
||||
import { useDirectCreateSelected, useDirectSearchSelected } from '../../../hooks/router/useDirectSelected';
|
||||
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||
import { UnreadBadge } from '../../../components/unread-badge';
|
||||
|
||||
import { UnreadBadge } from '../../../components/unread-badge';
|
||||
type DirectMenuProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
@@ -228,6 +227,7 @@ export function Direct() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createDirectSelected = useDirectCreateSelected();
|
||||
const searchSelected = useDirectSearchSelected();
|
||||
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const noRoomToDisplay = directs.length === 0;
|
||||
@@ -249,7 +249,7 @@ export function Direct() {
|
||||
const virtualizer = useVirtualizer({
|
||||
count: sortedDirects.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 38,
|
||||
estimateSize: () => 36,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
@@ -283,12 +283,28 @@ export function Direct() {
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
<PluginNavSlot location="direct-messages" />
|
||||
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
|
||||
<NavLink to={getDirectSearchPath()}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Search} size="100" filled={searchSelected} />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Message Search
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</NavCategory>
|
||||
<NavCategory>
|
||||
<NotificationsNavItem />
|
||||
<InvitesNavItem />
|
||||
</NavCategory>
|
||||
<NavCategory>
|
||||
<NavCategory data-nav-dropdown="">
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
@@ -298,13 +314,16 @@ export function Direct() {
|
||||
Chats
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
<div
|
||||
ref={animationRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{sortedDirects.length > 0 && (
|
||||
<div data-nav-rooms="">
|
||||
<div
|
||||
ref={animationRef}
|
||||
data-nav-room-list=""
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = sortedDirects[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
@@ -332,7 +351,9 @@ export function Direct() {
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
|
||||
55
src/app/pages/client/direct/Search.tsx
Normal file
55
src/app/pages/client/direct/Search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Direct';
|
||||
export * from './RoomProvider';
|
||||
export * from './DirectCreate';
|
||||
export * from './Search';
|
||||
|
||||
@@ -309,7 +309,7 @@ export function Home() {
|
||||
const virtualizer = useVirtualizer({
|
||||
count: listItems.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 32,
|
||||
estimateSize: () => 36,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
@@ -399,7 +399,7 @@ export function Home() {
|
||||
</NavItem>
|
||||
<PluginNavSlot location="channel-list" />
|
||||
</NavCategory>
|
||||
<NavCategory>
|
||||
<NavCategory data-nav-dropdown="">
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
@@ -410,12 +410,15 @@ export function Home() {
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
<PluginNavSlot location="home-section" />
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{listItems.length > 0 && (
|
||||
<div data-nav-rooms="">
|
||||
<div
|
||||
data-nav-room-list=""
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const item = listItems[vItem.index];
|
||||
if (!item) return null;
|
||||
@@ -446,7 +449,16 @@ export function Home() {
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: item.depth > 0 ? '1.5rem' : undefined }}>
|
||||
<div
|
||||
data-nav-depth={item.depth}
|
||||
style={{
|
||||
paddingLeft,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: item.depth > 0 ? '1.5rem' : undefined,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{treeIcon && (
|
||||
<span style={{
|
||||
paddingRight: '2px',
|
||||
@@ -475,7 +487,9 @@ export function Home() {
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
|
||||
@@ -154,7 +154,7 @@ export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
|
||||
if (threads.length === 0) return null;
|
||||
|
||||
return (
|
||||
<NavCategory>
|
||||
<NavCategory data-nav-dropdown="">
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(THREADS_CATEGORY_ID)}
|
||||
@@ -164,15 +164,18 @@ export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
|
||||
My Threads
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
{!closedCategories.has(THREADS_CATEGORY_ID) &&
|
||||
threads.map(({ room, thread }) => (
|
||||
<ThreadNavItem
|
||||
key={`${room.roomId}:${thread.id}`}
|
||||
room={room}
|
||||
thread={thread}
|
||||
selected={false}
|
||||
/>
|
||||
))}
|
||||
{!closedCategories.has(THREADS_CATEGORY_ID) && (
|
||||
<div data-nav-rooms="">
|
||||
{threads.map(({ room, thread }) => (
|
||||
<ThreadNavItem
|
||||
key={`${room.roomId}:${thread.id}`}
|
||||
room={room}
|
||||
thread={thread}
|
||||
selected={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</NavCategory>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import React, {
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { Avatar, Box, Button, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Spinner, Text, color, config, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { JoinRule, Room, Thread, ThreadEvent } from 'matrix-js-sdk';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
useSpaceSearchSelected,
|
||||
} from '../../../hooks/router/useSelectedSpace';
|
||||
import { useSpace } from '../../../hooks/useSpace';
|
||||
import { VirtualTile } from '../../../components/virtualizer';
|
||||
import { RoomNavCategoryButton, RoomNavItem, UnjoinedSubRoomItem } from '../../../features/room-nav';
|
||||
import { makeNavCategoryId } from '../../../state/closedNavCategories';
|
||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||
@@ -74,6 +72,119 @@ import { SpaceOptionsMenu } from '../../../features/space/SpaceOptionsMenu';
|
||||
import { ForumFeedSidebar } from '../../../features/forum/ForumFeedSidebar';
|
||||
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() {
|
||||
const space = useSpace();
|
||||
const spaceName = useRoomName(space);
|
||||
@@ -272,61 +383,74 @@ export function Space() {
|
||||
return hierarchy.filter((entry) => !globalSubRoomIds.has(entry.roomId));
|
||||
}, [hierarchy, globalSubRoomIds]);
|
||||
|
||||
// Flatten hierarchy to include sub-rooms
|
||||
type HierarchyItem =
|
||||
| { type: 'hierarchy'; roomId: string }
|
||||
| { type: 'subroom'; roomId: string; room: Room; depth: number; isLast?: boolean }
|
||||
| { type: 'unjoined-subroom'; roomId: string; depth: number; isLast?: boolean };
|
||||
|
||||
const flattenedHierarchy = useMemo(() => {
|
||||
const items: HierarchyItem[] = [];
|
||||
// One paper dropdown per space: space header + its rooms/sub-rooms
|
||||
const navSections = useMemo(() => {
|
||||
const sections: SpaceNavSectionData[] = [];
|
||||
let current: SpaceNavSectionData | null = null;
|
||||
const processedSubRooms = new Set<string>();
|
||||
|
||||
const addSubRooms = (parentRoomId: string, baseDepth: number) => {
|
||||
const addSubRooms = (parentRoomId: string, baseDepth: number, into: SpaceHierarchyItem[]) => {
|
||||
const room = mx.getRoom(parentRoomId);
|
||||
if (!room || isSpace(room)) return;
|
||||
|
||||
|
||||
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
|
||||
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
|
||||
|
||||
// Filter to only joined sub-rooms that haven't been processed
|
||||
const joinedSubRooms = subRooms.filter(subRoomId =>
|
||||
!processedSubRooms.has(subRoomId) && mx.getRoom(subRoomId)
|
||||
|
||||
const joinedSubRooms = subRooms.filter(
|
||||
(subRoomId) => !processedSubRooms.has(subRoomId) && mx.getRoom(subRoomId)
|
||||
);
|
||||
|
||||
|
||||
joinedSubRooms.forEach((subRoomId, index) => {
|
||||
processedSubRooms.add(subRoomId);
|
||||
|
||||
const isLast = index === joinedSubRooms.length - 1;
|
||||
const subRoom = mx.getRoom(subRoomId);
|
||||
if (subRoom) {
|
||||
// Joined sub-room
|
||||
items.push({ type: 'subroom', roomId: subRoomId, room: subRoom, depth: baseDepth + 1, isLast });
|
||||
// Recursively add sub-rooms of sub-rooms
|
||||
addSubRooms(subRoomId, baseDepth + 1);
|
||||
}
|
||||
if (!subRoom) return;
|
||||
into.push({
|
||||
type: 'subroom',
|
||||
roomId: subRoomId,
|
||||
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) => {
|
||||
items.push({ type: 'hierarchy', roomId: entry.roomId });
|
||||
|
||||
// Add sub-rooms after each non-space room
|
||||
const isSpaceEntry = 'space' in entry && entry.space === true;
|
||||
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)) {
|
||||
addSubRooms(entry.roomId, 0);
|
||||
addSubRooms(entry.roomId, 0, current!.items);
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [mx, filteredHierarchy]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flattenedHierarchy.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 32,
|
||||
overscan: 10,
|
||||
});
|
||||
return sections;
|
||||
}, [mx, filteredHierarchy, space.roomId]);
|
||||
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||
closedCategories.has(categoryId)
|
||||
@@ -410,110 +534,20 @@ export function Space() {
|
||||
</NavItem>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
<PluginNavSlot location="channel-list" />
|
||||
</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>
|
||||
</PageNavContent>
|
||||
</PageNav>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DIRECT_CREATE_PATH,
|
||||
DIRECT_PATH,
|
||||
DIRECT_ROOM_PATH,
|
||||
DIRECT_SEARCH_PATH,
|
||||
DIRECT_NOTIFICATIONS_PATH,
|
||||
DIRECT_INVITES_PATH,
|
||||
EXPLORE_FEATURED_PATH,
|
||||
@@ -121,6 +122,7 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string
|
||||
|
||||
export const getDirectPath = (): string => DIRECT_PATH;
|
||||
export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH;
|
||||
export const getDirectSearchPath = (): string => DIRECT_SEARCH_PATH;
|
||||
export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
|
||||
const params = {
|
||||
roomIdOrAlias,
|
||||
@@ -180,6 +182,28 @@ export const getSpaceRoomPath = (
|
||||
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 getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH;
|
||||
export const getExploreServerPath = (server: string): string => {
|
||||
|
||||
@@ -54,6 +54,7 @@ export type DirectCreateSearchParams = {
|
||||
userId?: string;
|
||||
};
|
||||
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 SPACE_PATH = '/:spaceIdOrAlias/';
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
mxcUrlToHttp,
|
||||
} from '../utils/matrix';
|
||||
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 { findAndReplace } from '../utils/findAndReplace';
|
||||
import {
|
||||
@@ -48,8 +48,6 @@ import { openExternalUrl } from '../utils/tauri';
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -106,6 +104,7 @@ export const renderMatrixMention = (
|
||||
{...customProps}
|
||||
className={css.Mention({ highlight: mx.getUserId() === userId })}
|
||||
data-mention-id={userId}
|
||||
data-mention-self={mx.getUserId() === userId ? '' : undefined}
|
||||
>
|
||||
{`@${
|
||||
(currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId)
|
||||
@@ -215,7 +214,7 @@ export const highlightText = (
|
||||
text,
|
||||
regex,
|
||||
(match, pushIndex) => (
|
||||
<span key={`highlight-${pushIndex}`} className={css.highlightText}>
|
||||
<span key={`highlight-${pushIndex}`} className={css.highlightText} data-text-highlight="">
|
||||
{match[0]}
|
||||
</span>
|
||||
),
|
||||
|
||||
168
src/app/state/mediaDimensionCache.ts
Normal file
168
src/app/state/mediaDimensionCache.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
17
src/app/state/mediaDrawer.ts
Normal file
17
src/app/state/mediaDrawer.ts
Normal 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);
|
||||
};
|
||||
46
src/app/state/roomScrollCache.ts
Normal file
46
src/app/state/roomScrollCache.ts
Normal 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);
|
||||
};
|
||||
@@ -262,9 +262,10 @@ export const EmoticonBase = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-block',
|
||||
padding: '0.05rem',
|
||||
height: '1em',
|
||||
verticalAlign: 'middle',
|
||||
verticalAlign: '-0.1em',
|
||||
lineHeight: 1,
|
||||
height: 'auto',
|
||||
padding: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -272,18 +273,10 @@ export const Emoticon = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
height: '1em',
|
||||
minWidth: '1em',
|
||||
fontSize: '1.33em',
|
||||
lineHeight: '1em',
|
||||
verticalAlign: 'middle',
|
||||
position: 'relative',
|
||||
top: '-0.35em',
|
||||
borderRadius: config.radii.R300,
|
||||
display: 'inline',
|
||||
fontSize: '1.2em',
|
||||
lineHeight: 1,
|
||||
verticalAlign: 'inherit',
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
@@ -298,7 +291,9 @@ export const Emoticon = recipe({
|
||||
export const EmoticonImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
height: '1em',
|
||||
display: 'block',
|
||||
height: '1.2em',
|
||||
width: '1.2em',
|
||||
cursor: 'default',
|
||||
},
|
||||
]);
|
||||
|
||||
87
src/app/utils/authenticatedMediaCache.ts
Normal file
87
src/app/utils/authenticatedMediaCache.ts
Normal 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;
|
||||
};
|
||||
@@ -87,6 +87,23 @@ export const scaleYDimension = (x: number, scaledX: number, y: number): number =
|
||||
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) => {
|
||||
const [, data] = location.split(':');
|
||||
const [cords] = data.split(';');
|
||||
|
||||
@@ -13,13 +13,18 @@ export const findAndReplace = <ReplaceReturnType, ConvertReturnType>(
|
||||
const result: Array<ReplaceReturnType | ConvertReturnType> = [];
|
||||
let lastEnd = 0;
|
||||
|
||||
regex.lastIndex = 0;
|
||||
let match: RegExpExecArray | RegExpMatchArray | null = regex.exec(text);
|
||||
while (match !== null && typeof match.index === 'number') {
|
||||
result.push(convertPart(text.slice(lastEnd, match.index), result.length));
|
||||
result.push(replace(match, result.length));
|
||||
|
||||
lastEnd = match.index + match[0].length;
|
||||
if (match[0].length === 0) {
|
||||
regex.lastIndex += 1;
|
||||
}
|
||||
if (regex.global) match = regex.exec(text);
|
||||
else break;
|
||||
}
|
||||
|
||||
result.push(convertPart(text.slice(lastEnd), result.length));
|
||||
|
||||
2
src/app/utils/paperSafeInk.ts
Normal file
2
src/app/utils/paperSafeInk.ts
Normal 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
@@ -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)',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
1515
src/index.css
1515
src/index.css
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { enableMapSet } from 'immer';
|
||||
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 { configClass, varsClass } from 'folds';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user