Add Stationery and Stationery Dark themes with notebook chrome.

Manila folders, ruled chat, post-it nav, and folder tabs; dark variant uses Catppuccin Mocha colors with muted stickies and soft glows.
This commit is contained in:
2026-07-23 00:24:10 +10:00
parent e7777f42d8
commit 61d41900cc
43 changed files with 2765 additions and 421 deletions

10
package-lock.json generated
View File

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

View File

@@ -24,6 +24,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
"@fontsource-variable/inter": "5.2.8", "@fontsource-variable/inter": "5.2.8",
"@fontsource/caveat": "5.3.0",
"@fontsource/inter": "5.2.8", "@fontsource/inter": "5.2.8",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git", "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.101.2", "@tanstack/react-query": "5.101.2",

View File

@@ -26,6 +26,7 @@ import {
MATRIX_SPOILER_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME, MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache'; import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
@@ -207,6 +208,13 @@ const resolveMediaBoxSize = (
h?: number h?: number
): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h); ): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h);
/** Extra space after media so following text stays on the 24px ruled grid — applied outside the photo chrome */
const STATIONERY_LINE_PX = 24;
const snapStationeryGridPad = (height: number): number => {
const rem = height % STATIONERY_LINE_PX;
return rem === 0 ? 0 : STATIONERY_LINE_PX - rem;
};
export function MImage({ content, renderImageContent, outlined }: MImageProps) { export function MImage({ content, renderImageContent, outlined }: MImageProps) {
const imgInfo = content?.info; const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url; const mxcUrl = content.file?.url ?? content.url;
@@ -215,11 +223,21 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
} }
const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h); const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h);
const gridPad = snapStationeryGridPad(height);
return ( return (
<Attachment outlined={outlined} transparent> <StationeryMedia
outlined={outlined}
transparent
tiltSeed={mxcUrl}
gridPad={gridPad}
>
<AttachmentBox <AttachmentBox
style={{ width: toRem(width), height: toRem(height) }} style={{
width: toRem(width),
height: toRem(height),
['--media-h' as string]: `${height}px`,
}}
data-paarrot-media-height={height} data-paarrot-media-height={height}
> >
{renderImageContent({ {renderImageContent({
@@ -232,7 +250,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})} })}
</AttachmentBox> </AttachmentBox>
</Attachment> </StationeryMedia>
); );
} }
@@ -269,9 +287,15 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
videoInfo.w ?? videoInfo.thumbnail_info?.w, videoInfo.w ?? videoInfo.thumbnail_info?.w,
videoInfo.h ?? videoInfo.thumbnail_info?.h videoInfo.h ?? videoInfo.thumbnail_info?.h
); );
const gridPad = snapStationeryGridPad(height);
return ( return (
<Attachment outlined={outlined} transparent> <StationeryMedia
outlined={outlined}
transparent
tiltSeed={mxcUrl}
gridPad={gridPad}
>
<AttachmentHeader> <AttachmentHeader>
<FileHeader <FileHeader
body={filename} body={filename}
@@ -287,7 +311,10 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
/> />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox <AttachmentBox
style={{ width: toRem(width), height: toRem(height) }} style={{
width: toRem(width),
height: toRem(height),
}}
data-paarrot-media-height={height} data-paarrot-media-height={height}
> >
{renderVideoContent({ {renderVideoContent({
@@ -300,7 +327,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})} })}
</AttachmentBox> </AttachmentBox>
</Attachment> </StationeryMedia>
); );
} }
@@ -439,12 +466,16 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
return <MessageBrokenContent />; return <MessageBrokenContent />;
} }
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152); const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
const tapeAlt = hashStationerySeed(mxcUrl) % 2 === 1;
return ( return (
<AttachmentBox <AttachmentBox
data-stationery-media=""
data-tape-alt={tapeAlt ? '' : undefined}
style={{ style={{
height: toRem(height < 48 ? 48 : height), height: toRem(height < 48 ? 48 : height),
width: toRem(152), width: toRem(152),
['--media-rot' as string]: stationeryMediaRot(mxcUrl),
}} }}
> >
{renderImageContent({ {renderImageContent({

View File

@@ -53,6 +53,19 @@ export const Reaction = style([
}, },
]); ]);
export const ReactionStack = style([
DefaultReset,
{
position: 'relative',
display: 'inline-grid',
placeItems: 'center',
gridTemplateColumns: '1fr',
gridTemplateRows: '1fr',
lineHeight: toRem(20),
minWidth: 0,
},
]);
export const ReactionText = style([ export const ReactionText = style([
DefaultReset, DefaultReset,
{ {
@@ -60,7 +73,15 @@ export const ReactionText = style([
maxWidth: toRem(150), maxWidth: toRem(150),
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
lineHeight: toRem(20), lineHeight: toRem(20),
gridArea: '1 / 1',
transformOrigin: 'center center',
// Fan each stacked copy so count>1 reads as multiple stickers
transform: `translate(
calc(var(--sticker-i, 0) * 7px - 2px),
calc(var(--sticker-i, 0) * -5px)
) rotate(calc((var(--sticker-i, 0) - 1) * 12deg))`,
}, },
]); ]);

View File

@@ -7,6 +7,8 @@ import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room'; import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
const MAX_STICKER_STACK = 4;
export const Reaction = as< export const Reaction = as<
'button', 'button',
{ {
@@ -15,23 +17,49 @@ export const Reaction = as<
reaction: string; reaction: string;
useAuthentication?: boolean; useAuthentication?: boolean;
} }
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => ( >(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
const showCount = count > 2;
const isCustomEmoji = reaction.startsWith('mxc://');
const customSrc = isCustomEmoji
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
: undefined;
return (
<Box <Box
as="button" as="button"
className={classNames(css.Reaction, className)} className={classNames(css.Reaction, className)}
alignItems="Center" alignItems="Center"
shrink="No" shrink="No"
gap="200" gap="200"
data-reaction=""
data-reaction-stack={stackCount}
aria-label={`${reaction}, ${count}`}
{...props} {...props}
ref={ref} ref={ref}
> >
<Text className={css.ReactionText} as="span" size="T400"> <span className={css.ReactionStack} data-reaction-stack-layer="">
{reaction.startsWith('mxc://') ? ( {Array.from({ length: stackCount }, (_, i) => {
// Draw back-to-front so the top sticker is the last layer
const layer = stackCount - 1 - i;
return (
<Text
key={layer}
className={css.ReactionText}
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 <img
className={css.ReactionImg} className={css.ReactionImg}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction src={customSrc}
} alt={i === stackCount - 1 ? reaction : ''}
alt={reaction}
/> />
) : ( ) : (
<Text as="span" size="Inherit" truncate> <Text as="span" size="Inherit" truncate>
@@ -39,11 +67,17 @@ export const Reaction = as<
</Text> </Text>
)} )}
</Text> </Text>
<Text as="span" size="T300"> );
})}
</span>
{showCount && (
<Text as="span" size="T300" data-reaction-count="">
{count} {count}
</Text> </Text>
)}
</Box> </Box>
)); );
});
type ReactionTooltipMsgProps = { type ReactionTooltipMsgProps = {
room: Room; room: Room;

View File

@@ -14,13 +14,19 @@ import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useOtherUserColor } from '../../hooks/useUserColor'; import { useOtherUserColor } from '../../hooks/useUserColor';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
import { STATIONERY_NAME_INK } from '../../utils/paperSafeInk';
type ReplyLayoutProps = { type ReplyLayoutProps = {
userColor?: string; userColor?: string;
username?: ReactNode; username?: ReactNode;
}; };
export const ReplyLayout = as<'div', ReplyLayoutProps>( export const ReplyLayout = as<'div', ReplyLayoutProps>(
({ username, userColor, className, children, ...props }, ref) => ( ({ username, userColor, className, children, ...props }, ref) => {
const theme = useTheme();
const isStationery = isStationeryTheme(theme);
return (
<Box <Box
className={classNames(css.Reply, className)} className={classNames(css.Reply, className)}
alignItems="Center" alignItems="Center"
@@ -28,7 +34,25 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
{...props} {...props}
ref={ref} ref={ref}
> >
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No"> <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} /> <Icon size="100" src={Icons.ReplyArrow} />
{username} {username}
</Box> </Box>
@@ -36,7 +60,8 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
{children} {children}
</Box> </Box>
</Box> </Box>
) );
}
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (

View File

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

View File

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

View File

@@ -204,6 +204,14 @@ export const Username = style({
'button&:hover, button&:focus-visible': { 'button&:hover, button&:focus-visible': {
textDecoration: 'underline', textDecoration: 'underline',
}, },
// Stationery uses a colored border-bottom accent instead
'html.stationery &, body.stationery &': {
overflow: 'visible',
overflowY: 'visible',
},
'body.stationery button&:hover, body.stationery button&:focus-visible': {
textDecoration: 'none',
},
}, },
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
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%',
});
globalStyle(`${RoomInputWrap} .${editorCss.Editor}`, {
borderRadius: 0,
boxShadow: 'none',
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderLeft: 'none',
borderRight: 'none',
borderBottom: 'none',
});

View File

@@ -108,6 +108,18 @@ import { useComposingCheck } from '../../hooks/useComposingCheck';
import { useOtherUserColor } from '../../hooks/useUserColor'; import { useOtherUserColor } from '../../hooks/useUserColor';
import { getMemberAvatarMxc } from '../../utils/room'; import { getMemberAvatarMxc } from '../../utils/room';
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter'; import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
import * as css from './RoomInput.css';
/**
* Creates a UUID used to group image events into one carousel.
*/
const createCarouselUuid = (): string => {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
@@ -306,12 +318,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}; };
const handleSendUpload = async (uploads: UploadSuccess[]) => { const handleSendUpload = async (uploads: UploadSuccess[]) => {
const imageUploads = uploads.filter((upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
return !!fileItem && fileItem.file.type.startsWith('image');
});
const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined;
const imageUploadIndexByFile = new Map(
imageUploads.map((upload, index) => [upload.file, index] as const)
);
const contentsPromises = uploads.map(async (upload) => { const contentsPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file); const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload'); if (!fileItem) throw new Error('Broken upload');
if (fileItem.file.type.startsWith('image')) { if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, upload.mxc); const imageIndex = imageUploadIndexByFile.get(upload.file);
return getImageMsgContent(
mx,
fileItem,
upload.mxc,
carouselUuid !== undefined && imageIndex !== undefined
? {
uuid: carouselUuid,
index: imageIndex,
total: imageUploads.length,
}
: undefined
);
} }
if (fileItem.file.type.startsWith('video')) { if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, upload.mxc); return getVideoMsgContent(mx, fileItem, upload.mxc);
@@ -584,7 +618,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}; };
return ( return (
<div ref={ref}> <div ref={ref} className={css.RoomInputWrap}>
{selectedFiles.length > 0 && ( {selectedFiles.length > 0 && (
<UploadBoard <UploadBoard
header={ header={
@@ -690,8 +724,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderBottom: 'none', borderBottom: 'none',
borderTopLeftRadius: config.radii.R400, borderRadius: 0,
borderTopRightRadius: config.radii.R400,
marginBottom: config.space.S100, marginBottom: config.space.S100,
}} }}
direction="Column" direction="Column"

View File

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

View File

@@ -2421,6 +2421,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
ref={timelineContentRef} ref={timelineContentRef}
direction="Column" direction="Column"
justifyContent="End" justifyContent="End"
data-chatroll=""
style={{ style={{
minHeight: '100%', minHeight: '100%',
padding: `${config.space.S600} 0`, padding: `${config.space.S600} 0`,

View File

@@ -14,7 +14,8 @@ import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping'; import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone'; import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput'; import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { RoomViewFollowing } from './RoomViewFollowing';
import * as roomViewCss from './RoomViewFollowing.css';
import { Page } from '../../components/page'; import { Page } from '../../components/page';
import { RoomViewHeader } from './RoomViewHeader'; import { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
@@ -109,7 +110,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
<ThreadView room={room} threadRootId={activeThreadId} /> <ThreadView room={room} threadRootId={activeThreadId} />
) : ( ) : (
<> <>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
<RoomTimeline <RoomTimeline
key={roomId} key={roomId}
room={room} room={room}
@@ -117,16 +118,20 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
roomInputRef={roomInputRef} roomInputRef={roomInputRef}
editor={editor} editor={editor}
/> />
<div className={roomViewCss.RoomViewBottomFloat}>
<RoomViewTyping room={room} /> <RoomViewTyping room={room} />
{!hideActivity && <RoomViewFollowing room={room} />}
</div>
</Box> </Box>
<Box shrink="No" direction="Column"> <Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? ( {tombstoneEvent ? (
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomTombstone <RoomTombstone
roomId={roomId} roomId={roomId}
body={tombstoneEvent.getContent().body} body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room} replacementRoomId={tombstoneEvent.getContent().replacement_room}
/> />
</div>
) : ( ) : (
<> <>
{canMessage && ( {canMessage && (
@@ -149,8 +154,6 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
)} )}
</> </>
)} )}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box> </Box>
</> </>
)} )}

View File

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

View File

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

View File

@@ -19,7 +19,9 @@ import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import colorMXID from '../../../util/colorMXID';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { _SearchPathSearchParams } from '../../pages/paths'; import { _SearchPathSearchParams } from '../../pages/paths';
import * as css from './RoomViewHeader.css'; import * as css from './RoomViewHeader.css';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
@@ -473,13 +475,23 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
const pinnedEvents = useRoomPinnedEvents(room); const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const ecryptedRoom = !!encryptionEvent; 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 name = useRoomName(room);
const topic = useRoomTopic(room); const topic = useRoomTopic(room);
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined;
const dmAvatarMxc =
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
(isDirect ? avatarMxc : undefined);
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
const dmFolderTabColor = isDirect
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId)); const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
@@ -511,7 +523,24 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
</IconButton> </IconButton>
</Box> </Box>
)} )}
<Box grow="Yes" alignItems="Center" gap="300"> <Box
data-folder-tab="room"
data-folder-tab-dm={isDirect ? '' : undefined}
shrink="No"
alignItems="Center"
gap="300"
style={{
minWidth: 0,
maxWidth: '100%',
...(dmFolderTabColor
? {
['--folder-tab-fill' as string]: dmFolderTabColor,
background: dmFolderTabColor,
backgroundColor: dmFolderTabColor,
}
: undefined),
}}
>
{!showInPageHeader && ( {!showInPageHeader && (
<Avatar size="300"> <Avatar size="300">
<RoomAvatar <RoomAvatar
@@ -528,7 +557,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
/> />
</Avatar> </Avatar>
)} )}
<Box direction="Column"> <Box direction="Column" style={{ minWidth: 0 }}>
<Box alignItems="Center" gap="100"> <Box alignItems="Center" gap="100">
<Text size={topic ? 'H5' : 'H3'} truncate> <Text size={topic ? 'H5' : 'H3'} truncate>
{name} {name}
@@ -578,6 +607,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
)} )}
</Box> </Box>
</Box> </Box>
<Box grow="Yes" />
<Box shrink="No" alignItems="Center" gap="100"> <Box shrink="No" alignItems="Center" gap="100">
<CallIndicator roomId={room.roomId} /> <CallIndicator roomId={room.roomId} />
<RoomCallButtons roomId={room.roomId} /> <RoomCallButtons roomId={room.roomId} />

View File

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

View File

@@ -45,7 +45,6 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
}; };
return ( return (
<div style={{ position: 'relative' }}>
<Box <Box
className={classNames(css.RoomViewTyping, className)} className={classNames(css.RoomViewTyping, className)}
alignItems="Center" alignItems="Center"
@@ -116,7 +115,6 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
<Icon size="50" src={Icons.Cross} /> <Icon size="50" src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
</div>
); );
} }
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -249,7 +249,7 @@ export function Direct() {
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: sortedDirects.length, count: sortedDirects.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 38, estimateSize: () => 36,
overscan: 10, overscan: 10,
}); });
@@ -288,7 +288,7 @@ export function Direct() {
<NotificationsNavItem /> <NotificationsNavItem />
<InvitesNavItem /> <InvitesNavItem />
</NavCategory> </NavCategory>
<NavCategory> <NavCategory data-nav-dropdown="">
<NavCategoryHeader> <NavCategoryHeader>
<RoomNavCategoryButton <RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)} closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
@@ -298,8 +298,11 @@ export function Direct() {
Chats Chats
</RoomNavCategoryButton> </RoomNavCategoryButton>
</NavCategoryHeader> </NavCategoryHeader>
{sortedDirects.length > 0 && (
<div data-nav-rooms="">
<div <div
ref={animationRef} ref={animationRef}
data-nav-room-list=""
style={{ style={{
position: 'relative', position: 'relative',
height: virtualizer.getTotalSize(), height: virtualizer.getTotalSize(),
@@ -333,6 +336,8 @@ export function Direct() {
); );
})} })}
</div> </div>
</div>
)}
</NavCategory> </NavCategory>
</Box> </Box>
</PageNavContent> </PageNavContent>

View File

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

View File

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

View File

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

View File

@@ -104,6 +104,7 @@ export const renderMatrixMention = (
{...customProps} {...customProps}
className={css.Mention({ highlight: mx.getUserId() === userId })} className={css.Mention({ highlight: mx.getUserId() === userId })}
data-mention-id={userId} data-mention-id={userId}
data-mention-self={mx.getUserId() === userId ? '' : undefined}
> >
{`@${ {`@${
(currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId) (currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId)
@@ -213,7 +214,7 @@ export const highlightText = (
text, text,
regex, regex,
(match, pushIndex) => ( (match, pushIndex) => (
<span key={`highlight-${pushIndex}`} className={css.highlightText}> <span key={`highlight-${pushIndex}`} className={css.highlightText} data-text-highlight="">
{match[0]} {match[0]}
</span> </span>
), ),

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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