Compare commits
13 Commits
61d41900cc
...
0da4f0d6cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 0da4f0d6cb | |||
| ea7642f0bb | |||
| 0fb3da20b9 | |||
| e460dbd34e | |||
| 0de2fd942f | |||
| 2603ca8e5c | |||
| 99a294791e | |||
| f62200ecd1 | |||
| b52926f7d8 | |||
| d338e1c35e | |||
| 9589680a81 | |||
| 27f1357fc0 | |||
| 9509a9705e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ devAssets
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.cache
|
||||
|
||||
@@ -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
41
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
149
playground-liveTsxPlugin.ts
Normal 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
12
playground.html
Normal 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>
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: trimmedCustomBody,
|
||||
})}
|
||||
{edited && <MessageEditedContent />}
|
||||
<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,
|
||||
|
||||
@@ -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))`,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -1,95 +1,149 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const CheckButtonContainer = style({
|
||||
const indeterminate = keyframes({
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(250%)' },
|
||||
});
|
||||
|
||||
export const IdleSlot = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
height: '100%',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const CheckButton = style({
|
||||
export const GhostCheck = style({
|
||||
all: 'unset',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
width: toRem(28),
|
||||
height: toRem(28),
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
color: color.Secondary.Main,
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'opacity 0.2s, background-color 0.15s',
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s ease, background-color 0.15s ease',
|
||||
selectors: {
|
||||
'&[data-visible="true"]': {
|
||||
opacity: 0.7,
|
||||
opacity: 0.65,
|
||||
},
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
},
|
||||
|
||||
':hover': {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
':active': {
|
||||
backgroundColor: color.Surface.ContainerActive,
|
||||
},
|
||||
|
||||
':focus-visible': {
|
||||
outline: `2px solid ${color.Secondary.Main}`,
|
||||
outlineOffset: '2px',
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const UpdateButton = style({
|
||||
all: 'unset',
|
||||
export const Bar = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
gap: config.space.S200,
|
||||
height: toRem(22),
|
||||
minWidth: toRem(120),
|
||||
maxWidth: toRem(200),
|
||||
padding: `0 ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
color: color.Success.Main,
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'background-color 0.15s',
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
':hover': {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
export const BarTrack = style({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
height: toRem(4),
|
||||
borderRadius: toRem(2),
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.Surface.ContainerLine,
|
||||
minWidth: toRem(64),
|
||||
});
|
||||
|
||||
':active': {
|
||||
backgroundColor: color.Surface.ContainerActive,
|
||||
},
|
||||
export const BarFill = style({
|
||||
position: 'absolute',
|
||||
inset: '0 auto 0 0',
|
||||
height: '100%',
|
||||
borderRadius: 'inherit',
|
||||
backgroundColor: color.Success.Main,
|
||||
transition: 'width 120ms linear',
|
||||
});
|
||||
|
||||
':focus-visible': {
|
||||
outline: `2px solid ${color.Success.Main}`,
|
||||
outlineOffset: '2px',
|
||||
export const BarIndeterminate = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '40%',
|
||||
borderRadius: 'inherit',
|
||||
backgroundColor: color.Secondary.Main,
|
||||
animation: `${indeterminate} 1s ease-in-out infinite`,
|
||||
});
|
||||
|
||||
export const BarLabel = style({
|
||||
flexShrink: 0,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
opacity: 0.9,
|
||||
minWidth: toRem(28),
|
||||
textAlign: 'right',
|
||||
});
|
||||
|
||||
export const Chip = style({
|
||||
height: toRem(22),
|
||||
maxWidth: toRem(260),
|
||||
padding: `0 ${config.space.S100} 0 ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.Success.Container,
|
||||
color: color.Success.OnContainer,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
flexShrink: 0,
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const ChipText = style({
|
||||
maxWidth: toRem(140),
|
||||
});
|
||||
|
||||
export const ChipAction = style({
|
||||
all: 'unset',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
height: toRem(18),
|
||||
padding: `0 ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.Success.Main,
|
||||
color: color.Success.OnMain,
|
||||
fontSize: toRem(11),
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
filter: 'brightness(1.05)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const UpdateMenu = style({
|
||||
minWidth: toRem(280),
|
||||
maxWidth: toRem(320),
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
boxShadow: config.shadow.E400,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const ProgressText = style({
|
||||
color: color.Success.Main,
|
||||
fontWeight: 500,
|
||||
minWidth: toRem(35),
|
||||
textAlign: 'center',
|
||||
export const ChipDismiss = style({
|
||||
all: 'unset',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(18),
|
||||
height: toRem(18),
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
fontSize: toRem(14),
|
||||
lineHeight: 1,
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,138 +1,136 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import * as css from './UpdateNotification.css';
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string;
|
||||
releaseNotes?: string;
|
||||
releaseDate?: string;
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
percent: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'ready';
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [phase, setPhase] = useState<UpdaterPhase>('idle');
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [updateReady, setUpdateReady] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
|
||||
const [isMock, setIsMock] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're in Electron environment
|
||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
||||
return;
|
||||
const electron = (window as any).electron;
|
||||
if (!electron?.updater) return undefined;
|
||||
|
||||
const { updater } = electron;
|
||||
|
||||
updater.isMock?.().then((result: { success?: boolean; data?: { mock?: boolean } }) => {
|
||||
if (result?.data?.mock) setIsMock(true);
|
||||
}).catch(() => {});
|
||||
|
||||
updater.onUpdateAvailable((info: UpdateInfo) => {
|
||||
setUpdateInfo(info);
|
||||
if (info.mock) setIsMock(true);
|
||||
setPhase('available');
|
||||
setDownloadProgress(0);
|
||||
});
|
||||
|
||||
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
||||
setPhase('downloading');
|
||||
setDownloadProgress(Math.min(100, Math.round(progress.percent)));
|
||||
});
|
||||
|
||||
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
||||
setUpdateInfo(info);
|
||||
setPhase('ready');
|
||||
setDownloadProgress(100);
|
||||
});
|
||||
|
||||
const onNotAvailable = () => {
|
||||
setPhase('idle');
|
||||
setUpdateInfo(null);
|
||||
setDownloadProgress(0);
|
||||
};
|
||||
// Optional channel — ignore if preload doesn't expose a dedicated listener
|
||||
if (electron.updater.onUpdateNotAvailable) {
|
||||
electron.updater.onUpdateNotAvailable(onNotAvailable);
|
||||
}
|
||||
|
||||
const { updater } = (window as any).electron;
|
||||
|
||||
// Listen for update available
|
||||
updater.onUpdateAvailable((info: UpdateInfo) => {
|
||||
console.log('Update available:', info.version);
|
||||
setUpdateAvailable(true);
|
||||
setUpdateInfo(info);
|
||||
setDownloading(false);
|
||||
setUpdateReady(false);
|
||||
setChecking(false);
|
||||
});
|
||||
|
||||
// Listen for download progress
|
||||
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
||||
setDownloadProgress(Math.round(progress.percent));
|
||||
});
|
||||
|
||||
// Listen for update downloaded
|
||||
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
||||
console.log('Update downloaded:', info.version);
|
||||
setDownloading(false);
|
||||
setUpdateReady(true);
|
||||
});
|
||||
|
||||
// Cleanup - IPC listeners don't need manual cleanup in this case
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
setChecking(true);
|
||||
const handleCheck = useCallback(async () => {
|
||||
setPhase('checking');
|
||||
try {
|
||||
const result = await (window as any).electron.updater.checkForUpdates();
|
||||
console.log('Update check result:', result);
|
||||
|
||||
// Handle error response (including dev mode error)
|
||||
if (!result.success) {
|
||||
console.warn('Update check failed:', result.error);
|
||||
setChecking(false);
|
||||
return;
|
||||
if (!result?.success) {
|
||||
setPhase('idle');
|
||||
}
|
||||
|
||||
// If no update found, show feedback briefly
|
||||
setTimeout(() => {
|
||||
if (!updateAvailable) {
|
||||
setChecking(false);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
setChecking(false);
|
||||
// available event will advance phase; if nothing comes, fall back
|
||||
window.setTimeout(() => {
|
||||
setPhase((current) => (current === 'checking' ? 'idle' : current));
|
||||
}, 4000);
|
||||
} catch {
|
||||
setPhase('idle');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
const handleDownload = useCallback(async () => {
|
||||
setPhase('downloading');
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
await (window as any).electron.updater.downloadUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to download update:', error);
|
||||
setDownloading(false);
|
||||
} catch {
|
||||
setPhase('available');
|
||||
}
|
||||
setMenuAnchor(undefined);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
const handleInstall = useCallback(async () => {
|
||||
try {
|
||||
await (window as any).electron.updater.installUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to install update:', error);
|
||||
if (isMock) {
|
||||
setPhase('idle');
|
||||
setUpdateInfo(null);
|
||||
setDownloadProgress(0);
|
||||
}
|
||||
} catch {
|
||||
// keep ready state
|
||||
}
|
||||
};
|
||||
}, [isMock]);
|
||||
|
||||
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (menuAnchor) {
|
||||
setMenuAnchor(undefined);
|
||||
} else {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
|
||||
}
|
||||
};
|
||||
const handleDismiss = useCallback(() => {
|
||||
setPhase('idle');
|
||||
setUpdateInfo(null);
|
||||
setDownloadProgress(0);
|
||||
}, []);
|
||||
|
||||
// Don't render anything if not in Electron
|
||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show check button if no update status
|
||||
if (!updateAvailable && !updateReady && !checking) {
|
||||
if (phase === 'idle') {
|
||||
return (
|
||||
<div
|
||||
className={css.CheckButtonContainer}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={css.IdleSlot}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<button
|
||||
className={css.CheckButton}
|
||||
data-visible={isHovered}
|
||||
onClick={handleCheckForUpdates}
|
||||
aria-label="Check for updates"
|
||||
title="Check for updates"
|
||||
type="button"
|
||||
className={css.GhostCheck}
|
||||
data-visible={hovered || isMock ? 'true' : undefined}
|
||||
onClick={handleCheck}
|
||||
title={isMock ? 'Check for updates (mock)' : 'Check for updates'}
|
||||
aria-label="Check for updates"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
||||
stroke="currentColor"
|
||||
@@ -140,109 +138,75 @@ export function UpdateNotification() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 14H13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show checking state
|
||||
if (checking) {
|
||||
if (phase === 'checking') {
|
||||
return (
|
||||
<button className={css.UpdateButton} disabled type="button">
|
||||
<Spinner variant="Secondary" size="50" />
|
||||
</button>
|
||||
<div className={css.Bar} title="Checking for updates…">
|
||||
<div className={css.BarTrack}>
|
||||
<div className={css.BarIndeterminate} />
|
||||
</div>
|
||||
<Text className={css.BarLabel} size="L400">
|
||||
Checking…
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show update available/ready state
|
||||
if (!updateAvailable && !updateReady) {
|
||||
return null;
|
||||
if (phase === 'downloading') {
|
||||
return (
|
||||
<div
|
||||
className={css.Bar}
|
||||
title={`Downloading update${updateInfo ? ` ${updateInfo.version}` : ''}… ${downloadProgress}%`}
|
||||
>
|
||||
<div className={css.BarTrack}>
|
||||
<div className={css.BarFill} style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<Text className={css.BarLabel} size="L400">
|
||||
{downloadProgress}%
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
className={css.UpdateButton}
|
||||
onClick={handleMenuToggle}
|
||||
aria-label={updateReady ? 'Update ready' : 'Update available'}
|
||||
title={updateReady ? 'Update downloaded - click to install' : 'New version available'}
|
||||
type="button"
|
||||
>
|
||||
{downloading ? (
|
||||
<Spinner variant="Secondary" size="50" />
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 14H13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
className={css.ChipDismiss}
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss update"
|
||||
title="Dismiss"
|
||||
>
|
||||
{null}
|
||||
</PopOut>
|
||||
</>
|
||||
×
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||
import { useRooms } from '../../state/hooks/roomList';
|
||||
import { useRooms, useDirects } from '../../state/hooks/roomList';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||
@@ -53,7 +53,13 @@ export function MessageSearch({
|
||||
}: MessageSearchProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const nonDirectRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const directRooms = useDirects(mx, allRoomsAtom, mDirects);
|
||||
// Include DMs — previously useRooms alone stripped them and broke DM search
|
||||
const allRooms = useMemo(
|
||||
() => [...nonDirectRooms, ...directRooms],
|
||||
[nonDirectRooms, directRooms]
|
||||
);
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
ISearchRequestBody,
|
||||
ISearchResponse,
|
||||
ISearchResult,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
SearchOrderBy,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
@@ -26,6 +29,12 @@ export type SearchResult = {
|
||||
groups: ResultGroup[];
|
||||
};
|
||||
|
||||
const EMPTY_CONTEXT: IResultContext = {
|
||||
events_before: [],
|
||||
events_after: [],
|
||||
profile_info: {},
|
||||
};
|
||||
|
||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
||||
const roomEvents = result.search_categories.room_events;
|
||||
|
||||
const searchResult: SearchResult = {
|
||||
return {
|
||||
nextToken: roomEvents?.next_batch,
|
||||
highlights: roomEvents?.highlights ?? [],
|
||||
groups: groupSearchResult(roomEvents?.results ?? []),
|
||||
};
|
||||
};
|
||||
|
||||
return searchResult;
|
||||
const eventToSearchEvent = (event: MatrixEvent, roomId: string): IEventWithRoomId | undefined => {
|
||||
const eventId = event.getId();
|
||||
if (!eventId) return undefined;
|
||||
|
||||
const content = event.getClearContent() ?? event.getContent();
|
||||
return {
|
||||
event_id: eventId,
|
||||
type: event.getWireType() === 'm.room.encrypted' ? 'm.room.message' : event.getType(),
|
||||
sender: event.getSender() ?? '',
|
||||
origin_server_ts: event.getTs(),
|
||||
content,
|
||||
room_id: roomId,
|
||||
unsigned: event.getUnsigned(),
|
||||
};
|
||||
};
|
||||
|
||||
const getSearchableBody = (event: MatrixEvent): string | undefined => {
|
||||
if (event.isRedacted()) return undefined;
|
||||
|
||||
// After decryption, clear content is available even if wire type was encrypted
|
||||
const content = event.getClearContent() ?? event.getContent();
|
||||
if (!content || typeof content !== 'object') return undefined;
|
||||
|
||||
// Skip still-encrypted payloads
|
||||
if (event.isEncrypted() && !event.isDecryptionFailure() && !event.getClearContent()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const msgType = content.msgtype;
|
||||
if (msgType && msgType !== 'm.text' && msgType !== 'm.notice' && msgType !== 'm.emote') {
|
||||
// Still allow filename / body on media
|
||||
}
|
||||
|
||||
const body = typeof content.body === 'string' ? content.body : undefined;
|
||||
const formatted =
|
||||
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
|
||||
return [body, formatted].filter(Boolean).join('\n') || undefined;
|
||||
};
|
||||
|
||||
const highlightsFromTerm = (term: string): string[] =>
|
||||
term
|
||||
.split(/\s+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 1);
|
||||
|
||||
/**
|
||||
* Search decrypted timeline events already loaded for a room.
|
||||
* Server-side search cannot match E2EE message bodies.
|
||||
*/
|
||||
const searchLocalRoomTimeline = (room: Room, term: string): ResultItem[] => {
|
||||
const needle = term.trim().toLowerCase();
|
||||
if (!needle) return [];
|
||||
|
||||
const events = room.getLiveTimeline().getEvents();
|
||||
const matches: ResultItem[] = [];
|
||||
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const event = events[i];
|
||||
const haystack = getSearchableBody(event);
|
||||
if (!haystack || !haystack.toLowerCase().includes(needle)) continue;
|
||||
|
||||
const searchEvent = eventToSearchEvent(event, room.roomId);
|
||||
if (!searchEvent) continue;
|
||||
|
||||
matches.push({
|
||||
rank: 1,
|
||||
event: searchEvent,
|
||||
context: EMPTY_CONTEXT,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
const isEncryptedRoom = (mx: MatrixClient, roomId: string): boolean => {
|
||||
const room = mx.getRoom(roomId);
|
||||
return !!room?.hasEncryptionStateEvent();
|
||||
};
|
||||
|
||||
const searchLocalRooms = (mx: MatrixClient, roomIds: string[], term: string): SearchResult => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
roomIds.forEach((roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
const items = searchLocalRoomTimeline(room, term);
|
||||
if (items.length > 0) {
|
||||
groups.push({ roomId, items });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
highlights: highlightsFromTerm(term),
|
||||
groups,
|
||||
};
|
||||
};
|
||||
|
||||
export type MessageSearchParams = {
|
||||
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
|
||||
rooms?: string[];
|
||||
senders?: string[];
|
||||
};
|
||||
|
||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
const mx = useMatrixClient();
|
||||
const { term, order, rooms, senders } = params;
|
||||
|
||||
const searchMessages = useCallback(
|
||||
async (nextBatch?: string) => {
|
||||
if (!term)
|
||||
if (!term) {
|
||||
return {
|
||||
highlights: [],
|
||||
groups: [],
|
||||
};
|
||||
const limit = 20;
|
||||
}
|
||||
|
||||
const scopedRooms = rooms?.filter(Boolean) ?? [];
|
||||
const encryptedScoped =
|
||||
scopedRooms.length > 0 && scopedRooms.every((roomId) => isEncryptedRoom(mx, roomId));
|
||||
|
||||
// Encrypted-only scope: server cannot see bodies — search loaded timeline locally
|
||||
if (encryptedScoped && !nextBatch) {
|
||||
return searchLocalRooms(mx, scopedRooms, term);
|
||||
}
|
||||
|
||||
const limit = 20;
|
||||
const requestBody: ISearchRequestBody = {
|
||||
search_categories: {
|
||||
room_events: {
|
||||
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
},
|
||||
};
|
||||
|
||||
const r = await mx.search({
|
||||
body: requestBody,
|
||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||
});
|
||||
return parseSearchResult(r);
|
||||
try {
|
||||
const r = await mx.search({
|
||||
body: requestBody,
|
||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||
});
|
||||
const parsed = parseSearchResult(r);
|
||||
|
||||
if (
|
||||
parsed.groups.length === 0 &&
|
||||
!nextBatch &&
|
||||
scopedRooms.length > 0 &&
|
||||
scopedRooms.some((roomId) => isEncryptedRoom(mx, roomId))
|
||||
) {
|
||||
return searchLocalRooms(
|
||||
mx,
|
||||
scopedRooms.filter((id) => isEncryptedRoom(mx, id)),
|
||||
term
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
if (!nextBatch && scopedRooms.length > 0) {
|
||||
return searchLocalRooms(mx, scopedRooms, term);
|
||||
}
|
||||
throw new Error('Message search failed');
|
||||
}
|
||||
},
|
||||
[mx, term, order, rooms, senders]
|
||||
);
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Box, Line } from 'folds';
|
||||
import { decodeRouteParam } from '../../pages/pathUtils';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import { RoomView } from './RoomView';
|
||||
import { MembersDrawer } from './MembersDrawer';
|
||||
import { MediaDrawer } from './room-media-menu';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -16,6 +17,7 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||
import { isForum } from '../../utils/room';
|
||||
import { ForumRoomView } from './ForumRoomView';
|
||||
|
||||
@@ -26,13 +28,18 @@ export function Room() {
|
||||
const mx = useMatrixClient();
|
||||
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [isPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [isMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const markAsRead = useMarkAsRead(mx);
|
||||
const forumRoom = isForum(room);
|
||||
const skinny = screenSize !== ScreenSize.Desktop;
|
||||
const showMediaSolo = skinny && isMediaDrawer;
|
||||
const showRightDrawer =
|
||||
!skinny && (isPeopleDrawer || isMediaDrawer);
|
||||
|
||||
// Update titlebar with current room ID
|
||||
useEffect(() => {
|
||||
@@ -55,11 +62,21 @@ export function Room() {
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<Box grow="Yes">
|
||||
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
|
||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
{!showMediaSolo &&
|
||||
(forumRoom ? (
|
||||
<ForumRoomView room={room} eventId={eventId} />
|
||||
) : (
|
||||
<RoomView room={room} eventId={eventId} />
|
||||
))}
|
||||
{showMediaSolo && <MediaDrawer room={room} solo />}
|
||||
{!showMediaSolo && showRightDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
{isMediaDrawer ? (
|
||||
<MediaDrawer room={room} />
|
||||
) : (
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
fileItems.push({
|
||||
file: f,
|
||||
originalFile: f,
|
||||
encInfo: undefined,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
setSelectedFiles({
|
||||
type: 'PUT',
|
||||
item: fileItems,
|
||||
});
|
||||
await enqueueFiles(files);
|
||||
},
|
||||
[setSelectedFiles, room]
|
||||
[enqueueFiles]
|
||||
);
|
||||
const pickFile = useFilePicker(handleFiles, true);
|
||||
const handlePaste = useFilePasteHandler(handleFiles);
|
||||
@@ -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"
|
||||
|
||||
@@ -9,7 +9,8 @@ export const TimelineFloat = recipe({
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
// Above timeline media carousels / sticky message chrome
|
||||
zIndex: 10,
|
||||
minWidth: 'max-content',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -85,7 +85,6 @@ import {
|
||||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import * as css from './RoomTimeline.css';
|
||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||
@@ -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) {
|
||||
setAtBottom(true);
|
||||
setLatestUnreadMessage(null);
|
||||
if (document.hasFocus()) {
|
||||
tryAutoMarkAsRead();
|
||||
}
|
||||
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);
|
||||
setTimeline(getInitialTimeline(room));
|
||||
|
||||
// Already on the live timeline: only snap the virtual window to the end.
|
||||
// A full getInitialTimeline() rebuild feels like a chat reload when far back.
|
||||
if (!eventId && liveTimelineLinked) {
|
||||
setTimeline((ct) => {
|
||||
const evLength = getTimelinesEventsCount(ct.linkedTimelines);
|
||||
return {
|
||||
linkedTimelines: ct.linkedTimelines,
|
||||
range: {
|
||||
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||
end: evLength,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setTimeline(getInitialTimeline(room));
|
||||
}
|
||||
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
scrollToBottomRef.current.force = true;
|
||||
};
|
||||
|
||||
const handleReturnToPrevious = () => {
|
||||
if (!returnToEventId) return;
|
||||
const targetId = returnToEventId;
|
||||
setReturnToEventId(null);
|
||||
handleOpenEvent(targetId);
|
||||
};
|
||||
|
||||
const handleClearReturnToPrevious = () => {
|
||||
setReturnToEventId(null);
|
||||
};
|
||||
|
||||
const handleJumpToUnread = () => {
|
||||
if (unreadInfo?.readUptoEventId) {
|
||||
setTimeline(getEmptyTimeline());
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
||||
: undefined;
|
||||
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,7 +505,9 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
};
|
||||
const path = space
|
||||
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
||||
: getHomeSearchPath();
|
||||
: isDirect || onDirectPath
|
||||
? getDirectSearchPath()
|
||||
: getHomeSearchPath();
|
||||
navigate(withSearchParam(path, searchParams));
|
||||
};
|
||||
|
||||
@@ -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,23 +633,21 @@ 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}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Search</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||
<Icon size="300" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Search</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||
<Icon size="300" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
src/app/features/room/emoji-confetti/burstOrigin.ts
Normal file
74
src/app/features/room/emoji-confetti/burstOrigin.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
357
src/app/features/room/emoji-confetti/emojiBurstEngine.ts
Normal file
357
src/app/features/room/emoji-confetti/emojiBurstEngine.ts
Normal 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);
|
||||
}
|
||||
391
src/app/features/room/emoji-confetti/emojiParticleProfiles.ts
Normal file
391
src/app/features/room/emoji-confetti/emojiParticleProfiles.ts
Normal 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);
|
||||
}
|
||||
11
src/app/features/room/emoji-confetti/findJumboMount.ts
Normal file
11
src/app/features/room/emoji-confetti/findJumboMount.ts
Normal 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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
54
src/app/features/room/emoji-confetti/resolveClickedEmoji.ts
Normal file
54
src/app/features/room/emoji-confetti/resolveClickedEmoji.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
42
src/app/features/room/emoji-confetti/sendEmojiConfetti.ts
Normal file
42
src/app/features/room/emoji-confetti/sendEmojiConfetti.ts
Normal 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);
|
||||
}
|
||||
16
src/app/features/room/emoji-confetti/types.ts
Normal file
16
src/app/features/room/emoji-confetti/types.ts
Normal 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;
|
||||
};
|
||||
137
src/app/features/room/emoji-confetti/useEmojiConfetti.ts
Normal file
137
src/app/features/room/emoji-confetti/useEmojiConfetti.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const MediaDrawer = style({
|
||||
width: toRem(399),
|
||||
});
|
||||
|
||||
export const MediaDrawerSolo = style({
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const MediaDrawerHeader = style({
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const MediaDrawerContentBase = style({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const MediaDrawerContent = style({
|
||||
padding: `${config.space.S100} ${config.space.S200} ${config.space.S200}`,
|
||||
});
|
||||
|
||||
export const MediaMonthSection = style({
|
||||
marginBottom: config.space.S300,
|
||||
});
|
||||
|
||||
export const MediaMonthHeader = style({
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
margin: '5px 0',
|
||||
padding: `0 ${config.space.S100}`,
|
||||
lineHeight: 1.2,
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
});
|
||||
|
||||
export const MediaDaySection = style({
|
||||
marginBottom: config.space.S300,
|
||||
});
|
||||
|
||||
export const MediaDayHeader = style({
|
||||
padding: `${config.space.S100} ${config.space.S100} ${config.space.S200}`,
|
||||
color: color.Background.OnContainer,
|
||||
});
|
||||
|
||||
export const MediaGrid = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: config.space.S200,
|
||||
});
|
||||
|
||||
export const MediaTile = style({
|
||||
position: 'relative',
|
||||
aspectRatio: '1',
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
});
|
||||
|
||||
export const MediaTileImg = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
export const MediaTileBadge = style({
|
||||
position: 'absolute',
|
||||
right: config.space.S100,
|
||||
bottom: config.space.S100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(24),
|
||||
height: toRem(24),
|
||||
borderRadius: config.radii.R400,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||
color: '#fff',
|
||||
});
|
||||
|
||||
export const MediaEmpty = style({
|
||||
padding: config.space.S400,
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
export const MediaFooter = style({
|
||||
padding: config.space.S200,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const MediaUserFiltersScroll = style({
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${color.SurfaceVariant.ContainerLine} ${color.SurfaceVariant.Container}`,
|
||||
selectors: {
|
||||
'&::-webkit-scrollbar': {
|
||||
height: toRem(8),
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.Container}`,
|
||||
backgroundClip: 'padding-box',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
backgroundColor: color.Surface.ContainerLine,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const MediaUserFilters = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S500,
|
||||
padding: `${config.space.S200} ${config.space.S200}`,
|
||||
width: 'max-content',
|
||||
minWidth: '100%',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const MediaUserChip = style({
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const MediaUserChipLabel = style({
|
||||
maxWidth: toRem(72),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Header,
|
||||
IconButton,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
as,
|
||||
config,
|
||||
Chip,
|
||||
Avatar,
|
||||
} from 'folds';
|
||||
import { MsgType, Room } from 'matrix-js-sdk';
|
||||
import { useAtom } from 'jotai';
|
||||
import classNames from 'classnames';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import * as css from './RoomMediaMenu.css';
|
||||
import { RoomMediaItem, useRoomMediaEvents } from '../../../hooks/useRoomMediaEvents';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import {
|
||||
decryptFile,
|
||||
downloadEncryptedMedia,
|
||||
getMxIdLocalPart,
|
||||
isAuthenticatedMediaUrl,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { Image } from '../../../components/media';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
import { VideoFrameThumbnail } from '../../../components/message/content/VideoFrameThumbnail';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import {
|
||||
getMediaDrawerScrollTop,
|
||||
isMediaDrawerAtom,
|
||||
setMediaDrawerScrollTop,
|
||||
} from '../../../state/mediaDrawer';
|
||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||
|
||||
const fetchAuthenticatedMedia = async (
|
||||
url: string,
|
||||
accessToken: string | null
|
||||
): Promise<string> => {
|
||||
try {
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok && response.status === 401 && accessToken) {
|
||||
response = await fetch(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
if (!response.ok) return url;
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
type MediaTileThumbProps = {
|
||||
item: RoomMediaItem;
|
||||
};
|
||||
|
||||
function MediaTileImageThumb({ item }: { item: RoomMediaItem & { thumbMxc: string } }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const accessToken = getCurrentAccessToken();
|
||||
|
||||
if (item.thumbEncInfo) {
|
||||
const mediaUrl = mxcUrlToHttp(mx, item.thumbMxc, useAuthentication) ?? item.thumbMxc;
|
||||
const fileContent = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) =>
|
||||
decryptFile(encBuf, item.thumbMimeType ?? FALLBACK_MIMETYPE, item.thumbEncInfo!),
|
||||
accessToken
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
|
||||
const mediaUrl =
|
||||
mxcUrlToHttp(mx, item.thumbMxc, useAuthentication, 240, 240, 'crop') ?? item.thumbMxc;
|
||||
|
||||
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||
}
|
||||
|
||||
return mediaUrl;
|
||||
}, [mx, item.thumbMxc, item.thumbEncInfo, item.thumbMimeType, useAuthentication])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSrc();
|
||||
}, [loadSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||
const src = srcState.data;
|
||||
if (!src.startsWith('blob:')) return undefined;
|
||||
return () => URL.revokeObjectURL(src);
|
||||
}, [srcState]);
|
||||
|
||||
if (srcState.status === AsyncStatus.Success) {
|
||||
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||
{srcState.status === AsyncStatus.Loading ? (
|
||||
<Spinner size="200" />
|
||||
) : (
|
||||
<Icon src={Icons.Photo} size="400" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTileFullImageThumb({ item }: { item: RoomMediaItem }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const mediaUrl = mxcUrlToHttp(mx, item.fullMxc, useAuthentication) ?? item.fullMxc;
|
||||
|
||||
if (item.fullEncInfo) {
|
||||
const fileContent = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) =>
|
||||
decryptFile(encBuf, item.fullMimeType ?? FALLBACK_MIMETYPE, item.fullEncInfo!),
|
||||
accessToken
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
|
||||
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||
}
|
||||
|
||||
return mediaUrl;
|
||||
}, [mx, item.fullMxc, item.fullEncInfo, item.fullMimeType, useAuthentication])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSrc();
|
||||
}, [loadSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||
const src = srcState.data;
|
||||
if (!src.startsWith('blob:')) return undefined;
|
||||
return () => URL.revokeObjectURL(src);
|
||||
}, [srcState]);
|
||||
|
||||
if (srcState.status === AsyncStatus.Success) {
|
||||
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||
{srcState.status === AsyncStatus.Loading ? (
|
||||
<Spinner size="200" />
|
||||
) : (
|
||||
<Icon src={Icons.Photo} size="400" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTileThumb({ item }: MediaTileThumbProps) {
|
||||
if (item.thumbMxc) {
|
||||
return <MediaTileImageThumb item={{ ...item, thumbMxc: item.thumbMxc }} />;
|
||||
}
|
||||
|
||||
if (item.msgtype === MsgType.Video) {
|
||||
return (
|
||||
<Box style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
>
|
||||
<Spinner size="200" />
|
||||
</Box>
|
||||
<VideoFrameThumbnail
|
||||
mimeType={item.fullMimeType ?? 'video/mp4'}
|
||||
url={item.fullMxc}
|
||||
encInfo={item.fullEncInfo}
|
||||
renderImage={(src) => (
|
||||
<Image className={css.MediaTileImg} src={src} alt={item.body} loading="lazy" />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <MediaTileFullImageThumb item={item} />;
|
||||
}
|
||||
|
||||
type MediaDayGroup = {
|
||||
dayKey: string;
|
||||
dayLabel: string;
|
||||
items: RoomMediaItem[];
|
||||
};
|
||||
|
||||
type MediaMonthGroup = {
|
||||
monthKey: string;
|
||||
monthLabel: string;
|
||||
days: MediaDayGroup[];
|
||||
};
|
||||
|
||||
const monthFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const sameDayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const formatDayLabel = (ts: number): string => {
|
||||
const date = new Date(ts);
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (sameDayFormatter.format(date) === sameDayFormatter.format(today)) return 'Today';
|
||||
if (sameDayFormatter.format(date) === sameDayFormatter.format(yesterday)) return 'Yesterday';
|
||||
return dayFormatter.format(date);
|
||||
};
|
||||
|
||||
const groupMediaByMonthAndDay = (items: RoomMediaItem[]): MediaMonthGroup[] => {
|
||||
const months: MediaMonthGroup[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const date = new Date(item.ts);
|
||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
|
||||
let month = months[months.length - 1];
|
||||
if (!month || month.monthKey !== monthKey) {
|
||||
month = {
|
||||
monthKey,
|
||||
monthLabel: monthFormatter.format(date),
|
||||
days: [],
|
||||
};
|
||||
months.push(month);
|
||||
}
|
||||
|
||||
let day = month.days[month.days.length - 1];
|
||||
if (!day || day.dayKey !== dayKey) {
|
||||
day = {
|
||||
dayKey,
|
||||
dayLabel: formatDayLabel(item.ts),
|
||||
items: [],
|
||||
};
|
||||
month.days.push(day);
|
||||
}
|
||||
|
||||
day.items.push(item);
|
||||
}
|
||||
|
||||
return months;
|
||||
};
|
||||
|
||||
type MediaSenderChip = {
|
||||
userId: string;
|
||||
name: string;
|
||||
avatarMxc?: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type MediaDrawerProps = {
|
||||
room: Room;
|
||||
/** Full-page takeover when the layout is too narrow for a side rail. */
|
||||
solo?: boolean;
|
||||
};
|
||||
|
||||
export const MediaDrawer = as<'div', MediaDrawerProps>(({ room, solo = false, className, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||
const { items, loading, hasMore, loadMore } = useRoomMediaEvents(room);
|
||||
const [excludedSenders, setExcludedSenders] = useState<Set<string>>(() => new Set());
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const restoredScrollRef = useRef(false);
|
||||
|
||||
const senders = useMemo<MediaSenderChip[]>(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of items) {
|
||||
counts.set(item.sender, (counts.get(item.sender) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.map(([userId, count]) => ({
|
||||
userId,
|
||||
count,
|
||||
name: getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
|
||||
avatarMxc: getMemberAvatarMxc(room, userId),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||
}, [items, room]);
|
||||
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter((item) => !excludedSenders.has(item.sender)),
|
||||
[items, excludedSenders]
|
||||
);
|
||||
const sections = useMemo(() => groupMediaByMonthAndDay(filteredItems), [filteredItems]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restoredScrollRef.current = false;
|
||||
}, [room.roomId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoredScrollRef.current || items.length === 0) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const saved = getMediaDrawerScrollTop(room.roomId);
|
||||
if (typeof saved === 'number') {
|
||||
el.scrollTop = saved;
|
||||
}
|
||||
restoredScrollRef.current = true;
|
||||
}, [room.roomId, items.length]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setMediaDrawer(false);
|
||||
}, [setMediaDrawer]);
|
||||
|
||||
const handleSenderClick = useCallback(
|
||||
(evt: React.MouseEvent, userId: string) => {
|
||||
if (evt.ctrlKey || evt.metaKey) {
|
||||
evt.preventDefault();
|
||||
const allIds = senders.map((sender) => sender.userId);
|
||||
setExcludedSenders((prev) => {
|
||||
const enabledIds = allIds.filter((id) => !prev.has(id));
|
||||
const alreadySolo = enabledIds.length === 1 && enabledIds[0] === userId;
|
||||
if (alreadySolo) return new Set();
|
||||
return new Set(allIds.filter((id) => id !== userId));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setExcludedSenders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[senders]
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(eventId: string) => {
|
||||
navigateRoom(room.roomId, eventId);
|
||||
if (solo) setMediaDrawer(false);
|
||||
},
|
||||
[navigateRoom, room.roomId, solo, setMediaDrawer]
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(evt: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = evt.currentTarget;
|
||||
setMediaDrawerScrollTop(room.roomId, el.scrollTop);
|
||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||
if (nearBottom && hasMore && !loading) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
[hasMore, loading, loadMore, room.roomId]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
shrink="No"
|
||||
grow={solo ? 'Yes' : undefined}
|
||||
direction="Column"
|
||||
className={classNames(
|
||||
solo ? css.MediaDrawerSolo : css.MediaDrawer,
|
||||
ContainerColor({ variant: 'Background' }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Header className={css.MediaDrawerHeader} size="600" variant="Background">
|
||||
<Box grow="Yes" alignItems="Center">
|
||||
<Text size="H5" truncate>
|
||||
Shared Media
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
{senders.length > 0 && (
|
||||
<div className={css.MediaUserFiltersScroll}>
|
||||
<div className={css.MediaUserFilters}>
|
||||
{senders.map((sender) => {
|
||||
const enabled = !excludedSenders.has(sender.userId);
|
||||
const avatarUrl = sender.avatarMxc
|
||||
? mxcUrlToHttp(mx, sender.avatarMxc, useAuthentication, 48, 48, 'crop') ??
|
||||
undefined
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
key={sender.userId}
|
||||
className={css.MediaUserChip}
|
||||
variant={enabled ? 'Primary' : 'SurfaceVariant'}
|
||||
outlined
|
||||
radii="Pill"
|
||||
aria-pressed={enabled}
|
||||
onClick={(evt) => handleSenderClick(evt, sender.userId)}
|
||||
before={
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={sender.userId}
|
||||
src={avatarUrl}
|
||||
alt={sender.name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="L400">
|
||||
{sender.name.slice(0, 1).toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Text className={css.MediaUserChipLabel} size="T200" truncate>
|
||||
{sender.name}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box grow="Yes" direction="Column" className={css.MediaDrawerContentBase}>
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
visibility="Hover"
|
||||
hideTrack
|
||||
variant="Background"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className={css.MediaDrawerContent}>
|
||||
{items.length === 0 && !loading && (
|
||||
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||
No shared photos or videos yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{items.length > 0 && filteredItems.length === 0 && (
|
||||
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||
No media from the selected people.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{sections.map((month) => (
|
||||
<section key={month.monthKey} className={css.MediaMonthSection}>
|
||||
<Text className={css.MediaMonthHeader} size="H6" as="h3">
|
||||
{month.monthLabel}
|
||||
</Text>
|
||||
{month.days.map((day) => (
|
||||
<div key={day.dayKey} className={css.MediaDaySection}>
|
||||
<Text className={css.MediaDayHeader} size="T200" priority="300" as="h4">
|
||||
{day.dayLabel}
|
||||
</Text>
|
||||
<div className={css.MediaGrid}>
|
||||
{day.items.map((item) => (
|
||||
<button
|
||||
key={item.eventId}
|
||||
type="button"
|
||||
className={css.MediaTile}
|
||||
onClick={() => handleOpen(item.eventId)}
|
||||
title={item.body}
|
||||
aria-label={item.body}
|
||||
>
|
||||
<MediaTileThumb item={item} />
|
||||
{item.msgtype === MsgType.Video && (
|
||||
<span className={css.MediaTileBadge}>
|
||||
<Icon src={Icons.Play} size="100" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{(loading || hasMore) && (
|
||||
<div className={css.MediaFooter} style={{ minHeight: config.space.S700 }}>
|
||||
{loading && <Spinner size="400" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
1
src/app/features/room/room-media-menu/index.ts
Normal file
1
src/app/features/room/room-media-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './RoomMediaMenu';
|
||||
@@ -145,11 +145,23 @@ export function Search({ requestClose }: SearchProps) {
|
||||
|
||||
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
||||
(roomId: string) => {
|
||||
const roomName = getRoom(roomId)?.name ?? roomId;
|
||||
const room = getRoom(roomId);
|
||||
const roomName = room?.name ?? roomId;
|
||||
if (mDirects.has(roomId)) {
|
||||
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
||||
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
|
||||
if (targetUsername) return [roomName, targetUsername];
|
||||
if (!targetUserId) return roomName;
|
||||
const targetUsername = getMxIdLocalPart(targetUserId);
|
||||
const targetServer = getMxIdServer(targetUserId);
|
||||
const member = room?.getMember(targetUserId);
|
||||
const displayName = member?.name || member?.rawDisplayName;
|
||||
return [
|
||||
roomName,
|
||||
displayName,
|
||||
targetUsername,
|
||||
targetUserId,
|
||||
targetServer ? `@${targetUsername}:${targetServer}` : undefined,
|
||||
targetServer,
|
||||
].filter((s): s is string => typeof s === 'string' && s.length > 0);
|
||||
}
|
||||
return roomName;
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,28 +43,49 @@ type ThemeSelectorProps = {
|
||||
onSelect: (theme: Theme) => void;
|
||||
};
|
||||
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||||
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
|
||||
<Menu {...props} ref={ref}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{themes.map((theme) => (
|
||||
<MenuItem
|
||||
key={theme.id}
|
||||
size="300"
|
||||
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => onSelect(theme)}
|
||||
>
|
||||
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
)
|
||||
({ 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
|
||||
key={theme.id}
|
||||
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>
|
||||
</MenuItem>
|
||||
))}
|
||||
</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',
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||
Not registered. Tap "Reset" to choose a distributor app.
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
||||
import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '../../pages/pathUtils';
|
||||
|
||||
export const useDirectSelected = (): boolean => {
|
||||
const directMatch = useMatch({
|
||||
@@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => {
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
||||
export const useDirectSearchSelected = (): boolean => {
|
||||
const match = useMatch({
|
||||
path: getDirectSearchPath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useClientConfig } from './useClientConfig';
|
||||
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
||||
import { isElectron } from '../utils/tauri';
|
||||
|
||||
/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
|
||||
export const ELECTRON_PROTOCOL_HOST = 'desktop';
|
||||
|
||||
/**
|
||||
* Build an absolute URL for the given in-app path.
|
||||
*
|
||||
* In Electron, SSO (and other browser handoffs) must return via paarrot://desktop/...
|
||||
* — a named host so consent UI identifies the desktop app, and a real pathname
|
||||
* (not a hash) because browsers strip fragments when launching custom protocols.
|
||||
*/
|
||||
export const usePathWithOrigin = (path: string): string => {
|
||||
const { hashRouter } = useClientConfig();
|
||||
const { origin } = window.location;
|
||||
|
||||
const pathWithOrigin = useMemo(() => {
|
||||
if (isElectron()) {
|
||||
const basename = trimSlash(hashRouter?.basename ?? '');
|
||||
const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
|
||||
const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
|
||||
return `paarrot://${ELECTRON_PROTOCOL_HOST}${route}`;
|
||||
}
|
||||
|
||||
let url: string = trimSlash(origin);
|
||||
|
||||
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
||||
|
||||
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import to from 'await-to-js';
|
||||
import {
|
||||
Direction,
|
||||
IRoomEvent,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
RelationType,
|
||||
Room,
|
||||
} from 'matrix-js-sdk';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { IEncryptedFile } from '../../types/matrix/common';
|
||||
|
||||
export type RoomMediaItem = {
|
||||
eventId: string;
|
||||
msgtype: typeof MsgType.Image | typeof MsgType.Video;
|
||||
/** MXC used for an image thumbnail when available. */
|
||||
thumbMxc?: string;
|
||||
thumbMimeType?: string;
|
||||
thumbEncInfo?: EncryptedAttachmentInfo;
|
||||
/** Full media MXC — used to extract a video frame when no image thumb exists. */
|
||||
fullMxc: string;
|
||||
fullMimeType?: string;
|
||||
fullEncInfo?: EncryptedAttachmentInfo;
|
||||
body: string;
|
||||
ts: number;
|
||||
sender: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const TARGET_BATCH = 24;
|
||||
const MAX_PAGES_PER_LOAD = 8;
|
||||
|
||||
type RoomMediaCache = {
|
||||
items: RoomMediaItem[];
|
||||
hasMore: boolean;
|
||||
nextToken: string | null;
|
||||
seenIds: Set<string>;
|
||||
};
|
||||
|
||||
/** Survives MediaDrawer remounts within the same room/session. */
|
||||
const mediaCache = new Map<string, RoomMediaCache>();
|
||||
|
||||
const getOrCreateCache = (roomId: string): RoomMediaCache => {
|
||||
const existing = mediaCache.get(roomId);
|
||||
if (existing) return existing;
|
||||
const created: RoomMediaCache = {
|
||||
items: [],
|
||||
hasMore: true,
|
||||
nextToken: null,
|
||||
seenIds: new Set(),
|
||||
};
|
||||
mediaCache.set(roomId, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const toMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => {
|
||||
if (mEvent.isRedacted()) return undefined;
|
||||
|
||||
const relation = mEvent.getRelation();
|
||||
if (relation?.rel_type === RelationType.Replace) return undefined;
|
||||
|
||||
const content = mEvent.getContent();
|
||||
const msgtype = content.msgtype;
|
||||
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video) return undefined;
|
||||
|
||||
const file = content.file as IEncryptedFile | undefined;
|
||||
const info = content.info as
|
||||
| {
|
||||
mimetype?: string;
|
||||
thumbnail_url?: string;
|
||||
thumbnail_info?: { mimetype?: string };
|
||||
thumbnail_file?: IEncryptedFile;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const fullMxc =
|
||||
(typeof content.url === 'string' && content.url) ||
|
||||
(typeof file?.url === 'string' && file.url) ||
|
||||
undefined;
|
||||
if (!fullMxc) return undefined;
|
||||
|
||||
const thumbFile = info?.thumbnail_file;
|
||||
const thumbMxc =
|
||||
(typeof info?.thumbnail_url === 'string' && info.thumbnail_url) ||
|
||||
(typeof thumbFile?.url === 'string' && thumbFile.url) ||
|
||||
undefined;
|
||||
|
||||
const eventId = mEvent.getId();
|
||||
const sender = mEvent.getSender();
|
||||
if (!eventId || !sender) return undefined;
|
||||
|
||||
const usingThumbEnc = !!thumbMxc && thumbMxc === thumbFile?.url;
|
||||
|
||||
return {
|
||||
eventId,
|
||||
msgtype,
|
||||
thumbMxc,
|
||||
thumbMimeType: usingThumbEnc
|
||||
? info?.thumbnail_info?.mimetype
|
||||
: thumbMxc
|
||||
? info?.thumbnail_info?.mimetype
|
||||
: undefined,
|
||||
thumbEncInfo: usingThumbEnc ? thumbFile : undefined,
|
||||
fullMxc,
|
||||
fullMimeType: info?.mimetype,
|
||||
fullEncInfo: file,
|
||||
body: typeof content.body === 'string' ? content.body : msgtype,
|
||||
ts: mEvent.getTs(),
|
||||
sender,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginates room history for image/video messages without mutating the live timeline.
|
||||
*/
|
||||
export const useRoomMediaEvents = (room: Room) => {
|
||||
const mx = useMatrixClient();
|
||||
const roomId = room.roomId;
|
||||
const cacheRef = useRef(getOrCreateCache(roomId));
|
||||
if (cacheRef.current !== mediaCache.get(roomId)) {
|
||||
cacheRef.current = getOrCreateCache(roomId);
|
||||
}
|
||||
|
||||
const [items, setItems] = useState<RoomMediaItem[]>(() => cacheRef.current.items);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(() => cacheRef.current.hasMore);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const decryptEvent = useCallback(
|
||||
async (raw: IRoomEvent | MatrixEvent) => {
|
||||
const mEvent = raw instanceof MatrixEvent ? raw : new MatrixEvent(raw);
|
||||
if (mEvent.isEncrypted() && mx.getCrypto()) {
|
||||
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
|
||||
}
|
||||
return mEvent;
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
const fetchPages = useCallback(async () => {
|
||||
const cache = cacheRef.current;
|
||||
const collected: RoomMediaItem[] = [];
|
||||
|
||||
for (let page = 0; page < MAX_PAGES_PER_LOAD; page += 1) {
|
||||
const response = await mx.createMessagesRequest(
|
||||
roomId,
|
||||
cache.nextToken,
|
||||
PAGE_SIZE,
|
||||
Direction.Backward
|
||||
);
|
||||
|
||||
cache.nextToken = response.end ?? null;
|
||||
if (!response.end) {
|
||||
cache.hasMore = false;
|
||||
setHasMore(false);
|
||||
}
|
||||
|
||||
for (const raw of response.chunk ?? []) {
|
||||
const mEvent = await decryptEvent(raw);
|
||||
const item = toMediaItem(mEvent);
|
||||
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||
cache.seenIds.add(item.eventId);
|
||||
collected.push(item);
|
||||
}
|
||||
|
||||
if (collected.length >= TARGET_BATCH || !response.end) break;
|
||||
}
|
||||
|
||||
return collected;
|
||||
}, [mx, roomId, decryptEvent]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const cache = cacheRef.current;
|
||||
if (loadingRef.current || !cache.hasMore) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const collected = await fetchPages();
|
||||
if (collected.length > 0) {
|
||||
cache.items = [...cache.items, ...collected];
|
||||
setItems(cache.items);
|
||||
}
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchPages]);
|
||||
|
||||
useEffect(() => {
|
||||
const cache = getOrCreateCache(roomId);
|
||||
cacheRef.current = cache;
|
||||
|
||||
// Already loaded for this room — restore without re-fetching.
|
||||
if (cache.items.length > 0 || cache.nextToken !== null || !cache.hasMore) {
|
||||
setItems(cache.items);
|
||||
setHasMore(cache.hasMore);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const bootstrap = async () => {
|
||||
cache.seenIds = new Set();
|
||||
cache.nextToken = null;
|
||||
cache.hasMore = true;
|
||||
cache.items = [];
|
||||
setItems([]);
|
||||
setHasMore(true);
|
||||
setLoading(true);
|
||||
loadingRef.current = true;
|
||||
|
||||
try {
|
||||
const localFound: RoomMediaItem[] = [];
|
||||
const localEvents = room.getLiveTimeline().getEvents();
|
||||
for (let i = localEvents.length - 1; i >= 0; i -= 1) {
|
||||
const mEvent = await decryptEvent(localEvents[i]);
|
||||
const item = toMediaItem(mEvent);
|
||||
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||
cache.seenIds.add(item.eventId);
|
||||
localFound.push(item);
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
if (localFound.length > 0) {
|
||||
cache.items = localFound;
|
||||
setItems(localFound);
|
||||
}
|
||||
|
||||
const collected = await fetchPages();
|
||||
if (cancelled) return;
|
||||
if (collected.length > 0) {
|
||||
cache.items = [...cache.items, ...collected];
|
||||
setItems(cache.items);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Only re-bootstrap when switching rooms — not when callback identities change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roomId]);
|
||||
|
||||
return { items, loading, hasMore, loadMore };
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { NavigateOptions, useNavigate } from 'react-router-dom';
|
||||
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
||||
import {
|
||||
@@ -7,21 +7,25 @@ import {
|
||||
getHomeRoomPath,
|
||||
getSpacePath,
|
||||
getSpaceRoomPath,
|
||||
withRoomEventId,
|
||||
} from '../pages/pathUtils';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import { useSelectedRoom } from './router/useSelectedRoom';
|
||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
|
||||
export const useRoomNavigate = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const mx = useMatrixClient();
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const spaceSelectedId = useSelectedSpace();
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||
|
||||
const navigateSpace = useCallback(
|
||||
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
|
||||
|
||||
const navigateRoom = useCallback(
|
||||
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
||||
// Already viewing this room — keep the current home/direct/space URL and only
|
||||
// change the event id. Otherwise guessPerfectParent can yank the sidebar to
|
||||
// wherever the room "belongs".
|
||||
if (selectedRoomId === roomId) {
|
||||
navigate(withRoomEventId(location.pathname, eventId), opts);
|
||||
return;
|
||||
}
|
||||
|
||||
const room = mx.getRoom(roomId);
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
||||
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
||||
@@ -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 {
|
||||
|
||||
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
roomIdToUploadItemsAtomFamily,
|
||||
TUploadItem,
|
||||
} from '../state/room/roomInputDrafts';
|
||||
import { encryptFile } from '../utils/matrix';
|
||||
import { safeFile } from '../utils/mimeTypes';
|
||||
import { fulfilledPromiseSettledResult } from '../utils/common';
|
||||
import { useFilePicker } from './useFilePicker';
|
||||
|
||||
/**
|
||||
* Enqueues files into the room composer upload board (shared by RoomInput and header media).
|
||||
*/
|
||||
export const useRoomUploadFiles = (room: Room) => {
|
||||
const setSelectedFiles = useSetAtom(roomIdToUploadItemsAtomFamily(room.roomId));
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
const safeFiles = files.map(safeFile);
|
||||
const fileItems: TUploadItem[] = [];
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||
);
|
||||
encryptFiles.forEach((ef) =>
|
||||
fileItems.push({
|
||||
...ef,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
fileItems.push({
|
||||
file: f,
|
||||
originalFile: f,
|
||||
encInfo: undefined,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedFiles({
|
||||
type: 'PUT',
|
||||
item: fileItems,
|
||||
});
|
||||
},
|
||||
[setSelectedFiles, room]
|
||||
);
|
||||
|
||||
const pickFiles = useFilePicker(handleFiles, true);
|
||||
|
||||
return { handleFiles, pickFiles };
|
||||
};
|
||||
@@ -1,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;
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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!}
|
||||
|
||||
55
src/app/pages/client/direct/Search.tsx
Normal file
55
src/app/pages/client/direct/Search.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Text, Scroll, IconButton } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
|
||||
import { MessageSearch } from '../../../features/message-search';
|
||||
import { useDirectRooms } from './useDirectRooms';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||
|
||||
export function DirectSearch() {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rooms = useDirectRooms();
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" basis="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
</Box>
|
||||
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Search} />}
|
||||
<Text size="H3" truncate>
|
||||
Message Search
|
||||
</Text>
|
||||
</Box>
|
||||
<Box grow="Yes" basis="No" />
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box style={{ position: 'relative' }} grow="Yes">
|
||||
<Scroll ref={scrollRef} hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<MessageSearch
|
||||
defaultRoomsFilterName="Direct Messages"
|
||||
allowGlobal
|
||||
rooms={rooms}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Direct';
|
||||
export * from './RoomProvider';
|
||||
export * from './DirectCreate';
|
||||
export * from './Search';
|
||||
|
||||
@@ -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!}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DIRECT_CREATE_PATH,
|
||||
DIRECT_PATH,
|
||||
DIRECT_ROOM_PATH,
|
||||
DIRECT_SEARCH_PATH,
|
||||
DIRECT_NOTIFICATIONS_PATH,
|
||||
DIRECT_INVITES_PATH,
|
||||
EXPLORE_FEATURED_PATH,
|
||||
@@ -121,6 +122,7 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string
|
||||
|
||||
export const getDirectPath = (): string => DIRECT_PATH;
|
||||
export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH;
|
||||
export const getDirectSearchPath = (): string => DIRECT_SEARCH_PATH;
|
||||
export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
|
||||
const params = {
|
||||
roomIdOrAlias,
|
||||
@@ -180,6 +182,28 @@ export const getSpaceRoomPath = (
|
||||
return generatePath(SPACE_ROOM_PATH, params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the current room URL (home / direct / space) and only add, replace, or clear
|
||||
* the optional `$eventId` segment. Used so same-room jumps (media, permalinks) don't
|
||||
* re-home the room into a different sidebar section.
|
||||
*/
|
||||
export const withRoomEventId = (pathname: string, eventId?: string): string => {
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
|
||||
if (parts.length > 0) {
|
||||
const lastDecoded = decodeRouteParam(parts[parts.length - 1]);
|
||||
if (lastDecoded?.startsWith('$')) {
|
||||
parts.pop();
|
||||
}
|
||||
}
|
||||
|
||||
if (eventId) {
|
||||
parts.push(encodeURIComponent(eventId));
|
||||
}
|
||||
|
||||
return `/${parts.join('/')}/`;
|
||||
};
|
||||
|
||||
export const getExplorePath = (): string => EXPLORE_PATH;
|
||||
export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH;
|
||||
export const getExploreServerPath = (server: string): string => {
|
||||
|
||||
@@ -54,6 +54,7 @@ export type DirectCreateSearchParams = {
|
||||
userId?: string;
|
||||
};
|
||||
export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`;
|
||||
export const DIRECT_SEARCH_PATH = `/direct/${_SEARCH_PATH}`;
|
||||
export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`;
|
||||
|
||||
export const SPACE_PATH = '/:spaceIdOrAlias/';
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
src/app/state/mediaDrawer.ts
Normal file
17
src/app/state/mediaDrawer.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/** Session flag for the Shared Media side drawer (mutually exclusive with Members). */
|
||||
export const isMediaDrawerAtom = atom(false);
|
||||
|
||||
const scrollTops = new Map<string, number>();
|
||||
|
||||
export const getMediaDrawerScrollTop = (roomId: string): number | undefined =>
|
||||
scrollTops.get(roomId);
|
||||
|
||||
export const setMediaDrawerScrollTop = (roomId: string, top: number): void => {
|
||||
scrollTops.set(roomId, top);
|
||||
};
|
||||
|
||||
export const clearMediaDrawerScrollTop = (roomId: string): void => {
|
||||
scrollTops.delete(roomId);
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
365
src/playground/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/playground/LivePreview.tsx
Normal file
59
src/playground/LivePreview.tsx
Normal 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 />;
|
||||
}
|
||||
40
src/playground/PreviewErrorBoundary.tsx
Normal file
40
src/playground/PreviewErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
86
src/playground/ResizableStage.tsx
Normal file
86
src/playground/ResizableStage.tsx
Normal 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
128
src/playground/catalog.ts
Normal 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} />;
|
||||
}
|
||||
`;
|
||||
}
|
||||
43
src/playground/defaultLive.tsx
Normal file
43
src/playground/defaultLive.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/playground/defaultSource.ts
Normal file
3
src/playground/defaultSource.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import defaultLive from './defaultLive.tsx?raw';
|
||||
|
||||
export const DEFAULT_SOURCE = defaultLive;
|
||||
151
src/playground/liveClient.ts
Normal file
151
src/playground/liveClient.ts
Normal 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
49
src/playground/main.tsx
Normal 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>
|
||||
);
|
||||
106
src/playground/mocks/PlaygroundProviders.tsx
Normal file
106
src/playground/mocks/PlaygroundProviders.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/playground/mocks/index.ts
Normal file
2
src/playground/mocks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { mocks, createMockMatrixClient, createMockRoom, createMockMatrixEvent, MOCK_POWER_LEVELS } from './matrix';
|
||||
export { PlaygroundProviders } from './PlaygroundProviders';
|
||||
245
src/playground/mocks/matrix.ts
Normal file
245
src/playground/mocks/matrix.ts
Normal 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,
|
||||
};
|
||||
12
src/playground/monacoSetup.ts
Normal file
12
src/playground/monacoSetup.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
65
src/playground/shims/loglevel.js
Normal file
65
src/playground/shims/loglevel.js
Normal 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
414
src/playground/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
123
src/playground/usePlaygroundMatrix.ts
Normal file
123
src/playground/usePlaygroundMatrix.ts
Normal 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
11
src/playground/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'] })],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user