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

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 { 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;
@@ -306,12 +318,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 +618,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
};
return (
<div ref={ref}>
<div ref={ref} className={css.RoomInputWrap}>
{selectedFiles.length > 0 && (
<UploadBoard
header={
@@ -690,8 +724,7 @@ 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: 0,
marginBottom: config.space.S100,
}}
direction="Column"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,9 @@ 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 { 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';
@@ -473,13 +475,23 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
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 = isDirect
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
@@ -511,7 +523,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 +557,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,6 +607,7 @@ 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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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