Compare commits

13 Commits

Author SHA1 Message Date
0da4f0d6cb Add emoji confetti bursts and component playground.
Support app.relay.emoji_confetti on jumbo emoji clicks with canvas physics,
per-emoji particle profiles, and a fixed overlay that does not affect scroll.
Also add a Vite playground for live component previews and theme hover preview.
2026-07-29 20:40:43 +10:00
ea7642f0bb Surface OEM AUTO_START blocks in UnifiedPush settings status.
TCL App Boot can drop NEW_ENDPOINT; show a clear allow-auto-start hint when detected.
2026-07-28 02:39:35 +10:00
0fb3da20b9 Fail UnifiedPush reset when no endpoint arrives and surface lastFailure. 2026-07-28 01:38:01 +10:00
e460dbd34e Improve UnifiedPush reset UX: poll for endpoint and clearer status. 2026-07-28 00:40:02 +10:00
0de2fd942f Prefer native UnifiedPush gateway discovery to avoid WebView CORS failures. 2026-07-28 00:18:28 +10:00
2603ca8e5c Fix avatar upload failing on JPEG/WebP/GIF without a profile color.
embedMetadataInImage returned null for non-PNG when there was nothing to embed, which blocked plain avatar saves after format/library churn.
2026-07-25 20:09:22 +10:00
99a294791e Open DMs during sync catch-up instead of blocking on parent maps.
Prefer the direct route for m.direct rooms and allow joined space children before roomToParents finishes backfilling.
2026-07-24 16:52:34 +10:00
f62200ecd1 Fix Jump to Latest sticking visible while scrolled to the live bottom. 2026-07-24 16:00:59 +10:00
b52926f7d8 Fix system notifications ignoring mention-only and default room settings.
Skip non-highlight events for Mentions/Default non-DM rooms so public chats stay quiet unless they should ping.
2026-07-24 00:30:25 +10:00
d338e1c35e Show update progress as a title-bar bar instead of a popout. 2026-07-23 15:18:24 +10:00
9589680a81 Use paarrot://desktop SSO redirect so consent identifies the app. 2026-07-23 15:13:53 +10:00
27f1357fc0 Remove editor beforeinput autocorrect workaround. 2026-07-23 14:57:57 +10:00
9509a9705e Add Shared Media drawer and harden same-room navigation.
Widen the media panel, support skinny full-page mode, keep jump-to-latest and return-to-previous reliable, and stop same-room event jumps from remounting the outlet or yanking the sidebar.
2026-07-23 14:52:01 +10:00
91 changed files with 6094 additions and 577 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ devAssets
.DS_Store
.idea
.cache

View File

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

41
package-lock.json generated
View File

@@ -83,6 +83,7 @@
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@monaco-editor/react": "4.7.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -106,6 +107,7 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"monaco-editor": "0.52.2",
"prettier": "3.9.4",
"typescript": "6.0.3",
"vite": "8.1.3",
@@ -2563,6 +2565,31 @@
"node": ">= 18"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz",
@@ -8801,6 +8828,13 @@
"resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz",
"integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ=="
},
"node_modules/monaco-editor": {
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10157,6 +10191,13 @@
"node": ">=0.10.0"
}
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",

View File

@@ -14,7 +14,8 @@
"check:eslint": "eslint src/*",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"playground": "vite"
},
"keywords": [],
"author": "Ajay Bura",
@@ -94,6 +95,7 @@
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@monaco-editor/react": "4.7.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -117,6 +119,7 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"monaco-editor": "0.52.2",
"prettier": "3.9.4",
"typescript": "6.0.3",
"vite": "8.1.3",

149
playground-liveTsxPlugin.ts Normal file
View File

@@ -0,0 +1,149 @@
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { Plugin, ViteDevServer } from 'vite';
export type LiveTsxApi = {
getSource: () => string;
setSource: (source: string) => void;
};
const ALLOWED_PREFIXES = ['src/app/components/', 'src/app/features/'];
function resolveAppSourceFile(projectRoot: string, relPath: string): string | null {
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
if (normalized.includes('..') || path.isAbsolute(normalized)) return null;
if (!ALLOWED_PREFIXES.some((p) => normalized.startsWith(p))) return null;
if (!/\.(tsx|ts)$/.test(normalized)) return null;
const abs = path.resolve(projectRoot, normalized);
const root = path.resolve(projectRoot);
if (!abs.startsWith(root + path.sep) && abs !== root) return null;
return abs;
}
function readBody(req: NodeJS.ReadableStream): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function bumpLivePreview(server: ViteDevServer | undefined) {
if (!server) return;
server.ws.send({
type: 'custom',
event: 'playground:live-updated',
data: { revision: Date.now() },
});
}
export function liveTsxPlugin(projectRoot: string, initialSource: string): Plugin {
const liveFile = path.resolve(projectRoot, '.cache/playground-live.tsx');
fs.mkdirSync(path.dirname(liveFile), { recursive: true });
fs.writeFileSync(liveFile, initialSource, 'utf8');
let source = initialSource;
let server: ViteDevServer | undefined;
const api: LiveTsxApi = {
getSource: () => source,
setSource: (next) => {
source = next;
fs.writeFileSync(liveFile, next, 'utf8');
if (!server) return;
for (const m of server.moduleGraph.urlToModuleMap.values()) {
if (m.file === liveFile) server.moduleGraph.invalidateModule(m);
}
const byId = server.moduleGraph.getModuleById(liveFile);
if (byId) server.moduleGraph.invalidateModule(byId);
bumpLivePreview(server);
},
};
return {
name: 'playground-live-tsx',
configureServer(devServer) {
server = devServer;
(devServer as ViteDevServer & { playgroundLive?: LiveTsxApi }).playgroundLive = api;
devServer.middlewares.use('/__playground/live', (req, res, next) => {
if (req.method === 'GET') {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(source);
return;
}
if (req.method === 'POST') {
void readBody(req).then((body) => {
api.setSource(body);
res.statusCode = 204;
res.end();
});
return;
}
next();
});
// Read/write real Cinny component sources (strings, markup, etc.).
devServer.middlewares.use('/__playground/source', (req, res, next) => {
const url = new URL(req.url ?? '', 'http://playground.local');
const rel = url.searchParams.get('path') ?? '';
const abs = resolveAppSourceFile(projectRoot, rel);
if (!abs) {
res.statusCode = 400;
res.end('Invalid or disallowed path');
return;
}
if (req.method === 'GET') {
if (!fs.existsSync(abs)) {
res.statusCode = 404;
res.end('Not found');
return;
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(fs.readFileSync(abs, 'utf8'));
return;
}
if (req.method === 'POST') {
void readBody(req).then((body) => {
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body, 'utf8');
const mod =
server?.moduleGraph.getModuleById(abs) ??
[...(server?.moduleGraph.urlToModuleMap.values() ?? [])].find((m) => m.file === abs);
if (mod && server) server.moduleGraph.invalidateModule(mod);
bumpLivePreview(server);
res.statusCode = 204;
res.end();
});
return;
}
next();
});
},
resolveId(id) {
const clean = id.split('?')[0];
if (
clean === '/@playground/live.tsx' ||
clean === 'virtual:playground-live' ||
clean.endsWith('/.cache/playground-live.tsx')
) {
return liveFile;
}
return undefined;
},
};
}
export function readDefaultLiveSource(projectRoot: string): string {
return fs.readFileSync(path.join(projectRoot, 'src/playground/defaultLive.tsx'), 'utf8');
}
export function projectRootDir(): string {
return path.dirname(fileURLToPath(import.meta.url));
}

12
playground.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Component Playground</title>
</head>
<body style="margin:0;background:#0f1115;color:#e8eaed">
<div id="root"></div>
<script type="module" src="./src/playground/main.tsx?v=react-dedupe-1"></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text, IconButton, Input, Button, TextAreaComponent as TextArea, color, Spinner, Chip, Scroll, config } from 'folds';
import { Box, Text, IconButton, Input, Button, TextArea, color, Spinner, Chip, Scroll, config } from 'folds';
import { Icon, Icons } from './icons';
import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area';
@@ -154,7 +154,7 @@ function AccountDataEdit({
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
<TextArea
ref={textAreaRef}
name="contentTextArea"
style={{

View File

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

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { KeyboardEvent, MouseEvent } from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
@@ -49,6 +49,12 @@ type RenderMessageContentProps = {
linkifyOpts: Opts;
outlineAttachment?: boolean;
disabledEmbedPatterns?: string[];
targetEventId?: string;
onJumboEmojiClick?: (
targetEventId: string,
body: string,
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>
) => void;
};
export function RenderMessageContent({
displayName,
@@ -63,6 +69,8 @@ export function RenderMessageContent({
linkifyOpts,
outlineAttachment,
disabledEmbedPatterns,
targetEventId,
onJumboEmojiClick,
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
@@ -100,6 +108,11 @@ export function RenderMessageContent({
</>
);
};
const handleJumboEmojiClick =
targetEventId && onJumboEmojiClick
? (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) =>
onJumboEmojiClick(targetEventId, body, event)
: undefined;
const renderCaption = () => {
const content: IImageContent = getContent();
if (content.filename && content.filename !== content.body) {
@@ -182,6 +195,7 @@ export function RenderMessageContent({
<MText
edited={edited}
content={getContent()}
onJumboEmojiClick={handleJumboEmojiClick}
renderBody={(props) => (
<RenderBody
{...props}
@@ -201,6 +215,7 @@ export function RenderMessageContent({
displayName={displayName}
edited={edited}
content={getContent()}
onJumboEmojiClick={handleJumboEmojiClick}
renderBody={(props) => (
<RenderBody
{...props}
@@ -219,6 +234,7 @@ export function RenderMessageContent({
<MNotice
edited={edited}
content={getContent()}
onJumboEmojiClick={handleJumboEmojiClick}
renderBody={(props) => (
<RenderBody
{...props}

View File

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

View File

@@ -27,6 +27,7 @@ import {
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
import { getJumboEmojiInteractionProps, JumboEmojiClickHandler } from '../../features/room/emoji-confetti/jumboEmojiInteraction';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
@@ -78,8 +79,9 @@ type MTextProps = {
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
style?: CSSProperties;
onJumboEmojiClick?: JumboEmojiClickHandler;
};
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
export function MText({ edited, content, renderBody, renderUrlsPreview, style, onJumboEmojiClick }: MTextProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
@@ -89,13 +91,20 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return (
<>
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
jumboEmoji={isJumboEmoji}
style={style}
{...jumboEmojiInteraction}
>
{renderBody({
body: trimmedBody,
@@ -114,6 +123,7 @@ type MEmoteProps = {
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
onJumboEmojiClick?: JumboEmojiClickHandler;
};
export function MEmote({
displayName,
@@ -121,6 +131,7 @@ export function MEmote({
content,
renderBody,
renderUrlsPreview,
onJumboEmojiClick,
}: MEmoteProps) {
const { body, formatted_body: customBody } = content;
@@ -131,20 +142,28 @@ export function MEmote({
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return (
<>
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
jumboEmoji={isJumboEmoji}
>
<b>{`${displayName} `}</b>
<span {...jumboEmojiInteraction}>
{renderBody({
body: trimmedBody,
customBody: trimmedCustomBody,
})}
{edited && <MessageEditedContent />}
</span>
</MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
@@ -156,8 +175,9 @@ type MNoticeProps = {
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
onJumboEmojiClick?: JumboEmojiClickHandler;
};
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
export function MNotice({ edited, content, renderBody, renderUrlsPreview, onJumboEmojiClick }: MNoticeProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
@@ -167,13 +187,20 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return (
<>
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
jumboEmoji={isJumboEmoji}
{...jumboEmojiInteraction}
>
{renderBody({
body: trimmedBody,

View File

@@ -53,6 +53,17 @@ export const Reaction = style([
},
]);
export const ReactionText = style([
DefaultReset,
{
minWidth: 0,
maxWidth: toRem(150),
display: 'inline-flex',
alignItems: 'center',
lineHeight: toRem(20),
},
]);
export const ReactionStack = style([
DefaultReset,
{
@@ -66,7 +77,7 @@ export const ReactionStack = style([
},
]);
export const ReactionText = style([
export const ReactionSticker = style([
DefaultReset,
{
minWidth: 0,
@@ -77,11 +88,6 @@ export const ReactionText = style([
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

@@ -6,6 +6,7 @@ import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
const MAX_STICKER_STACK = 4;
@@ -18,13 +19,48 @@ export const Reaction = as<
useAuthentication?: boolean;
}
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
const showCount = count > 2;
const theme = useTheme();
const stationery = isStationeryTheme(theme);
const isCustomEmoji = reaction.startsWith('mxc://');
const customSrc = isCustomEmoji
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
: undefined;
const emoji = isCustomEmoji ? (
<img className={css.ReactionImg} src={customSrc} alt={reaction} />
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
);
// Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
if (!stationery) {
return (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
data-reaction=""
aria-label={`${reaction}, ${count}`}
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{emoji}
</Text>
<Text as="span" size="T300" data-reaction-count="">
{count}
</Text>
</Box>
);
}
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
const showCount = count > 2;
return (
<Box
as="button"
@@ -40,12 +76,11 @@ export const Reaction = as<
>
<span className={css.ReactionStack} data-reaction-stack-layer="">
{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}
className={css.ReactionSticker}
as="span"
size="T400"
data-reaction-sticker=""

View File

@@ -242,6 +242,10 @@ export const MessageTextBody = recipe({
lineHeight: 1,
overflow: 'visible',
overflowY: 'visible',
paddingBottom: config.space.S200,
cursor: 'pointer',
position: 'relative',
zIndex: 5,
},
},
emote: {
@@ -262,6 +266,8 @@ globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
padding: 0,
overflow: 'visible',
verticalAlign: 'middle',
position: 'relative',
zIndex: 1,
});
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextAreaComponent as TextArea, Input } from 'folds';
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextArea, Input } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { Page, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -171,7 +171,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
<TextArea
ref={textAreaRef}
name="contentTextArea"
style={{ fontFamily: 'monospace' }}

View File

@@ -1,5 +1,5 @@
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text, IconButton, Chip, Scroll, config, TextAreaComponent as TextArea, color, Spinner, Button } from 'folds';
import { Box, Text, IconButton, Chip, Scroll, config, TextArea, color, Spinner, Button } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { MatrixError } from 'matrix-js-sdk';
import { Page, PageHeader } from '../../../components/page';
@@ -153,7 +153,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
<TextArea
ref={textAreaRef}
name="contentTextArea"
style={{ fontFamily: 'monospace' }}

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,8 @@ export const RoomInputWrap = style({
width: '100%',
});
globalStyle(`${RoomInputWrap} .${editorCss.Editor}`, {
/* Notebook-flat composer chrome — Stationery only (other themes keep Editor inset border) */
globalStyle(`.stationery ${RoomInputWrap} .${editorCss.Editor}`, {
borderRadius: 0,
boxShadow: 'none',
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,

View File

@@ -45,7 +45,6 @@ import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
import {
TUploadContent,
encryptFile,
getImageInfo,
getMxIdLocalPart,
mxcUrlToHttp,
@@ -54,6 +53,7 @@ import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
import { useFileDropZone } from '../../hooks/useFileDrop';
import { useRoomUploadFiles } from '../../hooks/useRoomUploadFiles';
import {
TUploadItem,
TUploadMetadata,
@@ -76,7 +76,6 @@ import {
createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -101,7 +100,7 @@ import colorMXID from '../../../util/colorMXID';
import { useIsDirectRoom } from '../../hooks/useRoom';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useTheme } from '../../hooks/useTheme';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
@@ -224,42 +223,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
}, [commands]);
const { handleFiles: enqueueFiles } = useRoomUploadFiles(room);
const handleFiles = useCallback(
async (files: File[]) => {
setUploadBoard(true);
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
await enqueueFiles(files);
},
})
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
})
);
}
setSelectedFiles({
type: 'PUT',
item: fileItems,
});
},
[setSelectedFiles, room]
[enqueueFiles]
);
const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles);
@@ -724,7 +694,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderBottom: 'none',
borderRadius: 0,
borderRadius: isStationeryTheme(theme) ? 0 : undefined,
borderTopLeftRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
borderTopRightRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
marginBottom: config.space.S100,
}}
direction="Column"

View File

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

View File

@@ -85,7 +85,6 @@ import {
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
@@ -104,9 +103,12 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import {
clearRoomReturnAnchor,
clearRoomScrollState,
getRoomReturnAnchor,
getRoomScrollState,
saveRoomScrollState,
setRoomReturnAnchor,
} from '../../state/roomScrollCache';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
@@ -135,6 +137,8 @@ import {
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
/** Information about a call member event for grouping */
interface CallEventInfo {
@@ -580,6 +584,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
useJumboEmojiConfetti(room);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
@@ -650,6 +656,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
sender: string;
content: string;
} | null>(null);
const [returnToEventId, setReturnToEventIdState] = useState<string | null>(
() => getRoomReturnAnchor(room.roomId) ?? null
);
const setReturnToEventId = useCallback(
(eventId: string | null) => {
if (eventId) setRoomReturnAnchor(room.roomId, eventId);
else clearRoomReturnAnchor(room.roomId);
setReturnToEventIdState(eventId);
},
[room.roomId]
);
const scrollRef = useRef<HTMLDivElement>(null);
const timelineContentRef = useRef<HTMLDivElement>(null);
@@ -661,6 +679,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
force: false,
});
const getViewportAnchorEventId = useCallback((): string | undefined => {
const scrollEl = scrollRef.current;
if (!scrollEl) return undefined;
const scrollRect = scrollEl.getBoundingClientRect();
const centerY = scrollRect.top + scrollRect.height / 2;
const items = scrollEl.querySelectorAll<HTMLElement>('[data-message-id]');
let bestId: string | undefined;
let bestDist = Infinity;
items.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) return;
const mid = (rect.top + rect.bottom) / 2;
const dist = Math.abs(mid - centerY);
if (dist < bestDist) {
bestDist = dist;
bestId = el.getAttribute('data-message-id') ?? undefined;
}
});
return bestId;
}, []);
const [focusItem, setFocusItem] = useState<
| {
index: number;
@@ -709,6 +752,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timelineRef = useRef(timeline);
timelineRef.current = timeline;
const getRangeCenterEventId = useCallback((): string | undefined => {
const { linkedTimelines, range } = timelineRef.current;
if (range.end <= range.start) return undefined;
const mid = Math.floor((range.start + range.end - 1) / 2);
const [tm, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, mid);
if (!tm) return undefined;
return getTimelineEvent(tm, getTimelineRelativeIndex(mid, baseIndex))?.getId();
}, []);
const persistTimelineScroll = useCallback(
(roomId: string) => {
if (eventId) return;
@@ -736,6 +788,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
// Historical / non-live windows are never "at bottom" of the room.
// When we return to the live end, re-check scroll — IntersectionObserver may
// not re-fire if the anchor stayed intersecting across the transition.
useEffect(() => {
if (!liveTimelineLinked || !rangeAtEnd) {
setAtBottom(false);
return;
}
const scrollEl = scrollRef.current;
if (scrollEl && isScrolledToBottom(scrollEl, 100)) {
setAtBottom(true);
}
}, [liveTimelineLinked, rangeAtEnd]);
const handleTimelinePagination = useTimelinePagination(
mx,
timeline,
@@ -800,6 +866,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const isOwnMessage = mEvt.getSender() === mx.getUserId();
const isBottomPinnedEvent =
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
const wasAwayFromBottom = !atBottomRef.current;
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
// Always update timeline with new message
@@ -820,6 +887,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
}
}
// Sending while scrolled up jumps to latest — offer return to previous spot
if (wasAwayFromBottom && isBottomPinnedEvent) {
const anchorEventId =
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
if (anchorEventId) {
setReturnToEventId(anchorEventId);
}
}
} else if (!document.hasFocus() && !unreadInfo) {
// Show unread notification for other users' messages when unfocused
setUnreadInfo(getRoomUnreadInfo(room));
@@ -846,7 +921,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
});
}
},
[mx, room, unreadInfo, hideActivity, markAsRead]
[mx, room, unreadInfo, hideActivity, markAsRead, eventId, getViewportAnchorEventId, getRangeCenterEventId, setReturnToEventId]
)
);
@@ -961,30 +1036,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, [room, hideActivity, markAsRead]);
const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) {
setAtBottom(false);
}
}, []),
{ wait: 1000 }
);
useIntersectionObserver(
useCallback(
(entries) => {
const target = atBottomAnchorRef.current;
if (!target) return;
const targetEntry = getIntersectionObserverEntry(target, entries);
if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
if (!targetEntry) return;
// Leave the live bottom immediately so Jump to Latest stays available.
// Only intersection clears atBottom here; !atLiveEnd is handled by the
// liveTimelineLinked/rangeAtEnd effect so we don't stick false when the
// live end reconnects without a new intersection event.
if (!targetEntry.isIntersecting) {
setAtBottom(false);
return;
}
if (!atLiveEndRef.current) return;
setAtBottom(true);
setLatestUnreadMessage(null);
if (document.hasFocus()) {
tryAutoMarkAsRead();
}
}
},
[debounceSetAtBottom, tryAutoMarkAsRead]
[tryAutoMarkAsRead]
),
useCallback(
() => ({
@@ -1048,6 +1125,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, [eventId, loadEventTimeline]);
// Restore return-to-previous when switching rooms (survives remount via cache).
useEffect(() => {
setReturnToEventIdState(getRoomReturnAnchor(room.roomId) ?? null);
}, [room.roomId]);
// Scroll to latest when clicking on already-selected room
useEffect(() => {
if (scrollToLatest) {
@@ -1079,7 +1161,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const onScroll = () => {
if (restoringScrollRef.current) return;
window.cancelAnimationFrame(raf);
raf = window.requestAnimationFrame(() => persistTimelineScroll(roomId));
raf = window.requestAnimationFrame(() => {
persistTimelineScroll(roomId);
// Keep Jump to Latest in sync with real scroll position when on the live end.
// IntersectionObserver alone can miss transitions and leave atBottom stuck.
if (atLiveEndRef.current) {
const at = isScrolledToBottom(scrollEl, 100);
setAtBottom(at);
if (at) setLatestUnreadMessage(null);
}
});
};
scrollEl.addEventListener('scroll', onScroll, { passive: true });
@@ -1184,6 +1275,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
scrollToBottomRef.current.smooth,
scrollToBottomRef.current.force
);
if (atLiveEndRef.current) {
setAtBottom(true);
}
}
}
}, [scrollToBottomCount]);
@@ -1234,17 +1328,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}, [room, hour24Clock]);
const handleJumpToLatest = () => {
const anchorEventId =
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
if (anchorEventId) {
setReturnToEventId(anchorEventId);
}
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
}
clearRoomScrollState(room.roomId);
setLatestUnreadMessage(null);
// Already on the live timeline: only snap the virtual window to the end.
// A full getInitialTimeline() rebuild feels like a chat reload when far back.
if (!eventId && liveTimelineLinked) {
setTimeline((ct) => {
const evLength = getTimelinesEventsCount(ct.linkedTimelines);
return {
linkedTimelines: ct.linkedTimelines,
range: {
start: Math.max(evLength - PAGINATION_LIMIT, 0),
end: evLength,
},
};
});
} else {
setTimeline(getInitialTimeline(room));
}
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
};
const handleReturnToPrevious = () => {
if (!returnToEventId) return;
const targetId = returnToEventId;
setReturnToEventId(null);
handleOpenEvent(targetId);
};
const handleClearReturnToPrevious = () => {
setReturnToEventId(null);
};
const handleJumpToUnread = () => {
if (unreadInfo?.readUptoEventId) {
setTimeline(getEmptyTimeline());
@@ -1473,6 +1601,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
)}
</Message>
@@ -1581,6 +1711,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
);
}
@@ -1878,6 +2010,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Return special marker for call event (will be handled by eventRenderer)
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
},
[MessageEvent.RelayEmojiConfetti]: () => null,
},
(mEventId, mEvent, item) => {
if (!showHiddenEvents) return null;
@@ -2391,31 +2524,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return callSummaryJSX || eventJSX;
};
const showUnreadTop =
!!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
return (
<Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
<TimelineFloat position="Top">
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<Text size="L400">Jump to Unread</Text>
</Chip>
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">Mark as Read</Text>
</Chip>
</TimelineFloat>
)}
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
<Box
ref={timelineContentRef}
@@ -2507,10 +2621,65 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</MessageBase>
</>
))}
<span ref={atBottomAnchorRef} />
{/* 1×1 so IntersectionObserver reliably detects the live bottom */}
<span
ref={atBottomAnchorRef}
aria-hidden
style={{ display: 'block', width: 1, height: 1 }}
/>
</Box>
</Scroll>
{!atBottom && (
{(showUnreadTop || returnToEventId) && (
<TimelineFloat position="Top">
{returnToEventId && (
<>
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowTop} />}
onClick={handleReturnToPrevious}
>
<Text size="L400">Return to Previous</Text>
</Chip>
<IconButton
title="Dismiss"
aria-label="Dismiss return to previous"
size="300"
radii="Pill"
variant="SurfaceVariant"
onClick={handleClearReturnToPrevious}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</>
)}
{showUnreadTop && (
<>
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<Text size="L400">Jump to Unread</Text>
</Chip>
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">Mark as Read</Text>
</Chip>
</>
)}
</TimelineFloat>
)}
{!atLiveBottom && (
<TimelineFloat position="Bottom">
<Box direction="Column" gap="200" alignItems="Center">
{latestUnreadMessage && (
@@ -2544,6 +2713,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Box>
</TimelineFloat>
)}
<EmojiConfettiOverlay bursts={emojiConfettiBursts} onBurstComplete={removeEmojiConfettiBurst} />
</Box>
);
}

View File

@@ -7,18 +7,16 @@ import { JoinRule, Room } from 'matrix-js-sdk';
import { useAtom, useAtomValue } from 'jotai';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getHomeSearchPath, getSpaceSearchPath, getDirectSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import colorMXID from '../../../util/colorMXID';
import { useOtherUserColor } from '../../hooks/useUserColor';
@@ -43,6 +41,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import {
getRoomNotificationMode,
@@ -58,6 +57,8 @@ import { useRoomCall, useRoomCallMembers } from '../call';
import { CallState, CallType } from '../call/types';
import { UserAvatar } from '../../components/user-avatar';
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
import { useDirectSelected } from '../../hooks/router/useDirectSelected';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
type RoomMenuProps = {
room: Room;
@@ -471,10 +472,11 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const mDirects = useAtomValue(mDirectAtom);
const onDirectPath = useDirectSelected();
const theme = useTheme();
const stationery = isStationeryTheme(theme);
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const ecryptedRoom = !!encryptionEvent;
const isDirect = mDirects.has(room.roomId);
const avatarMxc = useRoomAvatar(room, isDirect);
const name = useRoomName(room);
@@ -488,11 +490,13 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
(isDirect ? avatarMxc : undefined);
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
const dmFolderTabColor = isDirect
const dmFolderTabColor =
stationery && isDirect
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [isMediaDrawer, setMediaDrawer] = useAtom(isMediaDrawerAtom);
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
const handleSearchClick = () => {
@@ -501,6 +505,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
};
const path = space
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
: isDirect || onDirectPath
? getDirectSearchPath()
: getHomeSearchPath();
navigate(withSearchParam(path, searchParams));
};
@@ -513,6 +519,22 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleTogglePeopleDrawer = () => {
setPeopleDrawer((drawer) => {
const next = !drawer;
if (next) setMediaDrawer(false);
return next;
});
};
const handleToggleMediaDrawer = () => {
setMediaDrawer((open) => {
const next = !open;
if (next) setPeopleDrawer(false);
return next;
});
};
return (
<PageHeader balance={showInPageHeader}>
<Box grow="Yes" gap="300">
@@ -611,7 +633,6 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
<Box shrink="No" alignItems="Center" gap="100">
<CallIndicator roomId={room.roomId} />
<RoomCallButtons roomId={room.roomId} />
{!ecryptedRoom && (
<TooltipProvider
position="Bottom"
offset={4}
@@ -627,7 +648,6 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
offset={4}
@@ -695,8 +715,12 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
<Icon size="300" src={Icons.User} />
<IconButton
ref={triggerRef}
onClick={handleTogglePeopleDrawer}
aria-pressed={peopleDrawer}
>
<Icon size="300" src={Icons.User} filled={peopleDrawer} />
</IconButton>
)}
</TooltipProvider>
@@ -720,6 +744,26 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{isMediaDrawer ? 'Hide Shared Media' : 'Show Shared Media'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleMediaDrawer}
aria-pressed={isMediaDrawer}
aria-label="Shared Media"
>
<Icon size="300" src={Icons.Photo} filled={isMediaDrawer} />
</IconButton>
)}
</TooltipProvider>
<PluginButtonSlot location="room-header" />
<TooltipProvider
position="Bottom"

View File

@@ -65,6 +65,8 @@ import * as roomViewCss from './RoomViewFollowing.css';
import { Message, Reactions, EncryptedContent } from './message';
import { Reply } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
import {
LINKIFY_OPTS,
factoryRenderLinkifyWithMention,
@@ -156,6 +158,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
useJumboEmojiConfetti(room);
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const direct = useIsDirectRoom();
@@ -638,6 +642,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
)}
</EncryptedContent>
@@ -654,6 +660,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
)}
</Message>
@@ -759,6 +767,10 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
{events.map((evt, idx, arr) => renderEvent(evt, idx, arr))}
</Box>
</Scroll>
<EmojiConfettiOverlay
bursts={emojiConfettiBursts}
onBurstComplete={removeEmojiConfettiBurst}
/>
</Box>
<div className={roomViewCss.RoomViewBottomFloat}>
<RoomViewTyping room={room} />

View File

@@ -0,0 +1,180 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { EmojiConfettiBurst } from './types';
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
import {
BurstParticle,
drawBurstParticles,
spawnEmojiBurst,
stepBurstParticles,
} from './emojiBurstEngine';
const MAX_DPR = 2;
type EmojiConfettiBurstCanvasProps = {
burst: EmojiConfettiBurst;
onComplete: (burstId: string) => void;
};
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const layerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<BurstParticle[]>([]);
const frameRef = useRef<number | null>(null);
const lastFrameTimeRef = useRef<number | null>(null);
const spawnedRef = useRef(false);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const canvasSize = getLocalBurstCanvasSize(burst.origin.maskRadius ?? 36);
const localOrigin = useMemo(
() => ({
x: canvasSize / 2,
y: canvasSize / 2,
maskRadius: burst.origin.maskRadius ?? 36,
}),
[burst.origin.maskRadius, canvasSize]
);
const updateLayerPosition = () => {
const layer = layerRef.current;
if (!layer) return false;
const jumbo = findJumboEmojiElement(burst.targetEventId);
if (!jumbo) return false;
const rect = jumbo.getBoundingClientRect();
layer.style.left = `${rect.left + rect.width / 2 - canvasSize / 2}px`;
layer.style.top = `${rect.top + rect.height / 2 - canvasSize / 2}px`;
return true;
};
useEffect(() => {
const onScrollOrResize = () => {
updateLayerPosition();
};
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
return () => {
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
};
}, [burst.targetEventId, canvasSize]);
useEffect(() => {
if (!canvasRef.current || spawnedRef.current) return undefined;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
onCompleteRef.current(burst.id);
return undefined;
}
let frameCleanup: (() => void) | undefined;
const start = (): boolean => {
if (spawnedRef.current || !canvasRef.current) return false;
if (!updateLayerPosition()) return false;
spawnedRef.current = true;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return false;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(canvasSize * dpr);
canvas.height = Math.round(canvasSize * dpr);
canvas.style.width = `${canvasSize}px`;
canvas.style.height = `${canvasSize}px`;
spawnEmojiBurst(
particlesRef.current,
burst.id,
localOrigin,
burst.emojis[0] ?? '🎉',
performance.now()
);
const tick = (now: number) => {
updateLayerPosition();
const last = lastFrameTimeRef.current ?? now;
const dtSeconds = Math.min((now - last) / 1000, 0.05);
lastFrameTimeRef.current = now;
stepBurstParticles(particlesRef.current, now, dtSeconds);
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
if (particlesRef.current.length > 0) {
drawBurstParticles(context, particlesRef.current, dpr, now);
frameRef.current = requestAnimationFrame(tick);
return;
}
onCompleteRef.current(burst.id);
frameRef.current = null;
lastFrameTimeRef.current = null;
};
lastFrameTimeRef.current = performance.now();
frameRef.current = requestAnimationFrame(tick);
frameCleanup = () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
return true;
};
if (start()) {
return frameCleanup;
}
let attempt = 0;
const retry = window.setInterval(() => {
attempt += 1;
if (start() || attempt >= 10) {
window.clearInterval(retry);
if (attempt >= 10 && !spawnedRef.current) {
onCompleteRef.current(burst.id);
}
}
}, 50);
return () => {
window.clearInterval(retry);
frameCleanup?.();
};
}, [burst.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]);
return createPortal(
<div
ref={layerRef}
aria-hidden
style={{
position: 'fixed',
width: canvasSize,
height: canvasSize,
pointerEvents: 'none',
zIndex: 4,
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
}}
/>
</div>,
document.body
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { EmojiConfettiBurst } from './types';
import { EmojiConfettiBurstCanvas } from './EmojiConfettiBurstCanvas';
type EmojiConfettiOverlayProps = {
bursts: EmojiConfettiBurst[];
onBurstComplete: (burstId: string) => void;
};
export function EmojiConfettiOverlay({ bursts, onBurstComplete }: EmojiConfettiOverlayProps) {
if (bursts.length === 0) return null;
return (
<>
{bursts.map((burst) => (
<EmojiConfettiBurstCanvas
key={burst.id}
burst={burst}
onComplete={onBurstComplete}
/>
))}
</>
);
}

View File

@@ -0,0 +1,74 @@
export type BurstPoint = {
x: number;
y: number;
maskRadius?: number;
};
export function getBurstPointFromElement(element: Element): BurstPoint {
const rect = element.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
maskRadius: Math.max(rect.width, rect.height) * 0.5,
};
}
export function getElementCenter(element: Element): BurstPoint {
return getBurstPointFromElement(element);
}
export function findEmojiOriginInMessage(
targetEventId: string,
emoji: string
): BurstPoint | undefined {
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (!message) return undefined;
const matches = message.querySelectorAll('[data-emoticon]');
for (const element of matches) {
if (element.getAttribute('data-emoticon') === emoji) {
return getBurstPointFromElement(element);
}
}
return undefined;
}
export function getMessageFallbackOrigin(targetEventId: string): BurstPoint | undefined {
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (!message) return undefined;
const jumboBody = message.querySelector('[data-jumbo-emoji]');
if (jumboBody) {
return getBurstPointFromElement(jumboBody);
}
const body = message.querySelector('[data-message-body]');
if (body) {
return getBurstPointFromElement(body);
}
return getBurstPointFromElement(message);
}
export function resolveBurstOrigin(
targetEventId: string | undefined,
emojis: string[],
explicitOrigin?: BurstPoint
): BurstPoint {
if (explicitOrigin) return explicitOrigin;
const primaryEmoji = emojis[0];
if (targetEventId && primaryEmoji) {
const emojiOrigin = findEmojiOriginInMessage(targetEventId, primaryEmoji);
if (emojiOrigin) return emojiOrigin;
const messageOrigin = getMessageFallbackOrigin(targetEventId);
if (messageOrigin) return messageOrigin;
}
return {
x: window.innerWidth / 2,
y: window.innerHeight * 0.35,
};
}

View File

@@ -0,0 +1,357 @@
import { BurstPoint } from './burstOrigin';
import {
BurstMotionStyle,
EmojiBurstProfile,
getEmojiBurstProfile,
pickParticleEmoji,
sampleBurstAngle,
} from './emojiParticleProfiles';
/** Particles spawn over this window. */
export const BURST_SPAWN_MS = 500;
/** Global fade runs from 0.5s → 2s after burst start. */
export const BURST_FADE_START_MS = 500;
export const BURST_FADE_END_MS = 2000;
export const BURST_TOTAL_MS = BURST_FADE_END_MS;
export type BurstParticle = {
burstId: string;
burstStartMs: number;
emoji: string;
fontSize: number;
spawnX: number;
spawnY: number;
maskRadius: number;
x: number;
y: number;
vx: number;
vy: number;
bornAt: number;
rotation: number;
spin: number;
peakScale: number;
style: BurstMotionStyle;
gravity: number;
drag: number;
phase: number;
twinkle?: boolean;
wobble?: boolean;
orbitRadius: number;
orbitSpeed: number;
orbitAngle: number;
bounceDamping: number;
bouncesLeft: number;
};
const EMOJI_CACHE_PX = 64;
const EMOJI_CACHE_SCALE = 2;
const MAX_DPR = 2;
const POP_IN_MS = 70;
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
function easeOutBack(t: number): number {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
}
function easeOutCubic(t: number): number {
return 1 - (1 - t) ** 3;
}
function lerp(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const cacheKey = `${emoji}:${dpr}`;
const cached = emojiCanvasCache.get(cacheKey);
if (cached) return cached;
const fontSize = Math.ceil(EMOJI_CACHE_PX * dpr);
const size = Math.ceil(fontSize * EMOJI_CACHE_SCALE);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (context) {
context.textAlign = 'center';
context.textBaseline = 'middle';
context.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
context.fillText(emoji, size / 2, size / 2);
}
emojiCanvasCache.set(cacheKey, canvas);
return canvas;
}
function emergeOpacity(
x: number,
y: number,
spawnX: number,
spawnY: number,
maskRadius: number
): number {
const dist = Math.hypot(x - spawnX, y - spawnY);
const inner = maskRadius * 0.55;
const outer = maskRadius * 1.05;
if (dist <= inner) return 0;
if (dist >= outer) return 1;
return (dist - inner) / (outer - inner);
}
function spawnParticle(
particles: BurstParticle[],
burstId: string,
burstStartMs: number,
point: BurstPoint,
primaryEmoji: string,
profile: EmojiBurstProfile,
bornAt: number,
options?: { hero?: boolean }
) {
const hero = options?.hero ?? false;
const angle = sampleBurstAngle(profile);
const speed = hero
? lerp(profile.speedMin * 0.65, profile.speedMax * 0.55)
: lerp(profile.speedMin, profile.speedMax);
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
const maskRadius = point.maskRadius ?? 28;
const jitter = hero ? 3 : 12;
const emoji = pickParticleEmoji(profile, primaryEmoji);
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 60;
let vy = Math.sin(angle) * speed - launchUp;
if (profile.style === 'spiral') {
vx *= 0.35;
vy *= 0.35;
}
if (profile.style === 'sparkle') {
vx *= 0.75;
vy *= 0.75;
}
particles.push({
burstId,
burstStartMs,
emoji,
fontSize: hero
? lerp(profile.heroFontSizeMin, profile.heroFontSizeMax)
: lerp(profile.fontSizeMin, profile.fontSizeMax),
spawnX: point.x,
spawnY: point.y,
maskRadius,
x: point.x + (Math.random() - 0.5) * jitter,
y: point.y + (Math.random() - 0.5) * jitter,
vx,
vy,
bornAt,
rotation: (Math.random() - 0.5) * 40,
spin: lerp(profile.spinMin, profile.spinMax),
peakScale: hero ? 1.1 + Math.random() * 0.2 : 0.9 + Math.random() * 0.4,
style: profile.style,
gravity: profile.gravity,
drag: profile.drag,
phase: Math.random() * Math.PI * 2,
twinkle: profile.twinkle,
wobble: profile.wobble,
orbitRadius: hero ? 8 : 14 + Math.random() * 28,
orbitSpeed: (Math.random() < 0.5 ? -1 : 1) * (1.8 + Math.random() * 2.4),
orbitAngle: Math.random() * Math.PI * 2,
bounceDamping: 0.42 + Math.random() * 0.18,
bouncesLeft: profile.style === 'bounce' ? 2 : 0,
});
}
/** Fountain from behind the emoji for 0.5s, fade 0.5s→2s. */
export function spawnEmojiBurst(
particles: BurstParticle[],
burstId: string,
point: BurstPoint,
emoji: string,
now = performance.now()
) {
const profile = getEmojiBurstProfile(emoji);
const burstStartMs = now;
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, burstStartMs, {
hero: true,
});
const particleCount = profile.particleCount;
for (let i = 0; i < particleCount; i += 1) {
const slot = i / particleCount;
const bornAt =
burstStartMs + slot * BURST_SPAWN_MS + Math.random() * (BURST_SPAWN_MS / particleCount);
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, bornAt);
}
}
function stepSpiralParticle(particle: BurstParticle, dtSeconds: number) {
particle.orbitRadius += 42 * dtSeconds;
particle.orbitAngle += particle.orbitSpeed * dtSeconds;
particle.x = particle.spawnX + Math.cos(particle.orbitAngle) * particle.orbitRadius;
particle.y =
particle.spawnY + Math.sin(particle.orbitAngle) * particle.orbitRadius * 0.65 + particle.vy * dtSeconds * 8;
particle.vy += particle.gravity * dtSeconds * 0.35;
particle.rotation += particle.spin * dtSeconds;
}
function stepBounceParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
particle.vy += particle.gravity * dtSeconds;
particle.vx *= dragFactor;
particle.vy *= dragFactor;
particle.x += particle.vx * dtSeconds;
particle.y += particle.vy * dtSeconds;
particle.rotation += particle.spin * dtSeconds;
if (particle.bouncesLeft > 0 && particle.vy > 0 && particle.y >= particle.spawnY + 6) {
particle.y = particle.spawnY + 6;
particle.vy = -Math.abs(particle.vy) * particle.bounceDamping;
particle.vx *= 0.82;
particle.bouncesLeft -= 1;
particle.spin += (Math.random() - 0.5) * 120;
}
}
function stepDefaultParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
particle.vy += particle.gravity * dtSeconds;
particle.vx *= dragFactor;
particle.vy *= dragFactor;
particle.x += particle.vx * dtSeconds;
particle.y += particle.vy * dtSeconds;
particle.rotation += particle.spin * dtSeconds;
}
export function stepBurstParticles(
particles: BurstParticle[],
now: number,
dtSeconds: number
): void {
for (let i = particles.length - 1; i >= 0; i -= 1) {
const particle = particles[i];
const burstElapsed = now - particle.burstStartMs;
if (burstElapsed > BURST_TOTAL_MS) {
particles[i] = particles[particles.length - 1];
particles.pop();
continue;
}
const ageMs = now - particle.bornAt;
if (ageMs < 0) continue;
const dragFactor = particle.drag ** (dtSeconds * 60);
switch (particle.style) {
case 'spiral':
stepSpiralParticle(particle, dtSeconds);
break;
case 'bounce':
stepBounceParticle(particle, dtSeconds, dragFactor);
break;
default:
stepDefaultParticle(particle, dtSeconds, dragFactor);
break;
}
particle.phase += dtSeconds * (particle.twinkle ? 9 : 5);
}
}
type DrawState = {
x: number;
y: number;
scale: number;
opacity: number;
rotation: number;
};
function particleDrawState(particle: BurstParticle, now: number): DrawState | null {
const burstElapsed = now - particle.burstStartMs;
if (burstElapsed > BURST_TOTAL_MS) return null;
const ageMs = now - particle.bornAt;
if (ageMs < 0) return null;
let scale = particle.peakScale;
let opacity = emergeOpacity(
particle.x,
particle.y,
particle.spawnX,
particle.spawnY,
particle.maskRadius
);
if (opacity <= 0) return null;
if (ageMs < POP_IN_MS) {
const pop = easeOutBack(ageMs / POP_IN_MS);
scale = 0.1 + pop * particle.peakScale;
opacity *= pop;
}
if (particle.twinkle) {
opacity *= 0.55 + 0.45 * Math.sin(particle.phase * 1.6);
}
if (particle.wobble) {
scale *= 1 + 0.12 * Math.sin(particle.phase * 2.2);
}
if (particle.style === 'sparkle' && burstElapsed < BURST_FADE_START_MS) {
scale *= 0.85 + 0.3 * Math.sin(particle.phase * 3);
}
if (burstElapsed >= BURST_FADE_START_MS) {
const fadeT = (burstElapsed - BURST_FADE_START_MS) / (BURST_FADE_END_MS - BURST_FADE_START_MS);
opacity *= 1 - easeOutCubic(Math.min(1, fadeT));
scale *= 1 - easeOutCubic(Math.min(1, fadeT)) * 0.2;
}
if (opacity <= 0.02) return null;
return {
x: particle.x,
y: particle.y,
scale,
opacity,
rotation: particle.rotation,
};
}
export function drawBurstParticles(
context: CanvasRenderingContext2D,
particles: BurstParticle[],
dpr: number,
now: number
) {
for (const particle of particles) {
const state = particleDrawState(particle, now);
if (!state) continue;
context.globalAlpha = state.opacity;
const emojiCanvas = getEmojiCanvas(particle.emoji);
const drawSize = particle.fontSize * state.scale * EMOJI_CACHE_SCALE;
const halfSize = drawSize / 2;
const radians = (state.rotation * Math.PI) / 180;
const cos = Math.cos(radians) * dpr;
const sin = Math.sin(radians) * dpr;
context.setTransform(cos, sin, -sin, cos, state.x * dpr, state.y * dpr);
context.drawImage(emojiCanvas, -halfSize, -halfSize, drawSize, drawSize);
}
context.globalAlpha = 1;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
}
export function hasBurstParticles(particles: BurstParticle[], burstId: string): boolean {
return particles.some((particle) => particle.burstId === burstId);
}

View File

@@ -0,0 +1,391 @@
export type BurstMotionStyle =
| 'explode'
| 'bounce'
| 'rise'
| 'sparkle'
| 'splash'
| 'spiral'
| 'punch'
| 'scatter'
| 'shower';
export type EmojiBurstProfile = {
style: BurstMotionStyle;
particleCount: number;
gravity: number;
drag: number;
speedMin: number;
speedMax: number;
launchUpMin: number;
launchUpMax: number;
spinMin: number;
spinMax: number;
fontSizeMin: number;
fontSizeMax: number;
heroFontSizeMin: number;
heroFontSizeMax: number;
companions?: string[];
companionChance?: number;
/** Bias burst angle in radians. 0 = right, -π/2 = up. */
angleBias?: number;
/** Limit spawn to a cone (radians). Omit for full 360°. */
angleSpread?: number;
twinkle?: boolean;
wobble?: boolean;
};
const DEFAULT_PROFILE: EmojiBurstProfile = {
style: 'explode',
particleCount: 36,
gravity: 400,
drag: 0.935,
speedMin: 240,
speedMax: 560,
launchUpMin: 40,
launchUpMax: 130,
spinMin: -180,
spinMax: 180,
fontSizeMin: 14,
fontSizeMax: 26,
heroFontSizeMin: 26,
heroFontSizeMax: 34,
};
const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
'😆': {
style: 'bounce',
particleCount: 28,
gravity: 520,
speedMin: 180,
speedMax: 360,
launchUpMin: 120,
launchUpMax: 220,
spinMin: -240,
spinMax: 240,
wobble: true,
},
'😂': {
style: 'bounce',
particleCount: 30,
gravity: 500,
speedMin: 160,
speedMax: 340,
launchUpMin: 110,
launchUpMax: 210,
spinMin: -220,
spinMax: 220,
wobble: true,
},
'🤣': {
style: 'bounce',
particleCount: 32,
gravity: 480,
speedMin: 200,
speedMax: 380,
launchUpMin: 130,
launchUpMax: 240,
wobble: true,
},
'🔥': {
style: 'rise',
particleCount: 24,
gravity: -120,
drag: 0.96,
speedMin: 80,
speedMax: 200,
launchUpMin: 60,
launchUpMax: 160,
spinMin: -90,
spinMax: 90,
fontSizeMin: 16,
fontSizeMax: 28,
twinkle: true,
wobble: true,
companions: ['✨'],
companionChance: 0.35,
},
'❤️': {
style: 'rise',
particleCount: 22,
gravity: -80,
drag: 0.97,
speedMin: 60,
speedMax: 150,
launchUpMin: 40,
launchUpMax: 110,
spinMin: -40,
spinMax: 40,
fontSizeMin: 14,
fontSizeMax: 24,
companions: ['💕', '💖'],
companionChance: 0.4,
},
'💕': {
style: 'rise',
particleCount: 20,
gravity: -70,
drag: 0.97,
speedMin: 50,
speedMax: 140,
launchUpMin: 35,
launchUpMax: 100,
spinMin: -35,
spinMax: 35,
},
'💖': {
style: 'rise',
particleCount: 20,
gravity: -90,
drag: 0.968,
speedMin: 55,
speedMax: 150,
launchUpMin: 45,
launchUpMax: 115,
twinkle: true,
},
'⭐': {
style: 'sparkle',
particleCount: 26,
gravity: 40,
drag: 0.94,
speedMin: 100,
speedMax: 260,
launchUpMin: 20,
launchUpMax: 80,
spinMin: -120,
spinMax: 120,
twinkle: true,
companions: ['✨'],
companionChance: 0.5,
},
'✨': {
style: 'sparkle',
particleCount: 30,
gravity: 30,
drag: 0.945,
speedMin: 90,
speedMax: 240,
launchUpMin: 15,
launchUpMax: 70,
spinMin: -200,
spinMax: 200,
twinkle: true,
},
'💀': {
style: 'scatter',
particleCount: 20,
gravity: 620,
drag: 0.92,
speedMin: 200,
speedMax: 420,
launchUpMin: 20,
launchUpMax: 90,
spinMin: -360,
spinMax: 360,
fontSizeMin: 16,
fontSizeMax: 28,
},
'🎉': {
style: 'shower',
particleCount: 40,
gravity: 280,
drag: 0.93,
speedMin: 200,
speedMax: 480,
launchUpMin: 80,
launchUpMax: 200,
companions: ['🎊', '✨', '🎈'],
companionChance: 0.45,
},
'🎊': {
style: 'shower',
particleCount: 38,
gravity: 260,
drag: 0.932,
speedMin: 190,
speedMax: 460,
launchUpMin: 70,
launchUpMax: 190,
companions: ['🎉', '✨'],
companionChance: 0.4,
},
'👏': {
style: 'punch',
particleCount: 18,
gravity: 320,
speedMin: 220,
speedMax: 400,
launchUpMin: 30,
launchUpMax: 100,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 0.85,
spinMin: -100,
spinMax: 100,
},
'👍': {
style: 'punch',
particleCount: 14,
gravity: 380,
speedMin: 260,
speedMax: 440,
launchUpMin: 100,
launchUpMax: 200,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 0.55,
spinMin: -60,
spinMax: 60,
},
'💯': {
style: 'punch',
particleCount: 16,
gravity: 300,
speedMin: 200,
speedMax: 380,
launchUpMin: 140,
launchUpMax: 240,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 0.45,
fontSizeMin: 16,
fontSizeMax: 30,
wobble: true,
},
'💦': {
style: 'splash',
particleCount: 32,
gravity: 540,
drag: 0.925,
speedMin: 280,
speedMax: 520,
launchUpMin: 160,
launchUpMax: 300,
spinMin: -140,
spinMax: 140,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 1.1,
},
'🌊': {
style: 'splash',
particleCount: 28,
gravity: 420,
drag: 0.93,
speedMin: 220,
speedMax: 460,
launchUpMin: 100,
launchUpMax: 220,
angleBias: -Math.PI / 2,
angleSpread: Math.PI,
companions: ['💧'],
companionChance: 0.3,
},
'⚡': {
style: 'scatter',
particleCount: 14,
gravity: 180,
drag: 0.88,
speedMin: 380,
speedMax: 680,
launchUpMin: 10,
launchUpMax: 60,
spinMin: -30,
spinMax: 30,
fontSizeMin: 18,
fontSizeMax: 32,
angleSpread: Math.PI * 1.2,
},
'🌀': {
style: 'spiral',
particleCount: 24,
gravity: 60,
drag: 0.96,
speedMin: 120,
speedMax: 260,
launchUpMin: 0,
launchUpMax: 40,
spinMin: -420,
spinMax: 420,
},
'🤯': {
style: 'explode',
particleCount: 34,
gravity: 360,
speedMin: 280,
speedMax: 580,
launchUpMin: 60,
launchUpMax: 160,
companions: ['💥', '✨', '⭐'],
companionChance: 0.5,
},
'😭': {
style: 'splash',
particleCount: 26,
gravity: 480,
drag: 0.94,
speedMin: 140,
speedMax: 300,
launchUpMin: 40,
launchUpMax: 120,
angleBias: Math.PI / 2,
angleSpread: Math.PI * 0.7,
companions: ['💧'],
companionChance: 0.55,
},
'🥳': {
style: 'shower',
particleCount: 36,
gravity: 250,
speedMin: 180,
speedMax: 440,
launchUpMin: 90,
launchUpMax: 210,
companions: ['🎉', '🎊', '🎈'],
companionChance: 0.4,
},
'🐱': {
style: 'bounce',
particleCount: 20,
gravity: 440,
speedMin: 150,
speedMax: 320,
launchUpMin: 90,
launchUpMax: 180,
spinMin: -160,
spinMax: 160,
},
'🐶': {
style: 'bounce',
particleCount: 20,
gravity: 460,
speedMin: 160,
speedMax: 330,
launchUpMin: 85,
launchUpMax: 175,
wobble: true,
},
};
export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
const override = PROFILE_OVERRIDES[emoji];
if (!override) return DEFAULT_PROFILE;
return { ...DEFAULT_PROFILE, ...override };
}
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
if (!profile.companions?.length || !profile.companionChance) {
return primaryEmoji;
}
if (Math.random() >= profile.companionChance) {
return primaryEmoji;
}
const companion = profile.companions[Math.floor(Math.random() * profile.companions.length)];
return companion ?? primaryEmoji;
}
export function sampleBurstAngle(profile: EmojiBurstProfile): number {
if (profile.angleSpread === undefined) {
return Math.random() * Math.PI * 2;
}
const bias = profile.angleBias ?? -Math.PI / 2;
const halfSpread = profile.angleSpread / 2;
return bias + (Math.random() - 0.5) * profile.angleSpread * (0.6 + Math.random() * 0.4);
}

View File

@@ -0,0 +1,11 @@
export function findJumboEmojiElement(targetEventId: string): HTMLElement | null {
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (!message) return null;
const jumbo = message.querySelector('[data-jumbo-emoji]');
return jumbo instanceof HTMLElement ? jumbo : null;
}
export function getLocalBurstCanvasSize(maskRadius: number): number {
return Math.max(300, Math.round(maskRadius * 6.5));
}

View File

@@ -0,0 +1,34 @@
import { KeyboardEvent, MouseEvent } from 'react';
export type JumboEmojiClickHandler = (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
export function getJumboEmojiInteractionProps({
body,
isJumboEmoji,
onJumboEmojiClick,
}: {
body: string;
isJumboEmoji: boolean;
onJumboEmojiClick?: JumboEmojiClickHandler;
}) {
if (!isJumboEmoji || !onJumboEmojiClick) {
return {};
}
const activate = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
if ('key' in event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
}
event.stopPropagation();
onJumboEmojiClick(body, event);
};
return {
role: 'button' as const,
tabIndex: 0,
title: 'Throw emoji confetti',
onClick: activate,
onKeyDown: activate,
};
}

View File

@@ -0,0 +1,54 @@
import { KeyboardEvent, MouseEvent } from 'react';
import { extractJumboEmojis } from './sendEmojiConfetti';
import { BurstPoint, getBurstPointFromElement } from './burstOrigin';
export function resolveClickedEmoji(
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>,
fallbackBody: string
): { emoji: string; origin: BurstPoint } | null {
const currentTarget = event.currentTarget as HTMLElement;
const target = event.target as HTMLElement;
const emoticonEl = target.closest('[data-emoticon]') ?? currentTarget.querySelector('[data-emoticon]');
if (emoticonEl instanceof HTMLElement) {
const emoji = emoticonEl.getAttribute('data-emoticon');
if (emoji) {
return {
emoji,
origin: getBurstPointFromElement(emoticonEl),
};
}
}
if (!currentTarget.closest('[data-jumbo-emoji]') && !currentTarget.hasAttribute('data-jumbo-emoji')) {
return null;
}
const emojis = extractJumboEmojis(fallbackBody);
if (emojis.length === 0) return null;
if ('clientX' in event) {
return {
emoji: emojis[0],
origin: {
x: event.clientX,
y: event.clientY,
},
};
}
const firstEmoticon = currentTarget.querySelector('[data-emoticon]');
if (firstEmoticon instanceof HTMLElement) {
const emoji = firstEmoticon.getAttribute('data-emoticon');
if (emoji) {
return {
emoji,
origin: getBurstPointFromElement(firstEmoticon),
};
}
}
return {
emoji: emojis[0],
origin: getBurstPointFromElement(currentTarget),
};
}

View File

@@ -0,0 +1,42 @@
import { MatrixClient } from 'matrix-js-sdk';
import { EMOJI_REG_G, JUMBO_EMOJI_REG } from '../../../utils/regex';
import { trimReplyFromBody } from '../../../utils/room';
import { emojis } from '../../../plugins/emoji';
import { EMOJI_CONFETTI_EVENT_TYPE, EmojiConfettiContent } from './types';
const SHORTCODE_PATTERN = /:([^\s:]+):/g;
export function extractJumboEmojis(body: string): string[] {
const trimmedBody = trimReplyFromBody(body).trim();
if (!JUMBO_EMOJI_REG.test(trimmedBody)) return [];
const found: string[] = [];
for (const match of trimmedBody.matchAll(EMOJI_REG_G)) {
const emoji = match[1];
if (emoji) found.push(emoji);
}
for (const match of trimmedBody.matchAll(SHORTCODE_PATTERN)) {
const shortcode = match[1];
const resolved = emojis.find((emoji) => emoji.shortcode === shortcode);
if (resolved) found.push(resolved.unicode);
}
return found.length > 0 ? found : ['🎉'];
}
export function sendEmojiConfettiEvent(
mx: MatrixClient,
roomId: string,
targetEventId: string,
emojis: string[]
) {
const content: EmojiConfettiContent = {
emojis,
msgtype: EMOJI_CONFETTI_EVENT_TYPE,
target_event_id: targetEventId,
};
return mx.sendEvent(roomId, EMOJI_CONFETTI_EVENT_TYPE as never, content);
}

View File

@@ -0,0 +1,16 @@
import type { BurstPoint } from './burstOrigin';
export const EMOJI_CONFETTI_EVENT_TYPE = 'app.relay.emoji_confetti';
export type EmojiConfettiContent = {
emojis?: string[];
msgtype?: string;
target_event_id?: string;
};
export type EmojiConfettiBurst = {
id: string;
targetEventId: string;
emojis: string[];
origin: BurstPoint;
};

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { BurstPoint, resolveBurstOrigin } from './burstOrigin';
import {
EMOJI_CONFETTI_EVENT_TYPE,
EmojiConfettiBurst,
EmojiConfettiContent,
} from './types';
function scheduleBurstFromValues(
burstId: string,
emojis: string[],
targetEventId: string | undefined,
onBurst: (burst: EmojiConfettiBurst) => void,
attempt = 0,
explicitOrigin?: BurstPoint
) {
const targetElement =
targetEventId &&
document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (targetEventId && !targetElement && attempt < 8) {
window.setTimeout(
() =>
scheduleBurstFromValues(
burstId,
emojis,
targetEventId,
onBurst,
attempt + 1,
explicitOrigin
),
50
);
return;
}
onBurst({
id: burstId,
targetEventId: targetEventId ?? '',
emojis: emojis.length > 0 ? emojis : ['🎉'],
origin: resolveBurstOrigin(targetEventId, emojis, explicitOrigin),
});
}
function scheduleBurst(
event: MatrixEvent,
onBurst: (burst: EmojiConfettiBurst) => void,
attempt = 0
) {
const eventId = event.getId();
if (!eventId) return;
const { emojis, targetEventId } = parseEmojiConfettiContent(event.getContent());
scheduleBurstFromValues(eventId, emojis, targetEventId, onBurst, attempt);
}
function parseEmojiConfettiContent(content: unknown): { emojis: string[]; targetEventId?: string } {
const confettiContent = content as EmojiConfettiContent;
const emojis = Array.isArray(confettiContent.emojis)
? confettiContent.emojis.filter((emoji): emoji is string => typeof emoji === 'string' && emoji.length > 0)
: [];
return {
emojis: emojis.length > 0 ? emojis : ['🎉'],
targetEventId:
typeof confettiContent.target_event_id === 'string'
? confettiContent.target_event_id
: undefined,
};
}
export function useEmojiConfetti(room: Room) {
const [bursts, setBursts] = useState<EmojiConfettiBurst[]>([]);
const seenEventIdsRef = useRef(new Set<string>());
const ownEventIdsRef = useRef(new Set<string>());
const addBurst = useCallback((burst: EmojiConfettiBurst) => {
setBursts((current) => [...current, burst]);
}, []);
const registerOwnEventId = useCallback((eventId: string) => {
ownEventIdsRef.current.add(eventId);
}, []);
const queueBurst = useCallback((event: MatrixEvent) => {
const eventId = event.getId();
if (!eventId || seenEventIdsRef.current.has(eventId)) return;
seenEventIdsRef.current.add(eventId);
if (ownEventIdsRef.current.has(eventId)) {
ownEventIdsRef.current.delete(eventId);
return;
}
scheduleBurst(event, addBurst);
}, [addBurst]);
const triggerLocalBurst = useCallback(
(targetEventId: string, emojis: string[], origin?: BurstPoint) => {
const burstId = `local-${targetEventId}-${Date.now()}`;
scheduleBurstFromValues(burstId, emojis, targetEventId, addBurst, 0, origin);
},
[addBurst]
);
useEffect(() => {
const handleTimeline: (
event: MatrixEvent,
eventRoom: Room | undefined,
toStartOfTimeline?: boolean,
removed?: boolean
) => void = (event, eventRoom, toStartOfTimeline, removed) => {
if (removed || toStartOfTimeline || eventRoom?.roomId !== room.roomId) return;
if (event.getType() !== EMOJI_CONFETTI_EVENT_TYPE) return;
queueBurst(event);
};
room.on(RoomEvent.Timeline, handleTimeline);
return () => {
room.off(RoomEvent.Timeline, handleTimeline);
};
}, [queueBurst, room]);
const removeBurst = useCallback((burstId: string) => {
setBursts((current) => current.filter((burst) => burst.id !== burstId));
}, []);
return {
bursts,
removeBurst,
triggerLocalBurst,
registerOwnEventId,
};
}

View File

@@ -0,0 +1,37 @@
import { KeyboardEvent, MouseEvent, useCallback } from 'react';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { resolveClickedEmoji } from './resolveClickedEmoji';
import { sendEmojiConfettiEvent } from './sendEmojiConfetti';
import { useEmojiConfetti } from './useEmojiConfetti';
export function useJumboEmojiConfetti(room: Room) {
const mx = useMatrixClient();
const { bursts, removeBurst, triggerLocalBurst, registerOwnEventId } = useEmojiConfetti(room);
const handleJumboEmojiClick = useCallback(
(targetEventId: string, body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
const clicked = resolveClickedEmoji(event, body);
if (!clicked) return;
const emojis = [clicked.emoji];
triggerLocalBurst(targetEventId, emojis, clicked.origin);
sendEmojiConfettiEvent(mx, room.roomId, targetEventId, emojis)
.then((response) => {
const eventId = response?.event_id;
if (eventId) registerOwnEventId(eventId);
})
.catch((error) => {
console.error('[emoji-confetti] Failed to send confetti event:', error);
});
},
[mx, registerOwnEventId, room.roomId, triggerLocalBurst]
);
return {
bursts,
removeBurst,
handleJumboEmojiClick,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,12 +39,14 @@ import { AvatarPresence, PresenceBadge } from '../../../components/presence';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
import {
extractMetadataFromImage,
embedMetadataInImage,
detectImageFormat,
getMimeType,
getExtension,
ImageMetadata,
needsPngForMetadata,
convertImageDataToPng,
uint8ArrayToBlob,
} from '../../../utils/imageMetadata';
import { getCurrentAccessToken } from '../../../utils/auth';
@@ -473,7 +475,35 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
setSaving(true);
setError(undefined);
try {
// Fetch the raw uploaded image so we can embed existing banner/color metadata
// Re-embed existing banner/color/style into the new avatar when present
const metadata: ImageMetadata = {
banner: userBanner,
color: userColor,
avatarBorderColor: profileStyle.avatarBorderColor,
gradient: profileStyle.gradient,
};
const hasMetadata = Boolean(
metadata.color ||
metadata.banner ||
metadata.avatarBorderColor ||
metadata.gradient
);
// Nothing to re-embed — use the already-uploaded MXC directly
if (!hasMetadata) {
await mx.setAvatarUrl(upload.mxc);
const userId = mx.getUserId();
if (userId) {
const user = mx.getUser(userId);
if (user && user.avatarUrl !== upload.mxc) {
user.setAvatarUrl(upload.mxc);
}
}
setAvatarFile(undefined);
await syncUserAvatar();
return;
}
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
@@ -490,22 +520,25 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
}
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
const avatarData = await response.arrayBuffer();
let avatarData: ArrayBuffer | Uint8Array = await response.arrayBuffer();
const format = detectImageFormat(avatarData);
let format = detectImageFormat(avatarData);
if (format === 'unknown') throw new Error('Unsupported avatar image format');
// Re-embed the existing banner URL and profile color into the new avatar
const metadata: ImageMetadata = {
banner: userBanner,
color: userColor,
};
// Banner / border / gradient only live in PNG tEXt — convert JPEG/WebP/GIF first
if (format !== 'png' && needsPngForMetadata(metadata)) {
const pngData = await convertImageDataToPng(avatarData);
if (!pngData) throw new Error('Failed to convert avatar to PNG for metadata');
avatarData = pngData;
format = 'png';
}
const modifiedData = embedMetadataInImage(avatarData, metadata);
if (!modifiedData) throw new Error('Failed to embed metadata in avatar');
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([modifiedData], { type: mimeType });
const blob = uint8ArrayToBlob(modifiedData, mimeType);
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
await mx.setAvatarUrl(uploadResponse.content_uri);
@@ -525,7 +558,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
}
setSaving(false);
},
[mx, useAuthentication, userBanner, userColor, syncUserAvatar]
[mx, useAuthentication, userBanner, userColor, profileStyle, syncUserAvatar]
);
const handleRemoveBanner = async () => {

View File

@@ -8,6 +8,7 @@ import React, {
} from 'react';
import dayjs from 'dayjs';
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
import { useSetAtom } from 'jotai';
import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful';
import { isKeyHotkey } from 'is-hotkey';
@@ -24,6 +25,7 @@ import {
LightTheme,
Theme,
ThemeKind,
themePreviewIdAtom,
useSystemThemeKind,
useThemeNames,
useThemes,
@@ -41,8 +43,25 @@ type ThemeSelectorProps = {
onSelect: (theme: Theme) => void;
};
const ThemeSelector = as<'div', ThemeSelectorProps>(
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
<Menu {...props} ref={ref}>
({ themeNames, themes, selected, onSelect, ...props }, ref) => {
const setPreviewId = useSetAtom(themePreviewIdAtom);
useEffect(
() => () => {
setPreviewId(undefined);
},
[setPreviewId]
);
const clearPreview = () => setPreviewId(undefined);
const previewTheme = (theme: Theme) => setPreviewId(theme.id);
return (
<Menu
{...props}
ref={ref}
onMouseLeave={clearPreview}
>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{themes.map((theme) => (
<MenuItem
@@ -50,6 +69,8 @@ const ThemeSelector = as<'div', ThemeSelectorProps>(
size="300"
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
radii="300"
onMouseEnter={() => previewTheme(theme)}
onFocus={() => previewTheme(theme)}
onClick={() => onSelect(theme)}
>
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
@@ -57,12 +78,14 @@ const ThemeSelector = as<'div', ThemeSelectorProps>(
))}
</Box>
</Menu>
)
);
}
);
function SelectTheme({ disabled }: { disabled?: boolean }) {
const themes = useThemes();
const themeNames = useThemeNames();
const setPreviewId = useSetAtom(themePreviewIdAtom);
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
@@ -72,10 +95,16 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
};
const handleThemeSelect = (theme: Theme) => {
setPreviewId(undefined);
setThemeId(theme.id);
setMenuCords(undefined);
};
const closeMenu = () => {
setPreviewId(undefined);
setMenuCords(undefined);
};
return (
<>
<Button
@@ -99,7 +128,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
onDeactivate: closeMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
@@ -125,6 +154,7 @@ function SystemThemePreferences() {
const themeKind = useSystemThemeKind();
const themeNames = useThemeNames();
const themes = useThemes();
const setPreviewId = useSetAtom(themePreviewIdAtom);
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
@@ -145,15 +175,27 @@ function SystemThemePreferences() {
};
const handleLightThemeSelect = (theme: Theme) => {
setPreviewId(undefined);
setLightThemeId(theme.id);
setLTCords(undefined);
};
const handleDarkThemeSelect = (theme: Theme) => {
setPreviewId(undefined);
setDarkThemeId(theme.id);
setDTCords(undefined);
};
const closeLightMenu = () => {
setPreviewId(undefined);
setLTCords(undefined);
};
const closeDarkMenu = () => {
setPreviewId(undefined);
setDTCords(undefined);
};
return (
<Box wrap="Wrap" gap="400">
<SettingTile
@@ -179,7 +221,7 @@ function SystemThemePreferences() {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setLTCords(undefined),
onDeactivate: closeLightMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
@@ -220,7 +262,7 @@ function SystemThemePreferences() {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setDTCords(undefined),
onDeactivate: closeDarkMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',

View File

@@ -170,22 +170,34 @@ type PushStatus = {
registered: boolean;
distributor: string;
endpoint: string;
distributors: string[];
lastFailure: string;
autoStartBlocked: boolean;
};
/** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [lastError, setLastError] = useState<string | undefined>(undefined);
const refresh = useCallback(async () => {
setLoading(true);
const s = await getBackgroundSyncStatus();
const distributors = Array.isArray(s?.distributors)
? s.distributors
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
? [s.distributors]
: [];
setStatus(
s
? {
registered: s.registered,
distributor: s.distributor || '',
endpoint: s.endpoint || '',
distributors,
lastFailure: s.lastFailure || '',
autoStartBlocked: Boolean(s.autoStartBlocked),
}
: undefined
);
@@ -198,8 +210,14 @@ function AndroidPushNotifications() {
const [resetState, reset] = useAsyncCallback(
useCallback(async () => {
await requestResetPushRegistration();
setLastError(undefined);
const result = await requestResetPushRegistration();
await refresh();
if (!result.success) {
setLastError(
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
);
}
}, [refresh])
);
@@ -214,6 +232,50 @@ function AndroidPushNotifications() {
resetState.status === AsyncStatus.Loading ||
pingState.status === AsyncStatus.Loading;
const statusDescription = (() => {
if (loading) return 'Loading status…';
if (status === undefined) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
);
}
if (status.registered) {
return (
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text>
);
}
if (status.lastFailure === 'AUTO_START_BLOCKED' || status.autoStartBlocked) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Phone is blocking auto-start for Paarrot. Open App Boot / Auto-start settings, allow Paarrot (and ntfy), then tap Reset.
</Text>
);
}
if (status.lastFailure) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`}
</Text>
);
}
if (status.distributors.length === 0) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
No UnifiedPush distributor found. Install ntfy from Play Store/F-Droid and enable UnifiedPush in ntfy settings.
</Text>
);
}
return (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
{`Found ${status.distributors.join(', ')} but not registered yet. Tap Reset and pick ntfy.`}
</Text>
);
})();
return (
<Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text>
@@ -226,29 +288,20 @@ function AndroidPushNotifications() {
<SettingTile
title="Background Notifications"
description={
loading ? (
'Loading status…'
) : status === undefined ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
{statusDescription}
{lastError ? (
<Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
{lastError}
</Text>
) : null}
</>
) : (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
Not registered. Tap &quot;Reset&quot; to choose a distributor app.
</Text>
)
}
after={loading ? <Spinner variant="Secondary" /> : undefined}
/>
<SettingTile
title="Change Distributor"
description="Re-open the UnifiedPush distributor selection dialog."
description="Shows a list of installed UnifiedPush apps (ntfy, etc.) and registers the one you pick."
after={
<Button
size="300"

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { NavigateOptions, useNavigate } from 'react-router-dom';
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import {
@@ -7,21 +7,25 @@ import {
getHomeRoomPath,
getSpacePath,
getSpaceRoomPath,
withRoomEventId,
} from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient';
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { mDirectAtom } from '../state/mDirectList';
import { useSelectedRoom } from './router/useSelectedRoom';
import { useSelectedSpace } from './router/useSelectedSpace';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
export const useRoomNavigate = () => {
const navigate = useNavigate();
const location = useLocation();
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace();
const selectedRoomId = useSelectedRoom();
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const navigateSpace = useCallback(
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
const navigateRoom = useCallback(
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
// Already viewing this room — keep the current home/direct/space URL and only
// change the event id. Otherwise guessPerfectParent can yank the sidebar to
// wherever the room "belongs".
if (selectedRoomId === roomId) {
navigate(withRoomEventId(location.pathname, eventId), opts);
return;
}
const room = mx.getRoom(roomId);
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
@@ -44,6 +56,19 @@ export const useRoomNavigate = () => {
}
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
// Prefer Direct for m.direct rooms unless we are already browsing a parent space
// of this room (keep space chrome). Opening via space while roomToParents is still
// catching up used to hit JoinBeforeNavigate even though the DM was listed.
if (mDirects.has(roomId)) {
const stayInSelectedSpace =
Boolean(spaceSelectedId) && orphanParents.includes(spaceSelectedId!);
if (!stayInSelectedSpace) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return;
}
}
if (orphanParents.length > 0) {
let parentSpace: string;
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
@@ -61,14 +86,18 @@ export const useRoomNavigate = () => {
return;
}
if (mDirects.has(roomId)) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return;
}
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
},
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
[
mx,
navigate,
location.pathname,
selectedRoomId,
spaceSelectedId,
roomToParents,
mDirects,
developerTools,
]
);
return {

View File

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

View File

@@ -1,4 +1,5 @@
import { lightTheme } from 'folds';
import { atom, useAtomValue } from 'jotai';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css';
@@ -6,6 +7,9 @@ import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
/** Ephemeral theme id while hovering theme options in settings (not persisted). */
export const themePreviewIdAtom = atom<string | undefined>(undefined);
export enum ThemeKind {
Light = 'light',
Dark = 'dark',
@@ -190,6 +194,16 @@ export const useActiveTheme = (): Theme => {
return selectedTheme;
};
/** Active theme, or hover-preview theme when browsing the theme menu. */
export const useDisplayTheme = (): Theme => {
const activeTheme = useActiveTheme();
const themes = useThemes();
const previewId = useAtomValue(themePreviewIdAtom);
if (!previewId) return activeTheme;
return themes.find((theme) => theme.id === previewId) ?? activeTheme;
};
const ThemeContext = createContext<Theme | null>(null);
export const ThemeContextProvider = ThemeContext.Provider;

View File

@@ -12,6 +12,8 @@ import {
getMimeType,
getExtension,
ImageMetadata,
convertImageDataToPng,
uint8ArrayToBlob,
} from '../utils/imageMetadata';
/**
@@ -137,27 +139,39 @@ export function useUserBanner(): [
throw new Error('Failed to fetch current avatar');
}
// Detect image format
const format = detectImageFormat(avatarData);
// Detect image format — banner lives in PNG tEXt, so convert other formats first
let workingData: ArrayBuffer | Uint8Array = avatarData;
let format = detectImageFormat(workingData);
if (format === 'unknown') {
throw new Error('Unsupported avatar image format');
}
if (format !== 'png') {
console.log('[updateBanner] Converting', format, 'avatar to PNG for banner metadata');
const pngData = await convertImageDataToPng(workingData);
if (!pngData) {
throw new Error('Failed to convert avatar to PNG for banner metadata');
}
workingData = pngData;
format = 'png';
}
// Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(avatarData);
const existingMetadata = extractMetadataFromImage(workingData);
console.log('[updateBanner] Existing metadata:', existingMetadata);
// Modify image metadata with new banner, preserving color
// Modify image metadata with new banner, preserving color.
// Empty string clears banner (PNG embed drops falsy fields after stripping old chunks).
const newMetadata: ImageMetadata = {
color: existingMetadata.color,
banner: newBanner,
banner: newBanner ?? '',
avatarBorderColor: existingMetadata.avatarBorderColor,
gradient: existingMetadata.gradient,
};
console.log('[updateBanner] New metadata to embed:', newMetadata);
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
if (!newAvatarData) {
throw new Error('Failed to embed banner in avatar metadata');
@@ -167,7 +181,7 @@ export function useUserBanner(): [
const verifyMetadata = extractMetadataFromImage(newAvatarData);
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
if (newBanner && verifyMetadata.banner !== newBanner) {
if ((newBanner || undefined) !== verifyMetadata.banner) {
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
throw new Error('Banner verification failed');
}
@@ -176,7 +190,7 @@ export function useUserBanner(): [
// Upload modified avatar with correct MIME type
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([newAvatarData], { type: mimeType });
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
const uploadResponse = await mx.uploadContent(blob, {
name: `avatar.${extension}`,

View File

@@ -12,6 +12,7 @@ import {
detectImageFormat,
getMimeType,
getExtension,
uint8ArrayToBlob,
} from '../utils/imageMetadata';
/**
@@ -176,7 +177,7 @@ export function useUserColor(): [
// Upload modified avatar with correct MIME type
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([newAvatarData], { type: mimeType });
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
const uploadResponse = await mx.uploadContent(blob, {
name: `avatar.${extension}`,
type: mimeType,

View File

@@ -11,6 +11,8 @@ import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken }
getExtension,
ImageMetadata,
ProfileGradient,
convertImageDataToPng,
uint8ArrayToBlob,
} from '../utils/imageMetadata';
/**
@@ -142,14 +144,24 @@ export function useUserProfileStyle(): [
throw new Error('Failed to fetch current avatar');
}
// Detect image format
const format = detectImageFormat(avatarData);
// Border/gradient live in PNG tEXt — convert other formats first
let workingData: ArrayBuffer | Uint8Array = avatarData;
let format = detectImageFormat(workingData);
if (format === 'unknown') {
throw new Error('Unsupported avatar image format');
}
if (format !== 'png') {
const pngData = await convertImageDataToPng(workingData);
if (!pngData) {
throw new Error('Failed to convert avatar to PNG for style metadata');
}
workingData = pngData;
format = 'png';
}
// Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(avatarData);
const existingMetadata = extractMetadataFromImage(workingData);
// Merge with new style
const newMetadata: ImageMetadata = {
@@ -159,7 +171,7 @@ export function useUserProfileStyle(): [
};
// Embed updated metadata
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
if (!newAvatarData) {
throw new Error('Failed to embed style in avatar metadata');
}
@@ -167,7 +179,7 @@ export function useUserProfileStyle(): [
// Upload modified avatar with correct MIME type
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([newAvatarData], { type: mimeType });
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
const uploadResponse = await mx.uploadContent(blob, {
name: `avatar.${extension}`,

View File

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

View File

@@ -5,7 +5,7 @@ import {
LightTheme,
ThemeContextProvider,
ThemeKind,
useActiveTheme,
useDisplayTheme,
useSystemThemeKind,
} from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
@@ -29,21 +29,21 @@ export function UnAuthRouteThemeManager() {
}
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
const activeTheme = useActiveTheme();
const displayTheme = useDisplayTheme();
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
useEffect(() => {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
document.body.classList.add(...activeTheme.classNames);
document.body.classList.add(...displayTheme.classNames);
if (monochromeMode) {
document.body.style.filter = 'grayscale(1)';
} else {
document.body.style.filter = '';
}
}, [activeTheme, monochromeMode]);
}, [displayTheme, monochromeMode]);
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
return <ThemeContextProvider value={displayTheme}>{children}</ThemeContextProvider>;
}

View File

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

View File

@@ -17,7 +17,7 @@ import {
NavItemContent,
NavLink,
} from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath } from '../../pathUtils';
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath, getDirectSearchPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../../../components/virtualizer';
@@ -41,10 +41,9 @@ import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
import { useDirectCreateSelected, useDirectSearchSelected } from '../../../hooks/router/useDirectSelected';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { UnreadBadge } from '../../../components/unread-badge';
type DirectMenuProps = {
requestClose: () => void;
};
@@ -228,6 +227,7 @@ export function Direct() {
const navigate = useNavigate();
const createDirectSelected = useDirectCreateSelected();
const searchSelected = useDirectSearchSelected();
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
@@ -283,6 +283,22 @@ export function Direct() {
</NavButton>
</NavItem>
<PluginNavSlot location="direct-messages" />
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getDirectSearchPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory>
<NotificationsNavItem />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -188,7 +188,7 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
text,
EMOJI_REG_G,
(match, pushIndex) => (
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase} data-emoticon={match[0]}>
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
{match[0]}
</span>
@@ -551,8 +551,15 @@ export const getReactCustomHtmlParser = (
);
}
if (htmlSrc && 'data-mx-emoticon' in props) {
const emoticonLabel =
(typeof props.alt === 'string' && props.alt) ||
(typeof props.title === 'string' && props.title) ||
undefined;
return (
<span className={css.EmoticonBase}>
<span
className={css.EmoticonBase}
{...(emoticonLabel ? { 'data-emoticon': emoticonLabel } : {})}
>
<span className={css.Emoticon()}>
<AuthenticatedImg {...props} className={css.EmoticonImg} src={htmlSrc} />
</span>

View File

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

View File

@@ -103,6 +103,23 @@ export const useBindRoomToParentsAtom = (
setRoomToParents({ type: 'DELETE', roomId: childId });
}
}
return;
}
// Create type can arrive after the room is already in the store. Without this,
// spaces are skipped on INITIALIZE (isSpace false) and children stay unmapped
// until an unrelated SpaceChild event fires — DMs/channels look listed but
// SpaceRouteRoomProvider blocks on missing roomToParents.
if (mEvent.getType() === StateEvent.RoomCreate) {
const roomId = mEvent.getRoomId();
const room = roomId ? mx.getRoom(roomId) : null;
if (room && isSpace(room) && room.getMyMembership() !== Membership.Invite) {
setRoomToParents({
type: 'PUT',
parent: room.roomId,
children: getSpaceChildren(room),
});
}
}
};

View File

@@ -30,3 +30,17 @@ export const saveRoomScrollState = (roomId: string, state: RoomScrollState): voi
export const clearRoomScrollState = (roomId: string): void => {
cache.delete(roomId);
};
/** Survives RoomTimeline remounts (e.g. clearing an event permalink URL). */
const returnAnchors = new Map<string, string>();
export const getRoomReturnAnchor = (roomId: string): string | undefined =>
returnAnchors.get(roomId);
export const setRoomReturnAnchor = (roomId: string, eventId: string): void => {
returnAnchors.set(roomId, eventId);
};
export const clearRoomReturnAnchor = (roomId: string): void => {
returnAnchors.delete(roomId);
};

View File

@@ -15,6 +15,9 @@ type UnifiedPushStatus = {
registered: boolean;
distributor: string;
distributors: string[] | string;
lastFailure?: string;
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
autoStartBlocked?: boolean;
};
type UnifiedPushEndpointEvent = {
@@ -55,6 +58,8 @@ interface MatrixBackgroundSyncPlugin {
setAppForeground(options: { foreground: boolean }): Promise<void>;
/** Returns fetch state and current UnifiedPush registration details. */
getStatus(): Promise<UnifiedPushStatus>;
/** Native Matrix gateway discovery for a UnifiedPush endpoint (avoids WebView CORS). */
resolveUnifiedPushGateway(options: { endpoint: string }): Promise<{ gatewayUrl: string }>;
addListener(
eventName: 'unifiedPushNewEndpoint',
listenerFunc: (event: UnifiedPushEndpointEvent) => void
@@ -141,6 +146,17 @@ const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[]
};
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
// Prefer native HTTP — ntfy/Matrix gateway responses omit CORS headers, so
// WebView fetch fails with TypeError: Failed to fetch and wrongly falls back.
try {
const { registerPlugin } = await loadCapacitorCore();
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
const result = await MatrixBackgroundSync.resolveUnifiedPushGateway({ endpoint });
if (result?.gatewayUrl) return result.gatewayUrl;
} catch (err) {
console.warn('[BackgroundSync] Native UnifiedPush gateway discovery failed:', err);
}
try {
const discoveryUrl = new URL(endpoint);
discoveryUrl.pathname = '/_matrix/push/v1/notify';
@@ -529,7 +545,23 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
return result ?? { success: false };
if (!result?.success) return { success: false };
// Endpoint arrives asynchronously from the distributor after register().
for (let i = 0; i < 40; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 250));
const status = await getBackgroundSyncStatus();
if (status?.registered && status.endpoint) {
return { success: true };
}
if (status?.lastFailure) {
console.warn('[BackgroundSync] Registration failed while waiting:', status.lastFailure);
return { success: false };
}
}
console.warn('[BackgroundSync] Distributor selected but no endpoint received in time');
return { success: false };
} catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false };

View File

@@ -258,6 +258,59 @@ export function embedColorInImage(imageData: ArrayBuffer | Uint8Array, color: st
}
}
/**
* True when metadata includes fields that only PNG tEXt chunks can store.
*/
export function needsPngForMetadata(metadata: ImageMetadata): boolean {
return Boolean(metadata.banner || metadata.avatarBorderColor || metadata.gradient);
}
/**
* Convert image data to PNG via canvas (needed for banner/border/gradient metadata).
*/
export async function convertImageDataToPng(
imageData: ArrayBuffer | Uint8Array
): Promise<Uint8Array | null> {
const bytes = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
const format = detectImageFormat(bytes);
if (format === 'png') return bytes;
if (format === 'unknown') return null;
try {
const blob = uint8ArrayToBlob(bytes, getMimeType(format));
const bitmap = await createImageBitmap(blob);
try {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(bitmap, 0, 0);
const pngBlob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png');
});
if (!pngBlob) return null;
return new Uint8Array(await pngBlob.arrayBuffer());
} finally {
bitmap.close();
}
} catch (error) {
console.error('[convertImageDataToPng] Conversion failed:', error);
return null;
}
}
/**
* Build a Blob from Uint8Array without relying on BufferSource/BlobPart quirks
* across TS DOM lib / Electron versions (avoids SharedArrayBuffer typing issues).
*/
export function uint8ArrayToBlob(data: Uint8Array, type: string): Blob {
const copy = new ArrayBuffer(data.byteLength);
new Uint8Array(copy).set(data);
return new Blob([copy], { type });
}
/**
* Embed paarrot metadata (color and/or banner) into image data
* Preserves existing metadata that is not being updated
@@ -270,18 +323,27 @@ export function embedMetadataInImage(
metadata: ImageMetadata
): Uint8Array | null {
const format = detectImageFormat(imageData);
const bytes = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
switch (format) {
case 'png':
return embedMetadataInPNG(imageData, metadata);
// For other formats, only embed color for now
// Non-PNG: color via format-specific chunks; banner/border/gradient need PNG (convert first)
case 'webp':
case 'jpeg':
case 'gif':
if (needsPngForMetadata(metadata)) {
console.warn(
'[embedMetadataInImage] Banner/border/gradient require PNG; convert with convertImageDataToPng first. Got:',
format
);
return null;
}
if (metadata.color) {
return embedColorInImage(imageData, metadata.color);
}
return null;
// Supported format with nothing to embed — succeed as a no-op so avatar upload isn't blocked
return bytes;
default:
console.warn('[embedMetadataInImage] Unknown image format, cannot embed metadata');
return null;

View File

@@ -259,24 +259,35 @@ body.stationery-dark-theme {
transition: width 0.2s ease;
}
/* Scrollbar styling */
/* Scrollbar styling — transparent until hover so Folds Hover/hideTrack
scroll areas (e.g. message composer) don't show a permanent side bar */
.twilight-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.twilight-theme ::-webkit-scrollbar-track {
background: rgba(20, 18, 31, 0.5);
background: transparent;
}
.twilight-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
background: transparent;
border-radius: 5px;
border: 2px solid rgba(20, 18, 31, 0.5);
transition: background 0.2s ease;
border: 2px solid transparent;
background-clip: padding-box;
transition: background 0.2s ease, border-color 0.2s ease;
}
.twilight-theme ::-webkit-scrollbar-thumb:hover {
.twilight-theme *:hover::-webkit-scrollbar-track {
background: rgba(20, 18, 31, 0.5);
}
.twilight-theme *:hover::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
border-color: rgba(20, 18, 31, 0.5);
}
.twilight-theme *:hover::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
}
@@ -357,24 +368,34 @@ body.stationery-dark-theme {
transition: width 0.2s ease;
}
/* Scrollbar styling */
/* Scrollbar styling — transparent until hover (see twilight note above) */
.mocha-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.mocha-theme ::-webkit-scrollbar-track {
background: rgba(26, 22, 20, 0.5);
background: transparent;
}
.mocha-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
background: transparent;
border-radius: 5px;
border: 2px solid rgba(26, 22, 20, 0.5);
transition: background 0.2s ease;
border: 2px solid transparent;
background-clip: padding-box;
transition: background 0.2s ease, border-color 0.2s ease;
}
.mocha-theme ::-webkit-scrollbar-thumb:hover {
.mocha-theme *:hover::-webkit-scrollbar-track {
background: rgba(26, 22, 20, 0.5);
}
.mocha-theme *:hover::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
border-color: rgba(26, 22, 20, 0.5);
}
.mocha-theme *:hover::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #625548 0%, #7E6D5A 100%);
}
@@ -866,6 +887,12 @@ body.stationery-dark-theme {
width: calc(100% + 25px) !important;
max-width: none !important;
box-sizing: border-box !important;
/* Gutter is only for post-it overflow paint — do not steal clicks from page-nav */
pointer-events: none;
}
.stationery [data-sidebar-scroll] > * {
pointer-events: auto;
}
.stationery [data-sidebar] > *,
@@ -1762,16 +1789,26 @@ body.stationery-dark-theme {
}
.stationery ::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--manila) 45%, transparent);
background: transparent;
}
.stationery ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--manila-deep) 0%, var(--manila-edge) 100%);
background: transparent;
border-radius: 2px;
border: 2px solid color-mix(in srgb, var(--manila) 45%, transparent);
border: 2px solid transparent;
background-clip: padding-box;
}
.stationery ::-webkit-scrollbar-thumb:hover {
.stationery *:hover::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--manila) 45%, transparent);
}
.stationery *:hover::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--manila-deep) 0%, var(--manila-edge) 100%);
border-color: color-mix(in srgb, var(--manila) 45%, transparent);
}
.stationery *:hover::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--manila-edge) 0%, var(--manila-dark) 100%);
}

365
src/playground/App.tsx Normal file
View File

@@ -0,0 +1,365 @@
import { useDeferredValue, useEffect, useMemo, useState, useTransition } from 'react';
import Editor, { loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor';
import './monacoSetup';
import { getCatalog, harnessForModule, type CatalogEntry } from './catalog';
import { DEFAULT_SOURCE } from './defaultSource';
import { loadManualSession, type ManualSessionInput } from './liveClient';
import { PlaygroundProviders } from './mocks/PlaygroundProviders';
import { PreviewErrorBoundary } from './PreviewErrorBoundary';
import { ResizableStage } from './ResizableStage';
import { LivePreview } from './LivePreview';
import { usePlaygroundMatrix } from './usePlaygroundMatrix';
// Use the npm monaco build — the CDN AMD loader breaks UMD deps like sanitize-html.
loader.config({ monaco });
type EditorTab = 'component' | 'harness';
async function pushLiveSource(source: string) {
await fetch('/__playground/live', {
method: 'POST',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: source,
});
}
async function fetchComponentSource(filePath: string): Promise<string> {
const res = await fetch(`/__playground/source?path=${encodeURIComponent(filePath)}`);
if (!res.ok) throw new Error(`Failed to load ${filePath}: ${res.status}`);
return res.text();
}
async function pushComponentSource(filePath: string, source: string) {
await fetch(`/__playground/source?path=${encodeURIComponent(filePath)}`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: source,
});
}
export function App() {
const catalog = useMemo(() => getCatalog(), []);
const [filter, setFilter] = useState('');
const [harnessSource, setHarnessSource] = useState(DEFAULT_SOURCE);
const [componentSource, setComponentSource] = useState<string>('');
const [editorTab, setEditorTab] = useState<EditorTab>('harness');
const [selected, setSelected] = useState<CatalogEntry | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [showTokenForm, setShowTokenForm] = useState(false);
const [manual, setManual] = useState<ManualSessionInput>(() => {
const saved = loadManualSession();
return (
saved ?? {
baseUrl: 'https://matrix.org',
userId: '@you:matrix.org',
deviceId: 'PLAYGROUND',
accessToken: '',
}
);
});
const deferredHarness = useDeferredValue(harnessSource);
const deferredComponent = useDeferredValue(componentSource);
const [, startTransition] = useTransition();
const matrix = usePlaygroundMatrix();
useEffect(() => {
fetch('/__playground/live')
.then((r) => r.text())
.then((text) => {
if (text.trim()) setHarnessSource(text);
})
.catch(() => undefined);
}, []);
useEffect(() => {
const handle = window.setTimeout(() => {
void pushLiveSource(deferredHarness);
}, 250);
return () => window.clearTimeout(handle);
}, [deferredHarness]);
// Upgrade stale RoomInput harness (bare <RoomInput /> or mocks.room) to useRoom().
useEffect(() => {
if (!selected?.importPath.endsWith('/RoomInput')) return;
if (harnessSource.includes('useRoom()')) return;
setHarnessSource(harnessForModule(selected.importPath));
}, [selected, harnessSource]);
// Writes the real component file — strings/markup edits land in src/app/…
useEffect(() => {
if (!selected || editorTab !== 'component') return;
if (!deferredComponent) return;
const handle = window.setTimeout(() => {
void pushComponentSource(selected.filePath, deferredComponent).catch((err) => {
setLoadError(err instanceof Error ? err.message : String(err));
});
}, 400);
return () => window.clearTimeout(handle);
}, [deferredComponent, editorTab, selected]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return catalog;
return catalog.filter((e) => e.label.toLowerCase().includes(q));
}, [catalog, filter]);
const openEntry = (entry: CatalogEntry) => {
setSelected(entry);
setLoadError(null);
setEditorTab('component');
startTransition(() => {
setHarnessSource(harnessForModule(entry.importPath));
});
void fetchComponentSource(entry.filePath)
.then((text) => setComponentSource(text))
.catch((err) => {
setComponentSource('');
setLoadError(err instanceof Error ? err.message : String(err));
});
};
const liveReady = matrix.mode === 'live' && matrix.status === 'ready' && matrix.client;
const previewClient = liveReady ? matrix.client! : undefined;
const previewRoom = liveReady ? matrix.room ?? undefined : undefined;
const editorValue = editorTab === 'component' ? componentSource : harnessSource;
const editorPath =
editorTab === 'component' && selected
? selected.filePath
: 'playground-live.tsx';
return (
<div className="app">
<header className="topbar">
<div>
<strong>Component Playground</strong>
<span className="muted">
{' '}
Component tab edits real source · Harness tab only mounts it
</span>
</div>
<div className="topbar-actions">
<button
type="button"
className="ghost"
onClick={() => {
setSelected(null);
setComponentSource('');
setEditorTab('harness');
setHarnessSource(DEFAULT_SOURCE);
}}
>
Reset message preview
</button>
</div>
</header>
<div className="conn-bar">
<div className="conn-modes">
<button
type="button"
className={matrix.mode === 'mock' ? 'conn-btn active' : 'conn-btn'}
onClick={() => matrix.useMocks()}
>
Mocks
</button>
<button
type="button"
className={matrix.mode === 'live' ? 'conn-btn active' : 'conn-btn'}
disabled={matrix.status === 'connecting'}
onClick={() => {
void matrix.connectLive();
}}
title={
matrix.hasPaarrotSession
? `Use logged-in session ${matrix.paarrotUserId}`
: 'Needs Paarrot login on this origin, or a pasted token'
}
>
{matrix.status === 'connecting' ? 'Connecting…' : 'Paarrot session'}
</button>
<button
type="button"
className="conn-btn"
onClick={() => setShowTokenForm((v) => !v)}
>
Token
</button>
</div>
<div className="conn-status">
{matrix.mode === 'mock' && <span className="muted">Using lightweight mocks</span>}
{matrix.mode === 'live' && matrix.status === 'connecting' && (
<span className="muted">Syncing (memory store, no crypto)</span>
)}
{matrix.mode === 'live' && matrix.status === 'ready' && (
<span className="conn-ok">{matrix.sessionLabel}</span>
)}
{matrix.mode === 'live' && matrix.status === 'error' && (
<span className="conn-err">{matrix.error}</span>
)}
{!matrix.hasPaarrotSession && matrix.mode !== 'live' && (
<span className="muted">Tip: log into Paarrot at / first, then hit Paarrot session</span>
)}
</div>
{liveReady && matrix.rooms.length > 0 && (
<label className="conn-room">
Room
<select
value={matrix.room?.roomId ?? ''}
onChange={(e) => matrix.selectRoom(e.target.value)}
>
{matrix.rooms.map((r) => (
<option key={r.roomId} value={r.roomId}>
{r.name || r.roomId}
</option>
))}
</select>
</label>
)}
</div>
{showTokenForm && (
<form
className="token-form"
onSubmit={(e) => {
e.preventDefault();
setShowTokenForm(false);
void matrix.connectLive(manual);
}}
>
<input
placeholder="Homeserver URL"
value={manual.baseUrl}
onChange={(e) => setManual((m) => ({ ...m, baseUrl: e.target.value }))}
/>
<input
placeholder="@user:server"
value={manual.userId}
onChange={(e) => setManual((m) => ({ ...m, userId: e.target.value }))}
/>
<input
placeholder="Device ID"
value={manual.deviceId}
onChange={(e) => setManual((m) => ({ ...m, deviceId: e.target.value }))}
/>
<input
placeholder="Access token"
value={manual.accessToken}
onChange={(e) => setManual((m) => ({ ...m, accessToken: e.target.value }))}
/>
<button type="submit" className="ghost">
Connect
</button>
</form>
)}
<div className="panes">
<aside className="pane catalog-pane">
<div className="pane-label">Cinny modules ({filtered.length})</div>
<input
className="filter"
placeholder="Filter components…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<div className="catalog-list">
{filtered.map((entry) => (
<button
key={entry.id}
type="button"
className={selected?.id === entry.id ? 'catalog-item active' : 'catalog-item'}
onClick={() => openEntry(entry)}
title={entry.filePath}
>
{entry.label}
</button>
))}
</div>
</aside>
<section className="pane editor-pane">
<div className="pane-label editor-tabs">
<button
type="button"
className={editorTab === 'component' ? 'tab active' : 'tab'}
disabled={!selected}
onClick={() => setEditorTab('component')}
title={selected ? `Edit ${selected.filePath}` : 'Pick a module first'}
>
Component
</button>
<button
type="button"
className={editorTab === 'harness' ? 'tab active' : 'tab'}
onClick={() => setEditorTab('harness')}
>
Harness
</button>
<span className="muted tab-hint">
{editorTab === 'component' && selected
? `writes ${selected.filePath}`
: 'mount-only · not saved'}
</span>
</div>
{loadError && <pre className="error-block editor-error">{loadError}</pre>}
<Editor
height="100%"
defaultLanguage="typescript"
path={editorPath}
theme="vs-dark"
value={editorValue}
onChange={(value) => {
const next = value ?? '';
if (editorTab === 'component') setComponentSource(next);
else setHarnessSource(next);
}}
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
scrollBeyondLastLine: false,
wordWrap: 'on',
tabSize: 2,
automaticLayout: true,
padding: { top: 12 },
readOnly: editorTab === 'component' && !selected,
}}
beforeMount={(monaco) => {
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
target: monaco.languages.typescript.ScriptTarget.ES2020,
allowNonTsExtensions: true,
esModuleInterop: true,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
paths: {
'@cinny/*': ['*'],
'@playground/mocks': ['*'],
},
});
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: false,
});
}}
/>
</section>
<section className="pane preview-pane">
<div className="pane-label">Preview</div>
<div className="preview-body">
<ResizableStage>
<PlaygroundProviders client={previewClient} room={previewRoom}>
<PreviewErrorBoundary resetKey={`${deferredHarness}:${selected?.filePath ?? ''}`}>
<LivePreview />
</PreviewErrorBoundary>
</PlaygroundProviders>
</ResizableStage>
</div>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
/**
* Dynamically remounts the Vite virtual live module when the harness changes.
*/
export function LivePreview() {
const [revision, setRevision] = useState(0);
const [Comp, setComp] = useState<React.ComponentType | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onUpdate = () => setRevision((n) => n + 1);
const handler = () => onUpdate();
// Vite custom event from liveTsxPlugin
if (import.meta.hot) {
import.meta.hot.on('playground:live-updated', handler);
}
return () => {
if (import.meta.hot) {
import.meta.hot.off('playground:live-updated', handler);
}
};
}, []);
useEffect(() => {
let cancelled = false;
setError(null);
setComp(null);
import(/* @vite-ignore */ `/@playground/live.tsx?t=${revision}`)
.then((mod) => {
if (cancelled) return;
const candidate = mod.default;
if (typeof candidate !== 'function') {
setError('Live module must `export default` a React component.');
return;
}
setComp(() => candidate);
})
.catch((err: unknown) => {
if (cancelled) return;
const message =
err instanceof Error
? `${err.message}\n\n${err.stack ?? ''}`
: String(err);
setError(message);
});
return () => {
cancelled = true;
};
}, [revision]);
if (error) return <pre className="error-block">{error}</pre>;
if (!Comp) return <div className="muted">Compiling harness</div>;
return <Comp />;
}

View File

@@ -0,0 +1,40 @@
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
type Props = {
resetKey: string;
children: ReactNode;
};
type State = {
error: string | null;
};
export class PreviewErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return {
error: `${error.message}\n\n${error.stack ?? ''}`,
};
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[playground preview]', error, info);
this.setState({
error: `${error.message}\n\n${error.stack ?? ''}\n\n${info.componentStack ?? ''}`,
});
}
componentDidUpdate(prevProps: Props) {
if (prevProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null });
}
}
render() {
if (this.state.error) {
return <pre className="error-block">{this.state.error}</pre>;
}
return this.props.children;
}
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react';
type Props = {
children: React.ReactNode;
};
const MIN = 160;
const MAX = 1200;
export function ResizableStage({ children }: Props) {
const [size, setSize] = useState({ width: 520, height: 480 });
const dragRef = useRef<{
edge: 'e' | 's' | 'se';
startX: number;
startY: number;
startW: number;
startH: number;
} | null>(null);
const onPointerMove = useCallback((e: PointerEvent) => {
const drag = dragRef.current;
if (!drag) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
setSize((prev) => {
let width = prev.width;
let height = prev.height;
if (drag.edge === 'e' || drag.edge === 'se') {
width = clamp(drag.startW + dx, MIN, MAX);
}
if (drag.edge === 's' || drag.edge === 'se') {
height = clamp(drag.startH + dy, MIN, MAX);
}
return { width, height };
});
}, []);
const onPointerUp = useCallback(() => {
dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
}, [onPointerMove]);
const startDrag = (edge: 'e' | 's' | 'se') => (e: React.PointerEvent) => {
e.preventDefault();
dragRef.current = {
edge,
startX: e.clientX,
startY: e.clientY,
startW: size.width,
startH: size.height,
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
};
useEffect(() => () => onPointerUp(), [onPointerUp]);
const stageStyle: CSSProperties = {
width: size.width,
height: size.height,
};
return (
<div className="stage-wrap">
<div className="stage-meta">
{size.width} × {size.height}
</div>
<div className="stage" style={stageStyle}>
<div className="stage-canvas">{children}</div>
<button type="button" className="handle handle-e" aria-label="Resize width" onPointerDown={startDrag('e')} />
<button type="button" className="handle handle-s" aria-label="Resize height" onPointerDown={startDrag('s')} />
<button
type="button"
className="handle handle-se"
aria-label="Resize both"
onPointerDown={startDrag('se')}
/>
</div>
</div>
);
}
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}

128
src/playground/catalog.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Path list of Cinny UI modules the playground can open.
*/
const componentModules = import.meta.glob(
[
'../app/components/**/*.{tsx,ts}',
'../app/features/**/*.{tsx,ts}',
],
{ eager: false }
);
export type CatalogEntry = {
id: string;
label: string;
importPath: string;
/** Path under cinny/, e.g. src/app/components/.../Foo.tsx */
filePath: string;
};
function toFilePath(viteKey: string): string | null {
const normalized = viteKey.replace(/\\/g, '/');
let rel = normalized;
const appIdx = rel.indexOf('/app/');
if (appIdx !== -1) {
rel = `src${rel.slice(appIdx)}`;
} else if (rel.startsWith('../app/')) {
rel = `src/${rel.slice('../'.length)}`;
} else if (rel.startsWith('../')) {
rel = `src/app/${rel.slice('../'.length)}`;
} else {
return null;
}
if (!/\.(tsx|ts)$/.test(rel)) return null;
return rel;
}
function toImportPath(viteKey: string): string | null {
const normalized = viteKey.replace(/\\/g, '/');
let rel = normalized;
const appIdx = rel.indexOf('/app/');
if (appIdx !== -1) {
rel = rel.slice(appIdx + '/app/'.length);
} else if (rel.startsWith('../')) {
rel = rel.replace(/^\.\.\//, '');
}
rel = rel.replace(/\.(tsx|ts)$/, '');
if (rel.endsWith('.css') || rel.includes('.css')) return null;
return `@cinny/app/${rel}`;
}
function toLabel(importPath: string): string {
return importPath.replace(/^@cinny\//, '');
}
export function getCatalog(): CatalogEntry[] {
const entries: CatalogEntry[] = [];
for (const key of Object.keys(componentModules)) {
if (/\.css(\.ts)?$/.test(key)) continue;
if (/\.test\./.test(key) || /\.spec\./.test(key)) continue;
// Prefer UI modules — plain .ts files are usually helpers/hooks, not preview targets.
if (key.endsWith('.ts') && !key.endsWith('.tsx')) continue;
const importPath = toImportPath(key);
const filePath = toFilePath(key);
if (!importPath || !filePath) continue;
entries.push({
id: key,
label: toLabel(importPath),
importPath,
filePath,
});
}
return entries.sort((a, b) => a.label.localeCompare(b.label));
}
/**
* Generated harness source. Prefer a clean named import the user can edit.
*/
export function harnessForModule(importPath: string): string {
const nameGuess =
importPath
.split('/')
.pop()
?.replace(/[^a-zA-Z0-9_$]/g, '') || 'Component';
if (nameGuess === 'RoomInput') {
return `/**
* Preview harness for ${importPath}
*
* Uses the room from PlaygroundProviders (mock or live Paarrot session).
* Switch to the Component tab to edit the real source.
*/
import { useRef } from 'react';
import { RoomInput } from '${importPath}';
import { useEditor } from '@cinny/app/components/editor';
import { useRoom } from '@cinny/app/hooks/useRoom';
export default function Preview() {
const editor = useEditor();
const dropRef = useRef<HTMLDivElement>(null);
const room = useRoom();
return (
<div ref={dropRef} style={{ width: '100%' }}>
<RoomInput
editor={editor}
fileDropContainerRef={dropRef}
roomId={room.roomId}
room={room}
/>
</div>
);
}
`;
}
return `/**
* Preview harness for ${importPath}
*
* This only mounts the component. To edit labels/strings/markup, switch to the
* Component tab (writes the real source file under src/app/…).
*/
import { ${nameGuess} } from '${importPath}';
export default function Preview() {
return <${nameGuess} />;
}
`;
}

View File

@@ -0,0 +1,43 @@
/**
* Live preview harness — edit freely. Nothing is saved.
*
* - Import any Cinny module via `@cinny/...`
* - Use mocks / PlaygroundProviders from `@playground/mocks`
* - `export default` the component to render in the stage
*/
import { Message } from '@cinny/app/features/room/message';
import { MessageLayout } from '@cinny/app/state/settings';
import { Box, Text } from 'folds';
import { mocks } from '@playground/mocks';
export default function Preview() {
const room = mocks.room;
const mEvent = mocks.event;
return (
<Box direction="Column" gap="200" style={{ width: '100%', maxWidth: 520 }}>
<Text size="T200" priority="300">
Preview harness swap imports to any `@cinny/...` export
</Text>
<Message
room={room}
mEvent={mEvent}
collapse={false}
highlight={false}
messageLayout={MessageLayout.Modern}
messageSpacing="400"
canDelete
canSendReaction
canPinEvent
hour24Clock={false}
dateFormatString="D MMM YYYY"
onUserClick={() => undefined}
onUsernameClick={() => undefined}
onReplyClick={() => undefined}
onReactionToggle={() => undefined}
>
<Text style={{ margin: 0 }}>{mEvent.getContent().body}</Text>
</Message>
</Box>
);
}

View File

@@ -0,0 +1,3 @@
import defaultLive from './defaultLive.tsx?raw';
export const DEFAULT_SOURCE = defaultLive;

View File

@@ -0,0 +1,151 @@
import {
ClientEvent,
createClient,
MemoryStore,
SyncState,
type MatrixClient,
type Room,
} from 'matrix-js-sdk';
import { getCurrentSession, type Session } from '@cinny/app/state/sessions';
export type LiveConnectMode = 'mock' | 'live';
export type ManualSessionInput = {
baseUrl: string;
userId: string;
deviceId: string;
accessToken: string;
};
const MANUAL_SESSION_KEY = 'playgroundManualSession';
/** Session already used by Paarrot/Cinny on this origin (localStorage). */
export function peekPaarrotSession(): Session | undefined {
return getCurrentSession();
}
export function loadManualSession(): ManualSessionInput | null {
try {
const raw = sessionStorage.getItem(MANUAL_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as ManualSessionInput;
if (parsed?.baseUrl && parsed?.userId && parsed?.deviceId && parsed?.accessToken) {
return parsed;
}
} catch {
/* ignore */
}
return null;
}
export function saveManualSession(input: ManualSessionInput | null) {
if (!input) {
sessionStorage.removeItem(MANUAL_SESSION_KEY);
return;
}
sessionStorage.setItem(MANUAL_SESSION_KEY, JSON.stringify(input));
}
function normalizeSession(input: Session | ManualSessionInput): Session {
return {
baseUrl: input.baseUrl.replace(/\/$/, ''),
userId: input.userId,
deviceId: input.deviceId,
accessToken: input.accessToken,
};
}
function resolveSession(explicit?: Session | ManualSessionInput): Session {
if (explicit) return normalizeSession(explicit);
const fromPaarrot = peekPaarrotSession();
if (fromPaarrot) return normalizeSession(fromPaarrot);
const manual = loadManualSession();
if (manual) return normalizeSession(manual);
throw new Error(
'No Paarrot session found. Log in at / (same origin), or paste homeserver + token in the playground.'
);
}
function waitForPrepared(mx: MatrixClient, timeoutMs = 90_000): Promise<void> {
return new Promise((resolve, reject) => {
const finish = (ok: boolean, err?: Error) => {
clearTimeout(timer);
mx.off(ClientEvent.Sync, onSync);
if (ok) resolve();
else reject(err ?? new Error('Sync failed'));
};
const timer = window.setTimeout(
() => finish(false, new Error('Timed out waiting for Matrix sync')),
timeoutMs
);
const onSync = (state: SyncState | null) => {
if (state === SyncState.Prepared || state === SyncState.Syncing) {
finish(true);
}
};
mx.on(ClientEvent.Sync, onSync);
const cur = mx.getSyncState();
if (cur === SyncState.Prepared || cur === SyncState.Syncing) {
finish(true);
}
});
}
/**
* Live Matrix client for the playground.
* Uses MemoryStore so it won't fight Paarrot's IndexedDB sync/crypto stores.
* Skips Rust crypto — encrypted timelines won't decrypt, but profiles/rooms/settings APIs work.
*/
export async function connectLiveMatrixClient(
session?: Session | ManualSessionInput
): Promise<MatrixClient> {
const finalSession = resolveSession(session);
const mx = createClient({
baseUrl: finalSession.baseUrl,
accessToken: finalSession.accessToken,
userId: finalSession.userId,
deviceId: finalSession.deviceId,
store: new MemoryStore({ localStorage: globalThis.localStorage }),
timelineSupport: true,
});
mx.setMaxListeners(50);
await mx.startClient({
lazyLoadMembers: true,
initialSyncLimit: 20,
});
await waitForPrepared(mx);
return mx;
}
export function stopLiveMatrixClient(mx: MatrixClient | null | undefined) {
if (!mx) return;
try {
mx.stopClient();
} catch {
/* ignore */
}
}
export function listJoinedRooms(mx: MatrixClient): Room[] {
return mx
.getRooms()
.filter((r) => r.getMyMembership() === 'join')
.sort((a, b) => (a.name || a.roomId).localeCompare(b.name || b.roomId));
}
export function pickDefaultRoom(mx: MatrixClient, preferredId?: string): Room | undefined {
const rooms = listJoinedRooms(mx);
if (preferredId) {
const hit = rooms.find((r) => r.roomId === preferredId);
if (hit) return hit;
}
return rooms[0];
}

49
src/playground/main.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { StrictMode, Component, type ErrorInfo, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds';
import { App } from './App';
import './styles.css';
document.body.classList.add(configClass, varsClass);
// Playground shares the Cinny origin — drop any SW/caches so stale shims cannot stick.
if ('serviceWorker' in navigator) {
void navigator.serviceWorker.getRegistrations().then((regs) => {
for (const reg of regs) void reg.unregister();
});
}
if (typeof caches !== 'undefined') {
void caches.keys().then((keys) => Promise.all(keys.map((k) => caches.delete(k))));
}
class BootErrorBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
state = { error: null as string | null };
static getDerivedStateFromError(error: Error) {
return { error: error.message };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[playground boot]', error, info);
}
render() {
if (this.state.error) {
return (
<pre style={{ padding: 24, color: '#ffb4b4', whiteSpace: 'pre-wrap' }}>
{this.state.error}
</pre>
);
}
return this.props.children;
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BootErrorBoundary>
<App />
</BootErrorBoundary>
</StrictMode>
);

View File

@@ -0,0 +1,106 @@
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { Provider as JotaiProvider } from 'jotai';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import {
Box,
OverlayContainerProvider,
PopOutContainerProvider,
TooltipContainerProvider,
configClass,
varsClass,
} from 'folds';
import { MatrixClientProvider } from '@cinny/app/hooks/useMatrixClient';
import { RoomProvider, IsDirectRoomProvider } from '@cinny/app/hooks/useRoom';
import { SpecVersionsProvider } from '@cinny/app/hooks/useSpecVersions';
import { DarkTheme, ThemeContextProvider } from '@cinny/app/hooks/useTheme';
import {
PowerLevelsContextProvider,
type IPowerLevels,
} from '@cinny/app/hooks/usePowerLevels';
import { ContainerColor } from '@cinny/app/styles/ContainerColor.css';
import type { MatrixClient, Room } from 'matrix-js-sdk';
import { MOCK_POWER_LEVELS, createMockRoom, mocks } from './matrix';
type Props = {
children: ReactNode;
client?: MatrixClient;
room?: Room;
isDirect?: boolean;
powerLevels?: IPowerLevels;
};
const SPEC_VERSIONS = {
versions: ['v1.11', 'v1.12'],
unstable_features: {
'org.matrix.msc3916.stable': true,
},
};
/**
* Wraps a preview in the providers Cinny UI pieces usually expect.
* Overlay/popout/tooltip portals stay inside the stage (not the full window).
*/
export function PlaygroundProviders({
children,
client,
room,
isDirect = false,
powerLevels = MOCK_POWER_LEVELS as IPowerLevels,
}: Props) {
const [portal, setPortal] = useState<HTMLDivElement | null>(null);
const queryClient = useMemo(() => new QueryClient(), []);
const resolvedClient = client ?? mocks.client;
// Always provide a room with `.client` set — RoomInput/useStateEvent require it.
const resolvedRoom = useMemo(() => {
if (room) return room;
return createMockRoom({ client: resolvedClient });
}, [room, resolvedClient]);
useEffect(() => {
document.body.classList.add(configClass, varsClass, ...DarkTheme.classNames);
}, []);
return (
<div className="preview-frame">
<ThemeContextProvider value={DarkTheme}>
<TooltipContainerProvider value={portal ?? undefined}>
<PopOutContainerProvider value={portal ?? undefined}>
<OverlayContainerProvider value={portal ?? undefined}>
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<MemoryRouter>
<SpecVersionsProvider value={SPEC_VERSIONS}>
<MatrixClientProvider value={resolvedClient}>
<RoomProvider value={resolvedRoom}>
<IsDirectRoomProvider value={isDirect}>
<PowerLevelsContextProvider value={powerLevels}>
<Box
className={ContainerColor({ variant: 'Surface' })}
style={{
width: '100%',
minHeight: '100%',
height: '100%',
padding: 16,
borderRadius: 8,
}}
>
{children}
</Box>
</PowerLevelsContextProvider>
</IsDirectRoomProvider>
</RoomProvider>
</MatrixClientProvider>
</SpecVersionsProvider>
</MemoryRouter>
</JotaiProvider>
</QueryClientProvider>
</OverlayContainerProvider>
</PopOutContainerProvider>
</TooltipContainerProvider>
</ThemeContextProvider>
{/* Host for folds Overlay/Dialog portals — contained by .preview-frame */}
<div className="preview-portal" ref={setPortal} />
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { mocks, createMockMatrixClient, createMockRoom, createMockMatrixEvent, MOCK_POWER_LEVELS } from './matrix';
export { PlaygroundProviders } from './PlaygroundProviders';

View File

@@ -0,0 +1,245 @@
import type { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { MessageEvent, StateEvent } from '@cinny/types/matrix/room';
import type { IPowerLevels } from '@cinny/app/hooks/usePowerLevels';
type AnyFn = (...args: never[]) => unknown;
function fn<T extends AnyFn>(impl?: T): T {
return (impl ?? ((() => undefined) as T)) as T;
}
export const MOCK_USER_ID = '@alice:example.org';
export const MOCK_OTHER_USER_ID = '@bob:example.org';
export const MOCK_ROOM_ID = '!room:example.org';
export const MOCK_EVENT_ID = '$event:example.org';
/** Default power levels — user can send messages / invite. */
export const MOCK_POWER_LEVELS: IPowerLevels = {
users_default: 0,
state_default: 50,
events_default: 0,
invite: 0,
redact: 50,
kick: 50,
ban: 50,
historical: 0,
events: {
'm.room.message': 0,
'm.reaction': 0,
},
users: {
[MOCK_USER_ID]: 100,
},
notifications: {
room: 50,
},
};
function mockStateEvent(type: string, content: Record<string, unknown>, stateKey = ''): MatrixEvent {
return {
getId: () => `$${type}`,
getSender: () => MOCK_USER_ID,
getTs: () => Date.now(),
getType: () => type,
getContent: () => content,
getWireContent: () => content,
isRedacted: () => false,
getStateKey: () => stateKey,
isEncrypted: () => false,
getRoomId: () => MOCK_ROOM_ID,
event: { sender: MOCK_USER_ID },
} as unknown as MatrixEvent;
}
/** Lightweight MatrixEvent stand-in for UI previews. */
export function createMockMatrixEvent(
overrides: Partial<{
id: string;
sender: string;
body: string;
ts: number;
type: string;
redacted: boolean;
content: Record<string, unknown>;
}> = {}
): MatrixEvent {
const id = overrides.id ?? MOCK_EVENT_ID;
const sender = overrides.sender ?? MOCK_OTHER_USER_ID;
const content = {
body: overrides.body ?? 'Hello from the playground preview.',
msgtype: 'm.text',
...(overrides.content ?? {}),
};
const ts = overrides.ts ?? Date.now() - 60_000;
const type = overrides.type ?? MessageEvent.RoomMessage;
const redacted = overrides.redacted ?? false;
return {
getId: () => id,
getSender: () => sender,
getTs: () => ts,
getType: () => type,
getContent: () => (redacted ? {} : content),
getWireContent: () => content,
isRedacted: () => redacted,
getStateKey: () => undefined,
isEncrypted: () => false,
threadRootId: undefined,
getRoomId: () => MOCK_ROOM_ID,
} as unknown as MatrixEvent;
}
/** Lightweight Room stand-in. */
export function createMockRoom(
overrides: Partial<{
roomId: string;
name: string;
client: MatrixClient;
members: Record<string, { displayName?: string; avatarMxc?: string }>;
powerLevels: IPowerLevels;
}> = {}
): Room {
const roomId = overrides.roomId ?? MOCK_ROOM_ID;
const members = {
[MOCK_USER_ID]: { displayName: 'Alice' },
[MOCK_OTHER_USER_ID]: { displayName: 'Bob' },
...(overrides.members ?? {}),
};
const powerLevels = overrides.powerLevels ?? MOCK_POWER_LEVELS;
const stateByType: Record<string, MatrixEvent> = {
[StateEvent.RoomPowerLevels]: mockStateEvent(
StateEvent.RoomPowerLevels,
powerLevels as unknown as Record<string, unknown>
),
[StateEvent.RoomCreate]: mockStateEvent(StateEvent.RoomCreate, {
room_version: '11',
creator: MOCK_USER_ID,
}),
[StateEvent.RoomPinnedEvents]: mockStateEvent(StateEvent.RoomPinnedEvents, { pinned: [] }),
};
const getStateEvents = ((type: string, stateKey?: string) => {
const ev = stateByType[type];
if (stateKey !== undefined) return ev ?? null;
return ev ? [ev] : [];
}) as Room['currentState']['getStateEvents'];
const liveState = {
getStateEvents,
};
const client = overrides.client ?? ({
on: fn(() => undefined),
removeListener: fn(() => undefined),
once: fn(() => undefined),
off: fn(() => undefined),
} as unknown as MatrixClient);
return {
roomId,
name: overrides.name ?? 'Preview Room',
client,
getMyMembership: () => 'join',
getMember: (userId: string) => {
const m = members[userId];
if (!m) return null;
return {
userId,
name: m.displayName ?? userId,
rawDisplayName: m.displayName ?? userId,
getAvatarUrl: () => null,
getMxcAvatarUrl: () => m.avatarMxc ?? null,
};
},
getCanonicalAlias: () => null,
getAltAliases: () => [],
getTimelineForEvent: () => null,
getLiveTimeline: () => ({
getTimelineSet: () => ({ relations: { getChildEventsForEvent: () => null } }),
getEvents: () => [],
getState: () => liveState,
}),
currentState: {
getStateEvents,
},
} as unknown as Room;
}
/** Lightweight MatrixClient stand-in used by MatrixClientProvider. */
export function createMockMatrixClient(
overrides: Partial<{ userId: string }> = {}
): MatrixClient {
const userId = overrides.userId ?? MOCK_USER_ID;
const users = new Map<
string,
{
userId: string;
presence: string;
presenceStatusMsg?: string;
currentlyActive: boolean;
getLastActiveTs: () => number;
on: AnyFn;
removeListener: AnyFn;
once: AnyFn;
off: AnyFn;
}
>();
const ensureUser = (id: string) => {
if (!users.has(id)) {
users.set(id, {
userId: id,
presence: 'online',
currentlyActive: true,
getLastActiveTs: () => Date.now(),
on: fn(() => undefined),
removeListener: fn(() => undefined),
once: fn(() => undefined),
off: fn(() => undefined),
});
}
return users.get(id)!;
};
ensureUser(userId);
ensureUser(MOCK_OTHER_USER_ID);
const client = {
getUserId: () => userId,
getSafeUserId: () => userId,
getDomain: () => 'example.org',
getUser: (id: string) => ensureUser(id),
getRoom: (id: string | undefined) =>
id ? createMockRoom({ roomId: id, client: client as MatrixClient }) : null,
getRooms: () => [createMockRoom({ client: client as MatrixClient })],
isRoomEncrypted: () => false,
getAccountData: () => undefined,
sendEvent: fn(async () => ({ event_id: '$sent' })),
sendStateEvent: fn(async () => ({ event_id: '$state' })),
redactEvent: fn(async () => ({ event_id: '$redact' })),
reportEvent: fn(async () => ({})),
downloadKeys: fn(async () => ({})),
mxcUrlToHttp: (mxc: string) => mxc,
on: fn(() => undefined),
removeListener: fn(() => undefined),
once: fn(() => undefined),
} as unknown as MatrixClient;
return client;
}
const defaultClient = createMockMatrixClient();
export const mocks = {
userId: MOCK_USER_ID,
otherUserId: MOCK_OTHER_USER_ID,
roomId: MOCK_ROOM_ID,
eventId: MOCK_EVENT_ID,
powerLevels: MOCK_POWER_LEVELS,
client: defaultClient,
room: createMockRoom({ client: defaultClient }),
event: createMockMatrixEvent(),
createClient: createMockMatrixClient,
createRoom: createMockRoom,
createEvent: createMockMatrixEvent,
};

View File

@@ -0,0 +1,12 @@
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
// Vite-friendly Monaco workers (no AMD CDN loader).
self.MonacoEnvironment = {
getWorker(_workerId: string, label: string) {
if (label === 'json') return new jsonWorker();
if (label === 'typescript' || label === 'javascript') return new tsWorker();
return new editorWorker();
},
};

View File

@@ -0,0 +1,65 @@
/**
* Standalone loglevel stand-in for the playground.
* Avoids Vite CJS interop fights with the real package; matrix-js-sdk only needs
* getLogger / levels / methodFactory / setLevel / rebuild.
*/
const levels = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
SILENT: 5,
};
const loggers = new Map();
function defaultMethodFactory(methodName) {
return (...args) => {
const supported =
methodName === 'error' ||
methodName === 'warn' ||
methodName === 'trace' ||
methodName === 'info' ||
methodName === 'debug';
if (supported) console[methodName](...args);
else console.log(...args);
};
}
function createLogger(name) {
const logger = {
name,
prefix: undefined,
methodFactory: defaultMethodFactory,
getChild: undefined,
setLevel(_level, _persist) {},
getLevel() {
return levels.WARN;
},
setDefaultLevel() {},
enableAll() {},
disableAll() {},
rebuild() {
for (const methodName of ['trace', 'debug', 'info', 'warn', 'error']) {
logger[methodName] = logger.methodFactory(methodName, levels.DEBUG, name);
}
},
};
logger.rebuild();
// matrix-js-sdk expects console-like `.log` (e.g. logger.log.bind(logger)).
logger.log = logger.info;
return logger;
}
const loglevel = createLogger('root');
loglevel.levels = levels;
loglevel.getLogger = (name) => {
const key = String(name ?? 'default');
if (!loggers.has(key)) loggers.set(key, createLogger(key));
return loggers.get(key);
};
loglevel.methodFactory = defaultMethodFactory;
export default loglevel;

414
src/playground/styles.css Normal file
View File

@@ -0,0 +1,414 @@
:root {
color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
background: #0f1115;
color: #e8eaed;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
.app {
height: 100%;
display: grid;
grid-template-rows: auto auto auto 1fr;
}
.conn-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
padding: 8px 14px;
border-bottom: 1px solid #232833;
background: #12161d;
}
.conn-modes {
display: flex;
gap: 6px;
}
.conn-btn {
border: 1px solid #2f3640;
background: transparent;
color: #c7ced8;
border-radius: 8px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
}
.conn-btn:hover:not(:disabled) {
background: #1c2230;
}
.conn-btn.active {
background: #243147;
border-color: #3d7eff;
color: #fff;
}
.conn-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.conn-status {
flex: 1;
min-width: 180px;
font-size: 12px;
}
.conn-ok {
color: #7dcea0;
}
.conn-err {
color: #ffb4b4;
}
.conn-room {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #9aa3af;
}
.conn-room select {
max-width: 240px;
border: 1px solid #2f3640;
border-radius: 8px;
background: #0c0e12;
color: #e8eaed;
padding: 5px 8px;
}
.token-form {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr)) auto;
gap: 8px;
padding: 8px 14px 10px;
border-bottom: 1px solid #232833;
background: #0f131a;
}
.token-form input {
border: 1px solid #2f3640;
border-radius: 8px;
background: #0c0e12;
color: #e8eaed;
padding: 7px 10px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (max-width: 1100px) {
.token-form {
grid-template-columns: 1fr;
}
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #232833;
background: #141820;
}
.topbar-actions {
display: flex;
gap: 8px;
}
.muted {
color: #9aa3af;
font-size: 13px;
}
.ghost {
border: 1px solid #2f3640;
background: transparent;
color: #e8eaed;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
}
.ghost:hover {
background: #1c2230;
}
.panes {
display: grid;
grid-template-columns: minmax(200px, 280px) 1.2fr 1fr;
min-height: 0;
}
.pane {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
border-right: 1px solid #232833;
}
.pane:last-child {
border-right: 0;
}
.pane-label {
padding: 8px 12px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8b949e;
border-bottom: 1px solid #232833;
background: #12161d;
}
.catalog-pane {
grid-template-rows: auto auto 1fr;
background: #10141b;
}
.filter {
margin: 8px 10px;
border: 1px solid #2f3640;
border-radius: 8px;
background: #0c0e12;
color: #e8eaed;
padding: 8px 10px;
font-size: 13px;
}
.catalog-list {
overflow: auto;
padding: 4px 6px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.catalog-item {
text-align: left;
border: 0;
background: transparent;
color: #c7ced8;
border-radius: 6px;
padding: 7px 8px;
font-size: 12px;
cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.catalog-item:hover {
background: #1a2130;
}
.catalog-item.active {
background: #243147;
color: #fff;
}
.editor-pane {
background: #1e1e1e;
grid-template-rows: auto 1fr;
}
.editor-tabs {
display: flex;
align-items: center;
gap: 8px;
text-transform: none;
letter-spacing: 0;
}
.tab {
border: 1px solid #2f3640;
background: transparent;
color: #9aa3af;
border-radius: 6px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tab:hover:not(:disabled) {
background: #1c2230;
color: #e8eaed;
}
.tab.active {
background: #243147;
border-color: #3d7eff;
color: #fff;
}
.tab:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tab-hint {
margin-left: auto;
font-size: 11px;
text-transform: none;
letter-spacing: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 55%;
}
.editor-error {
margin: 8px 12px;
max-width: none;
}
.preview-pane {
background: #0f1115;
}
.preview-body {
overflow: auto;
padding: 24px;
display: grid;
place-items: center;
}
.stage-wrap {
display: grid;
gap: 8px;
justify-items: center;
}
.stage-meta {
font-size: 12px;
color: #8b949e;
font-variant-numeric: tabular-nums;
}
.stage {
position: relative;
background:
linear-gradient(45deg, #161a22 25%, transparent 25%),
linear-gradient(-45deg, #161a22 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #161a22 75%),
linear-gradient(-45deg, transparent 75%, #161a22 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
background-color: #0c0e12;
border: 1px solid #2a3140;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
/* Contain folds position:fixed overlays inside the stage. */
transform: translateZ(0);
overflow: hidden;
}
.stage-canvas {
width: 100%;
height: 100%;
overflow: auto;
padding: 12px;
display: grid;
place-items: stretch;
align-content: stretch;
}
.preview-frame {
position: relative;
width: 100%;
height: 100%;
min-height: 200px;
/* Nested containing block so Overlay fixed coords map to the frame. */
transform: translateZ(0);
overflow: hidden;
border-radius: 8px;
}
.preview-portal {
position: absolute;
inset: 0;
z-index: 30;
pointer-events: none;
}
.preview-portal > * {
pointer-events: auto;
}
.handle {
position: absolute;
padding: 0;
border: 0;
background: #3d7eff;
opacity: 0.85;
}
.handle:hover {
opacity: 1;
}
.handle-e {
top: 12px;
right: -4px;
width: 8px;
height: calc(100% - 24px);
cursor: ew-resize;
border-radius: 999px;
}
.handle-s {
left: 12px;
bottom: -4px;
height: 8px;
width: calc(100% - 24px);
cursor: ns-resize;
border-radius: 999px;
}
.handle-se {
right: -5px;
bottom: -5px;
width: 12px;
height: 12px;
cursor: nwse-resize;
border-radius: 3px;
}
.error-block {
margin: 0;
max-width: 560px;
white-space: pre-wrap;
word-break: break-word;
padding: 14px 16px;
border-radius: 10px;
background: #2a1215;
border: 1px solid #5c1f28;
color: #ffb4b4;
font-size: 13px;
line-height: 1.45;
}
@media (max-width: 1100px) {
.panes {
grid-template-columns: 1fr;
grid-template-rows: 180px 1fr 1fr;
}
}

View File

@@ -0,0 +1,123 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MatrixClient, Room } from 'matrix-js-sdk';
import {
connectLiveMatrixClient,
listJoinedRooms,
loadManualSession,
peekPaarrotSession,
pickDefaultRoom,
saveManualSession,
stopLiveMatrixClient,
type LiveConnectMode,
type ManualSessionInput,
} from './liveClient';
type LiveState = {
mode: LiveConnectMode;
status: 'idle' | 'connecting' | 'ready' | 'error';
error: string | null;
client: MatrixClient | null;
room: Room | null;
rooms: Room[];
sessionLabel: string | null;
};
const initial: LiveState = {
mode: 'mock',
status: 'idle',
error: null,
client: null,
room: null,
rooms: [],
sessionLabel: null,
};
export function usePlaygroundMatrix() {
const [state, setState] = useState<LiveState>(initial);
const clientRef = useRef<MatrixClient | null>(null);
const roomIdRef = useRef<string | undefined>(undefined);
const paarrotSession = peekPaarrotSession();
const hasPaarrotSession = Boolean(paarrotSession);
const hasManualSession = Boolean(loadManualSession());
const teardown = useCallback(() => {
stopLiveMatrixClient(clientRef.current);
clientRef.current = null;
}, []);
useEffect(() => () => teardown(), [teardown]);
const selectRoom = useCallback((roomId: string) => {
roomIdRef.current = roomId;
setState((prev) => {
if (!prev.client) return prev;
const room = pickDefaultRoom(prev.client, roomId) ?? null;
return { ...prev, room };
});
}, []);
const connectLive = useCallback(
async (manual?: ManualSessionInput) => {
teardown();
setState((prev) => ({
...prev,
mode: 'live',
status: 'connecting',
error: null,
client: null,
room: null,
rooms: [],
sessionLabel: null,
}));
try {
if (manual) saveManualSession(manual);
const mx = await connectLiveMatrixClient(manual);
clientRef.current = mx;
const rooms = listJoinedRooms(mx);
const room = pickDefaultRoom(mx, roomIdRef.current) ?? null;
if (room) roomIdRef.current = room.roomId;
const hs =
peekPaarrotSession()?.baseUrl ??
loadManualSession()?.baseUrl ??
'';
setState({
mode: 'live',
status: 'ready',
error: null,
client: mx,
room,
rooms,
sessionLabel: `${mx.getUserId() ?? '?'} · ${hs}`,
});
} catch (err) {
clientRef.current = null;
setState({
...initial,
mode: 'live',
status: 'error',
error: err instanceof Error ? err.message : String(err),
});
}
},
[teardown]
);
const useMocks = useCallback(() => {
teardown();
roomIdRef.current = undefined;
setState(initial);
}, [teardown]);
return {
...state,
hasPaarrotSession,
hasManualSession,
paarrotUserId: paarrotSession?.userId ?? null,
paarrotBaseUrl: paarrotSession?.baseUrl ?? null,
connectLive,
useMocks,
selectRoom,
};
}

11
src/playground/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
declare module '*?raw' {
const content: string;
default content;
}
declare module '/@playground/live.tsx' {
const Comp: React.ComponentType;
export default Comp;
}

View File

@@ -58,6 +58,9 @@ export enum MessageEvent {
Sticker = 'm.sticker',
RoomRedaction = 'm.room.redaction',
Reaction = 'm.reaction',
/** Relay emoji confetti burst tied to a target message */
RelayEmojiConfetti = 'app.relay.emoji_confetti',
}
export enum RoomType {

View File

@@ -10,6 +10,9 @@ import { VitePWA } from 'vite-plugin-pwa';
import fs from 'fs';
import path from 'path';
import buildConfig from './build.config';
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
const projectRoot = path.resolve();
const copyFiles = {
targets: [
@@ -217,6 +220,30 @@ export default defineConfig({
publicDir: false,
base: buildConfig.base,
assetsInclude: ['**/*.ogg', '**/*.mp3', '**/*.wav'],
resolve: {
// Keep a single React instance — dual copies cause resolveDispatcher() === null.
dedupe: ['react', 'react-dom'],
alias: [
{ find: '@cinny', replacement: path.resolve(projectRoot, 'src') },
{
find: '@playground/mocks',
replacement: path.resolve(projectRoot, 'src/playground/mocks/index.ts'),
},
// Exact match only — do not rewrite loglevel/lib/...
{
find: /^loglevel$/,
replacement: path.resolve(projectRoot, 'src/playground/shims/loglevel.js'),
},
{
find: /^react$/,
replacement: path.resolve(projectRoot, 'node_modules/react'),
},
{
find: /^react-dom$/,
replacement: path.resolve(projectRoot, 'node_modules/react-dom'),
},
],
},
server: {
port: 38347,
host: '0.0.0.0',
@@ -232,6 +259,7 @@ export default defineConfig({
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
serveGifWorker(),
servePublicAssets(),
liveTsxPlugin(projectRoot, readDefaultLiveSource(projectRoot)),
topLevelAwait({
// The export name of top-level await promise for each chunk module
promiseExportName: '__tla',
@@ -258,6 +286,16 @@ export default defineConfig({
}),
],
optimizeDeps: {
include: [
'react',
'react-dom',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'loglevel',
'matrix-js-sdk',
'@monaco-editor/react',
],
needsInterop: ['loglevel'],
rolldownOptions: {
define: {
global: 'globalThis',
@@ -277,6 +315,10 @@ export default defineConfig({
sourcemap: true,
copyPublicDir: false,
rollupOptions: {
input: {
main: path.resolve(projectRoot, 'index.html'),
playground: path.resolve(projectRoot, 'playground.html'),
},
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
},
},