Compare commits
4 Commits
13523fea2b
...
e65a516350
| Author | SHA1 | Date | |
|---|---|---|---|
| e65a516350 | |||
| 9887903f49 | |||
| 898d217451 | |||
| e08b4ec22e |
1464
package-lock.json
generated
1464
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -84,8 +84,10 @@
|
||||
"react-error-boundary": "6.1.2",
|
||||
"react-google-recaptcha": "3.1.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-range": "1.10.0",
|
||||
"react-router-dom": "7.18.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sanitize-html": "2.17.5",
|
||||
"slate": "0.124.1",
|
||||
"slate-dom": "0.124.1",
|
||||
|
||||
16
public/update/4.11.175.md
Normal file
16
public/update/4.11.175.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
version: 4.11.175
|
||||
title: Stability & polish
|
||||
date: 2026-08-10
|
||||
summary: Desktop update flow improvements and UI refinements.
|
||||
---
|
||||
|
||||
## Desktop updates
|
||||
|
||||
- Smoother download and install flow in the title bar updater.
|
||||
- Better handling when an update is already downloading.
|
||||
|
||||
## UI polish
|
||||
|
||||
- Refined spacing and contrast in settings panels.
|
||||
- Improved mobile layout for room headers and notifications.
|
||||
22
public/update/currentupdate.md
Normal file
22
public/update/currentupdate.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 4.11.177
|
||||
title: Profile colors & banners
|
||||
date: 2026-08-23
|
||||
summary: Username colors now follow MSC4522 with separate dark and light theme pickers.
|
||||
---
|
||||
|
||||
## Profile colors (MSC4522)
|
||||
|
||||
Username colors are now stored on your Matrix profile instead of inside avatar images. In turn **will need to be updated in your Account settings**, this is now cross compatible with Element + Cinny
|
||||
|
||||
- Set **separate colors for dark and light themes** in Settings → Account → Profile.
|
||||
- Per-room username overrides are read from member profile data when available.
|
||||
|
||||

|
||||
|
||||
## Profile banners (MSC4133)
|
||||
|
||||
Profile banners now live on your Matrix profile via `m.banner_url` (MSC4133), not embedded in avatar metadata.
|
||||
|
||||
- Upload or remove a banner independently from your avatar.
|
||||
- Other clients that support MSC4133 can read your banner directly.
|
||||
5634
public/update/images/paarrot.svg
Normal file
5634
public/update/images/paarrot.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 889 KiB |
BIN
public/update/images/usernamecolors.png
Normal file
BIN
public/update/images/usernamecolors.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
11
public/update/manifest.json
Normal file
11
public/update/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"current": "currentupdate.md",
|
||||
"older": [
|
||||
{
|
||||
"file": "4.11.175.md",
|
||||
"version": "4.11.175",
|
||||
"title": "Stability & polish",
|
||||
"date": "2026-08-10"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import classNames from 'classnames';
|
||||
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import FileSaver from 'file-saver';
|
||||
import { saveMediaBlob } from '../../utils/saveMedia';
|
||||
import * as css from './PdfViewer.css';
|
||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
@@ -61,8 +61,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
||||
}
|
||||
}, [docState, pageNo, zoom]);
|
||||
|
||||
const handleDownload = () => {
|
||||
FileSaver.saveAs(src, name);
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
if (!res.ok) throw new Error('Failed to fetch PDF');
|
||||
await saveMediaBlob(await res.blob(), name);
|
||||
} catch (error) {
|
||||
console.warn('[PdfViewer] Failed to download:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
|
||||
@@ -1,42 +1,127 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config } from 'folds';
|
||||
import { DefaultReset } from 'folds';
|
||||
|
||||
export const ImageViewer = style([
|
||||
export const Root = style([
|
||||
DefaultReset,
|
||||
{
|
||||
height: '100%',
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
]);
|
||||
|
||||
export const ImageViewerHeader = style([
|
||||
export const Backdrop = style([
|
||||
DefaultReset,
|
||||
{
|
||||
paddingLeft: config.space.S200,
|
||||
paddingRight: config.space.S200,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
flexShrink: 0,
|
||||
gap: config.space.S200,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.72)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
WebkitBackdropFilter: 'blur(18px)',
|
||||
cursor: 'zoom-out',
|
||||
},
|
||||
]);
|
||||
|
||||
export const ImageViewerContent = style([
|
||||
export const Stage = style([
|
||||
DefaultReset,
|
||||
{
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
gap: 10,
|
||||
maxWidth: 'min(96vw, 1100px)',
|
||||
maxHeight: 'min(92vh, 900px)',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Chrome = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
color: '#fff',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Title = style([
|
||||
DefaultReset,
|
||||
{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
lineHeight: '18px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
]);
|
||||
|
||||
export const ImageViewerImg = style([
|
||||
export const ChromeButton = style([
|
||||
DefaultReset,
|
||||
{
|
||||
objectFit: 'contain',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
backgroundColor: color.Surface.Container,
|
||||
transition: 'transform 100ms linear',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
padding: 6,
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
background: 'rgba(0, 0, 0, 0.4)',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Frame = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
alignSelf: 'center',
|
||||
maxWidth: 'min(96vw, 1100px)',
|
||||
maxHeight: 'min(80vh, 820px)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'zoom-in',
|
||||
touchAction: 'none',
|
||||
pointerEvents: 'auto',
|
||||
selectors: {
|
||||
'&[data-zoomed]': {
|
||||
cursor: 'zoom-out',
|
||||
overflow: 'visible',
|
||||
},
|
||||
'&[data-pannable]': {
|
||||
cursor: 'grab',
|
||||
},
|
||||
'&[data-dragging]': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const Image = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'block',
|
||||
borderRadius: 8,
|
||||
background: 'transparent',
|
||||
transform: 'translate(var(--img-tx, 0px), var(--img-ty, 0px)) scale(var(--img-zoom, 1))',
|
||||
transformOrigin: '0 0',
|
||||
userSelect: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,16 +1,61 @@
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||
import React from 'react';
|
||||
import FileSaver from 'file-saver';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
||||
import { as } from 'folds';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import * as css from './ImageViewer.css';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan } from '../../hooks/usePan';
|
||||
import { downloadMedia } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { downloadAndSaveMedia } from '../../utils/saveMedia';
|
||||
import { getCurrentAccessToken } from '../../utils/auth';
|
||||
|
||||
const ZOOM_MIN = 1;
|
||||
const ZOOM_MAX = 5;
|
||||
const ZOOM_CLICK = 2.5;
|
||||
const DRAG_CLICK_SLOP = 6;
|
||||
|
||||
type ZoomState = {
|
||||
scale: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
};
|
||||
|
||||
type PinchSession = {
|
||||
startDist: number;
|
||||
startScale: number;
|
||||
startTx: number;
|
||||
startTy: number;
|
||||
startMidX: number;
|
||||
startMidY: number;
|
||||
};
|
||||
|
||||
const INITIAL_ZOOM: ZoomState = { scale: 1, tx: 0, ty: 0 };
|
||||
|
||||
function imageViewportMax() {
|
||||
return {
|
||||
w: Math.min(window.innerWidth * 0.96, 1100),
|
||||
h: Math.min(window.innerHeight * 0.8, 820),
|
||||
};
|
||||
}
|
||||
|
||||
function pointerDistance(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number }
|
||||
): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
function pointerMidpoint(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number }
|
||||
): { x: number; y: number } {
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
}
|
||||
|
||||
export type ImageViewerProps = {
|
||||
alt: string;
|
||||
src: string;
|
||||
@@ -19,100 +64,382 @@ export type ImageViewerProps = {
|
||||
|
||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const zoomRef = useRef<ZoomState>({ ...INITIAL_ZOOM });
|
||||
const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||
const dragRef = useRef<{
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const pinchRef = useRef<PinchSession | null>(null);
|
||||
/** Skip the next click after a pinch so we don't toggle zoom. */
|
||||
const suppressClickRef = useRef(false);
|
||||
const baseSizeRef = useRef({ w: 0, h: 0 });
|
||||
|
||||
const [zoom, setZoom] = useState<ZoomState>(INITIAL_ZOOM);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const applyZoomCss = useCallback((next: ZoomState) => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
img.style.setProperty('--img-zoom', String(next.scale));
|
||||
img.style.setProperty('--img-tx', `${next.tx}px`);
|
||||
img.style.setProperty('--img-ty', `${next.ty}px`);
|
||||
}, []);
|
||||
|
||||
const clampPan = useCallback((state: ZoomState): ZoomState => {
|
||||
const img = imgRef.current;
|
||||
const frame = frameRef.current;
|
||||
if (!img || !frame) return state;
|
||||
|
||||
const baseW = img.offsetWidth || baseSizeRef.current.w;
|
||||
const baseH = img.offsetHeight || baseSizeRef.current.h;
|
||||
const frameW = frame.clientWidth;
|
||||
const frameH = frame.clientHeight;
|
||||
if (!baseW || !baseH || !frameW || !frameH) return state;
|
||||
|
||||
const scaledW = baseW * state.scale;
|
||||
const scaledH = baseH * state.scale;
|
||||
let { tx, ty } = state;
|
||||
|
||||
if (scaledW <= frameW) tx = (frameW - scaledW) / 2;
|
||||
else tx = Math.min(0, Math.max(frameW - scaledW, tx));
|
||||
|
||||
if (scaledH <= frameH) ty = (frameH - scaledH) / 2;
|
||||
else ty = Math.min(0, Math.max(frameH - scaledH, ty));
|
||||
|
||||
return { ...state, tx, ty };
|
||||
}, []);
|
||||
|
||||
const commitZoom = useCallback(
|
||||
(next: ZoomState) => {
|
||||
const clamped = clampPan(next);
|
||||
zoomRef.current = clamped;
|
||||
applyZoomCss(clamped);
|
||||
setZoom(clamped);
|
||||
},
|
||||
[applyZoomCss, clampPan]
|
||||
);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
pinchRef.current = null;
|
||||
setDragging(false);
|
||||
commitZoom({ ...INITIAL_ZOOM });
|
||||
}, [commitZoom]);
|
||||
|
||||
const fitImage = useCallback(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
const iw = img.naturalWidth || 0;
|
||||
const ih = img.naturalHeight || 0;
|
||||
if (iw <= 0 || ih <= 0) return;
|
||||
|
||||
const { w: maxW, h: maxH } = imageViewportMax();
|
||||
const s = Math.min(1, maxW / iw, maxH / ih);
|
||||
const w = Math.max(1, Math.round(iw * s));
|
||||
const h = Math.max(1, Math.round(ih * s));
|
||||
img.style.width = `${w}px`;
|
||||
img.style.height = `${h}px`;
|
||||
baseSizeRef.current = { w, h };
|
||||
commitZoom({ ...INITIAL_ZOOM });
|
||||
}, [commitZoom]);
|
||||
|
||||
const zoomAround = useCallback(
|
||||
(px: number, py: number, newScale: number) => {
|
||||
const z = zoomRef.current;
|
||||
const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newScale));
|
||||
const ix = (px - z.tx) / z.scale;
|
||||
const iy = (py - z.ty) / z.scale;
|
||||
commitZoom({
|
||||
scale,
|
||||
tx: px - ix * scale,
|
||||
ty: py - iy * scale,
|
||||
});
|
||||
},
|
||||
[commitZoom]
|
||||
);
|
||||
|
||||
const framePoint = (clientX: number, clientY: number) => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return { x: 0, y: 0 };
|
||||
const r = frame.getBoundingClientRect();
|
||||
return { x: clientX - r.left, y: clientY - r.top };
|
||||
};
|
||||
|
||||
const beginPinch = useCallback(() => {
|
||||
const pts = [...pointersRef.current.values()];
|
||||
if (pts.length < 2) return;
|
||||
const [a, b] = pts;
|
||||
const mid = pointerMidpoint(a, b);
|
||||
const local = framePoint(mid.x, mid.y);
|
||||
const z = zoomRef.current;
|
||||
pinchRef.current = {
|
||||
startDist: Math.max(1, pointerDistance(a, b)),
|
||||
startScale: z.scale,
|
||||
startTx: z.tx,
|
||||
startTy: z.ty,
|
||||
startMidX: local.x,
|
||||
startMidY: local.y,
|
||||
};
|
||||
dragRef.current = null;
|
||||
setDragging(true);
|
||||
}, []);
|
||||
|
||||
const updatePinch = useCallback(() => {
|
||||
const pinch = pinchRef.current;
|
||||
const pts = [...pointersRef.current.values()];
|
||||
if (!pinch || pts.length < 2) return;
|
||||
|
||||
const [a, b] = pts;
|
||||
const dist = Math.max(1, pointerDistance(a, b));
|
||||
const mid = pointerMidpoint(a, b);
|
||||
const local = framePoint(mid.x, mid.y);
|
||||
const scale = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.max(ZOOM_MIN, pinch.startScale * (dist / pinch.startDist))
|
||||
);
|
||||
|
||||
// Keep the original content under the pinch midpoint while scaling + panning.
|
||||
const ix = (pinch.startMidX - pinch.startTx) / pinch.startScale;
|
||||
const iy = (pinch.startMidY - pinch.startTy) / pinch.startScale;
|
||||
commitZoom({
|
||||
scale,
|
||||
tx: local.x - ix * scale,
|
||||
ty: local.y - iy * scale,
|
||||
});
|
||||
suppressClickRef.current = true;
|
||||
}, [commitZoom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
resetZoom();
|
||||
}, [src, resetZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [requestClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return undefined;
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const img = imgRef.current;
|
||||
if (!img?.src) return;
|
||||
const factor = Math.exp(-event.deltaY * 0.002);
|
||||
const r = frame.getBoundingClientRect();
|
||||
const x = event.clientX - r.left;
|
||||
const y = event.clientY - r.top;
|
||||
zoomAround(x, y, zoomRef.current.scale * factor);
|
||||
};
|
||||
|
||||
// Block browser gesture zoom / scroll while pinching on the frame.
|
||||
const blockGesture = (event: Event) => {
|
||||
if (pointersRef.current.size >= 2 || pinchRef.current) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
frame.addEventListener('wheel', onWheel, { passive: false });
|
||||
frame.addEventListener('touchmove', blockGesture, { passive: false });
|
||||
return () => {
|
||||
frame.removeEventListener('wheel', onWheel);
|
||||
frame.removeEventListener('touchmove', blockGesture);
|
||||
};
|
||||
}, [zoomAround]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => fitImage();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [fitImage]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
||||
FileSaver.saveAs(fileContent, alt);
|
||||
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
||||
} catch (error) {
|
||||
console.warn('[ImageViewer] Failed to download media:', error);
|
||||
// Fallback: try to fetch via standard fetch as blob
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
FileSaver.saveAs(blob, alt);
|
||||
}
|
||||
} catch {
|
||||
// If all else fails, open in new tab to let browser handle it
|
||||
window.open(src, '_blank');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (dragRef.current?.moved) {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
const img = imgRef.current;
|
||||
if (!img?.src) return;
|
||||
event.preventDefault();
|
||||
const { x, y } = framePoint(event.clientX, event.clientY);
|
||||
if (zoomRef.current.scale <= 1.01) {
|
||||
zoomAround(x, y, ZOOM_CLICK);
|
||||
} else {
|
||||
resetZoom();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
if (event.button !== 0 && event.pointerType === 'mouse') return;
|
||||
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
frameRef.current?.setPointerCapture(event.pointerId);
|
||||
|
||||
if (pointersRef.current.size >= 2) {
|
||||
beginPinch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (zoomRef.current.scale <= 1.01) return;
|
||||
const z = zoomRef.current;
|
||||
dragRef.current = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
tx: z.tx,
|
||||
ty: z.ty,
|
||||
moved: false,
|
||||
};
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent) => {
|
||||
if (!pointersRef.current.has(event.pointerId)) return;
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
|
||||
if (pointersRef.current.size >= 2 && pinchRef.current) {
|
||||
event.preventDefault();
|
||||
updatePinch();
|
||||
return;
|
||||
}
|
||||
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
const dx = event.clientX - drag.x;
|
||||
const dy = event.clientY - drag.y;
|
||||
if (!drag.moved && Math.hypot(dx, dy) > DRAG_CLICK_SLOP) drag.moved = true;
|
||||
commitZoom({
|
||||
scale: zoomRef.current.scale,
|
||||
tx: drag.tx + dx,
|
||||
ty: drag.ty + dy,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent) => {
|
||||
pointersRef.current.delete(event.pointerId);
|
||||
frameRef.current?.releasePointerCapture(event.pointerId);
|
||||
|
||||
if (pinchRef.current) {
|
||||
if (pointersRef.current.size >= 2) {
|
||||
beginPinch();
|
||||
return;
|
||||
}
|
||||
pinchRef.current = null;
|
||||
setDragging(false);
|
||||
// Extra Things: one finger left after pinch — hand off to pan if still zoomed.
|
||||
if (pointersRef.current.size === 1 && zoomRef.current.scale > 1.01) {
|
||||
const [id, pt] = [...pointersRef.current.entries()][0];
|
||||
const z = zoomRef.current;
|
||||
dragRef.current = {
|
||||
id,
|
||||
x: pt.x,
|
||||
y: pt.y,
|
||||
tx: z.tx,
|
||||
ty: z.ty,
|
||||
moved: true,
|
||||
};
|
||||
setDragging(true);
|
||||
} else {
|
||||
dragRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
setDragging(false);
|
||||
if (!drag.moved) dragRef.current = null;
|
||||
else dragRef.current = { ...drag, moved: true };
|
||||
};
|
||||
|
||||
const zoomed = zoom.scale > 1.01;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.ImageViewer, className)}
|
||||
direction="Column"
|
||||
<div
|
||||
className={classNames(css.Root, className)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt || 'Image viewer'}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Header className={css.ImageViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
<div className={css.Backdrop} onClick={requestClose} aria-hidden />
|
||||
<div className={css.Stage}>
|
||||
<div className={css.Chrome}>
|
||||
<div className={css.Title} title={alt}>
|
||||
{alt}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<IconButton
|
||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom < 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
onClick={zoomOut}
|
||||
aria-label="Zoom Out"
|
||||
>
|
||||
<Icon size="50" src={Icons.Minus} />
|
||||
</IconButton>
|
||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom > 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
onClick={zoomIn}
|
||||
aria-label="Zoom In"
|
||||
>
|
||||
<Icon size="50" src={Icons.Plus} />
|
||||
</IconButton>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ChromeButton}
|
||||
aria-label="Download"
|
||||
onClick={handleDownload}
|
||||
radii="300"
|
||||
before={<Icon size="50" src={Icons.Download} />}
|
||||
>
|
||||
<Text size="B300">Download</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box
|
||||
grow="Yes"
|
||||
className={css.ImageViewerContent}
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
<img
|
||||
className={css.ImageViewerImg}
|
||||
style={{
|
||||
cursor,
|
||||
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
||||
}}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Icon size="50" src={Icons.Download} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ChromeButton}
|
||||
aria-label="Close"
|
||||
onClick={requestClose}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={frameRef}
|
||||
className={css.Frame}
|
||||
data-zoomed={zoomed || undefined}
|
||||
data-pannable={zoomed || undefined}
|
||||
data-dragging={dragging || undefined}
|
||||
onClick={handleClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={css.Image}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
onLoad={fitImage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import React, { ReactNode, useCallback } from 'react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import FileSaver from 'file-saver';
|
||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
mxcUrlToHttp,
|
||||
} from '../../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../../utils/auth';
|
||||
import { saveMediaBlob } from '../../utils/saveMedia';
|
||||
|
||||
const badgeStyles = { maxWidth: toRem(100) };
|
||||
|
||||
@@ -36,9 +36,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
|
||||
const fileURL = URL.createObjectURL(fileContent);
|
||||
FileSaver.saveAs(fileURL, filename);
|
||||
return fileURL;
|
||||
await saveMediaBlob(fileContent, filename);
|
||||
return true;
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
||||
);
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export const Reply = as<'div', ReplyProps>(
|
||||
const { body } = replyEvent?.getContent() ?? {};
|
||||
const sender = replyEvent?.getSender();
|
||||
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
||||
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
|
||||
const customUserColor = useOtherUserColor(sender ?? '', room);
|
||||
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
|
||||
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { ReactNode, useCallback, useState } from 'react';
|
||||
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||
import { Icon, Icons } from '../../icons';
|
||||
import FileSaver from 'file-saver';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { saveMediaBlob } from '../../../utils/saveMedia';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { IFileInfo } from '../../../../types/matrix/common';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
@@ -252,9 +252,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
|
||||
const fileURL = URL.createObjectURL(fileContent);
|
||||
FileSaver.saveAs(fileURL, body);
|
||||
return fileURL;
|
||||
await saveMediaBlob(fileContent, body);
|
||||
return fileContent;
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo, body])
|
||||
);
|
||||
|
||||
@@ -268,7 +267,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
||||
size="400"
|
||||
onClick={() =>
|
||||
downloadState.status === AsyncStatus.Success
|
||||
? FileSaver.saveAs(downloadState.data, body)
|
||||
? saveMediaBlob(downloadState.data, body)
|
||||
: download()
|
||||
}
|
||||
disabled={downloadState.status === AsyncStatus.Loading}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||
import { Badge, Box, Button, Chip, Overlay, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||
import { Icon, Icons } from '../../icons';
|
||||
import classNames from 'classnames';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
@@ -14,7 +14,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||
@@ -192,28 +191,21 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
<div className={css.MediaSkeleton} />
|
||||
))}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
alt: body,
|
||||
requestClose: () => setViewer(false),
|
||||
})}
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
<Overlay open={viewer} backdrop={null}>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
alt: body,
|
||||
requestClose: () => setViewer(false),
|
||||
})}
|
||||
</FocusTrap>
|
||||
</Overlay>
|
||||
)}
|
||||
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||
|
||||
35
src/app/components/updates-dialog/UpdateMarkdownViewer.tsx
Normal file
35
src/app/components/updates-dialog/UpdateMarkdownViewer.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { resolveUpdateAssetUrl } from '../../data/updateNotes';
|
||||
import * as css from './UpdatesDialog.css';
|
||||
|
||||
type UpdateMarkdownViewerProps = {
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
export function UpdateMarkdownViewer({ markdown }: UpdateMarkdownViewerProps) {
|
||||
if (!markdown.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.Markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noreferrer noopener">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
<img src={resolveUpdateAssetUrl(src) ?? src} alt={alt ?? ''} loading="lazy" />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
296
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
296
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
const MOBILE_BREAKPOINT = '480px';
|
||||
|
||||
/** Dialog height cap — 85% of the viewport, minus safe areas. */
|
||||
const DIALOG_MAX_HEIGHT =
|
||||
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';
|
||||
|
||||
export const OverlayFrame = style({
|
||||
boxSizing: 'border-box',
|
||||
maxHeight: '100vh',
|
||||
maxWidth: '100vw',
|
||||
padding:
|
||||
'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
|
||||
});
|
||||
|
||||
export const DialogShell = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
maxWidth: `min(${toRem(560)}, calc(100vw - ${config.space.S800}))`,
|
||||
maxHeight: DIALOG_MAX_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
'@media': {
|
||||
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||
maxWidth: `calc(100vw - ${config.space.S400})`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Hero = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S200,
|
||||
padding: config.space.S500,
|
||||
paddingBottom: config.space.S400,
|
||||
backgroundColor: color.Primary.Container,
|
||||
color: color.Primary.OnContainer,
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.Primary.ContainerLine}`,
|
||||
'@media': {
|
||||
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||
padding: config.space.S400,
|
||||
paddingBottom: config.space.S300,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const HeroRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S300,
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const HeroLogo = style({
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
flexShrink: 0,
|
||||
'@media': {
|
||||
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||
width: toRem(40),
|
||||
height: toRem(40),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const HeroTitleWrap = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const BodyScroll = style({
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
export const BodyContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: config.space.S500,
|
||||
paddingTop: config.space.S400,
|
||||
'@media': {
|
||||
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||
padding: config.space.S400,
|
||||
paddingTop: config.space.S300,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BodyLoading = style({
|
||||
padding: config.space.S800,
|
||||
});
|
||||
|
||||
export const Markdown = style({
|
||||
width: '100%',
|
||||
color: color.Surface.OnContainer,
|
||||
fontSize: toRem(14),
|
||||
lineHeight: 1.55,
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} h1, ${Markdown} h2, ${Markdown} h3, ${Markdown} h4`, {
|
||||
margin: 0,
|
||||
marginTop: config.space.S400,
|
||||
marginBottom: config.space.S200,
|
||||
lineHeight: 1.3,
|
||||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} h1:first-child, ${Markdown} h2:first-child, ${Markdown} h3:first-child, ${Markdown} h4:first-child`, {
|
||||
marginTop: 0,
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} h2`, { fontSize: toRem(18) });
|
||||
globalStyle(`${Markdown} h3`, { fontSize: toRem(16) });
|
||||
|
||||
globalStyle(`${Markdown} p`, {
|
||||
margin: 0,
|
||||
marginBottom: config.space.S300,
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} p:last-child`, { marginBottom: 0 });
|
||||
|
||||
globalStyle(`${Markdown} ul, ${Markdown} ol`, {
|
||||
margin: 0,
|
||||
marginBottom: config.space.S300,
|
||||
paddingLeft: config.space.S500,
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} li`, { marginBottom: config.space.S100 });
|
||||
globalStyle(`${Markdown} li:last-child`, { marginBottom: 0 });
|
||||
|
||||
globalStyle(`${Markdown} a`, {
|
||||
color: color.Primary.Main,
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: toRem(2),
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} a:hover`, { color: color.Primary.MainHover });
|
||||
|
||||
globalStyle(`${Markdown} img`, {
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
marginTop: config.space.S200,
|
||||
marginBottom: config.space.S300,
|
||||
borderRadius: config.radii.R300,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} code`, {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: toRem(13),
|
||||
padding: `0 ${config.space.S100}`,
|
||||
borderRadius: config.radii.R200,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} pre`, {
|
||||
margin: 0,
|
||||
marginBottom: config.space.S300,
|
||||
padding: config.space.S300,
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} pre code`, {
|
||||
padding: 0,
|
||||
backgroundColor: 'transparent',
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} blockquote`, {
|
||||
margin: 0,
|
||||
marginBottom: config.space.S300,
|
||||
paddingLeft: config.space.S300,
|
||||
borderLeft: `${toRem(3)} solid ${color.Primary.Main}`,
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0.9,
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} hr`, {
|
||||
border: 'none',
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
margin: `${config.space.S400} 0`,
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} table`, {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
marginBottom: config.space.S300,
|
||||
fontSize: toRem(13),
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} th, ${Markdown} td`, {
|
||||
padding: config.space.S200,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
textAlign: 'left',
|
||||
});
|
||||
|
||||
globalStyle(`${Markdown} th`, {
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
});
|
||||
|
||||
export const OlderSection = style({
|
||||
width: '100%',
|
||||
marginTop: config.space.S500,
|
||||
});
|
||||
|
||||
export const OlderToggle = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const OlderList = style({
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const OlderListItem = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const OlderItem = style({
|
||||
all: 'unset',
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: config.space.S100,
|
||||
width: '100%',
|
||||
minHeight: toRem(48),
|
||||
padding: config.space.S300,
|
||||
borderRadius: config.radii.R300,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: color.SurfaceVariant.ContainerHover,
|
||||
},
|
||||
'&[data-active="true"]': {
|
||||
borderColor: color.Primary.Main,
|
||||
backgroundColor: color.Primary.Container,
|
||||
color: color.Primary.OnContainer,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Footer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S200,
|
||||
padding: config.space.S400,
|
||||
paddingTop: config.space.S300,
|
||||
paddingBottom: `calc(${config.space.S400} + env(safe-area-inset-bottom, 0px))`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
'@media': {
|
||||
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||
padding: config.space.S300,
|
||||
paddingTop: config.space.S300,
|
||||
paddingBottom: `calc(${config.space.S300} + env(safe-area-inset-bottom, 0px))`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const FooterButton = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const CloseButton = style({
|
||||
position: 'absolute',
|
||||
top: config.space.S300,
|
||||
right: config.space.S300,
|
||||
'@media': {
|
||||
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||
top: config.space.S200,
|
||||
right: config.space.S200,
|
||||
},
|
||||
},
|
||||
});
|
||||
181
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
181
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
IconButton,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes';
|
||||
import PaarrotSVG from '../../../../public/res/svg/paarrot.svg';
|
||||
import { UpdateMarkdownViewer } from './UpdateMarkdownViewer';
|
||||
import * as css from './UpdatesDialog.css';
|
||||
|
||||
type UpdatesDialogProps = {
|
||||
manifest: UpdateManifest | null;
|
||||
activeDoc: ParsedUpdateDoc | null;
|
||||
loading: boolean;
|
||||
onSelectFile: (file: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function UpdatesDialog({
|
||||
manifest,
|
||||
activeDoc,
|
||||
loading,
|
||||
onSelectFile,
|
||||
onClose,
|
||||
}: UpdatesDialogProps) {
|
||||
const [showOlderList, setShowOlderList] = useState(false);
|
||||
|
||||
const displayVersion = activeDoc?.version;
|
||||
const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes');
|
||||
const isCurrent = manifest && activeDoc?.file === manifest.current;
|
||||
const olderEntries = manifest?.older ?? [];
|
||||
|
||||
const handleSelectOlder = (file: string) => {
|
||||
onSelectFile(file);
|
||||
setShowOlderList(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter className={css.OverlayFrame}>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: onClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface" className={css.DialogShell}>
|
||||
<Box className={css.Hero}>
|
||||
<Box className={css.HeroRow}>
|
||||
<img className={css.HeroLogo} src={PaarrotSVG} alt="" draggable={false} />
|
||||
<Box className={css.HeroTitleWrap}>
|
||||
<Text size="L400" priority="300">What's new</Text>
|
||||
<Text size="H4" truncate>
|
||||
{displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="H6">{displayTitle}</Text>
|
||||
{activeDoc?.date && (
|
||||
<Text size="T200" priority="300">
|
||||
{activeDoc.date}
|
||||
</Text>
|
||||
)}
|
||||
{!loading && !isCurrent && manifest?.current && (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => onSelectFile(manifest.current)}
|
||||
>
|
||||
<Text size="B300">Back to current update</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Scroll className={css.BodyScroll} visibility="Hover">
|
||||
<Box className={css.BodyContent} direction="Column">
|
||||
{loading && (
|
||||
<Box justifyContent="Center" alignItems="Center" className={css.BodyLoading}>
|
||||
<Spinner size="400" variant="Secondary" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && activeDoc?.markdown && (
|
||||
<UpdateMarkdownViewer markdown={activeDoc.markdown} />
|
||||
)}
|
||||
|
||||
{!loading && !activeDoc?.markdown && (
|
||||
<Text size="T300" priority="400">
|
||||
No update notes are available right now.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!loading && olderEntries.length > 0 && (
|
||||
<Box className={css.OlderSection} direction="Column" gap="200">
|
||||
<Button
|
||||
className={css.OlderToggle}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
aria-expanded={showOlderList}
|
||||
onClick={() => setShowOlderList((open) => !open)}
|
||||
>
|
||||
<Text size="B300">
|
||||
{showOlderList
|
||||
? 'Hide older versions'
|
||||
: `Older versions (${olderEntries.length})`}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{showOlderList && (
|
||||
<ul className={css.OlderList}>
|
||||
{olderEntries.map((entry) => {
|
||||
const isActive = activeDoc?.file === entry.file;
|
||||
const label = entry.version ?? entry.file.replace(/\.md$/i, '');
|
||||
return (
|
||||
<li key={entry.file} className={css.OlderListItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.OlderItem}
|
||||
data-active={isActive ? 'true' : 'false'}
|
||||
onClick={() => handleSelectOlder(entry.file)}
|
||||
>
|
||||
<Text size="B300">{label}</Text>
|
||||
{entry.title && (
|
||||
<Text size="T200" priority="400">
|
||||
{entry.title}
|
||||
</Text>
|
||||
)}
|
||||
{entry.date && (
|
||||
<Text size="T200" priority="300">
|
||||
{entry.date}
|
||||
</Text>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
|
||||
<Box className={css.Footer}>
|
||||
<Button
|
||||
className={css.FooterButton}
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Text size="B400">Got it</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className={css.CloseButton}>
|
||||
<IconButton variant="Surface" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
149
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
149
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import {
|
||||
loadCurrentUpdateDocument,
|
||||
loadUpdateDocument,
|
||||
loadUpdateManifest,
|
||||
type ParsedUpdateDoc,
|
||||
type UpdateManifest,
|
||||
} from '../../data/updateNotes';
|
||||
import {
|
||||
clearPendingReleaseNotes,
|
||||
getLastSeenAppVersion,
|
||||
releaseNotesDialogAtom,
|
||||
setLastSeenAppVersion,
|
||||
storePendingReleaseNotes,
|
||||
} from '../../state/releaseNotes';
|
||||
import { getAppVersion } from '../../utils/appVersion';
|
||||
import { UpdatesDialog } from './UpdatesDialog';
|
||||
|
||||
type UpdaterInfo = {
|
||||
version: string;
|
||||
releaseNotes?: unknown;
|
||||
};
|
||||
|
||||
/** Open the What's New dialog from Settings → About (or anywhere else). */
|
||||
export function useOpenReleaseNotesDialog(): () => void {
|
||||
const setDialogState = useSetAtom(releaseNotesDialogAtom);
|
||||
return useCallback(() => {
|
||||
setDialogState({ open: true, manual: true });
|
||||
}, [setDialogState]);
|
||||
}
|
||||
|
||||
export function UpdatesDialogHost() {
|
||||
const [dialogState, setDialogState] = useAtom(releaseNotesDialogAtom);
|
||||
const [manifest, setManifest] = useState<UpdateManifest | null>(null);
|
||||
const [activeDoc, setActiveDoc] = useState<ParsedUpdateDoc | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogState({ open: false, manual: false });
|
||||
setManifest(null);
|
||||
setActiveDoc(null);
|
||||
setLoading(false);
|
||||
}, [setDialogState]);
|
||||
|
||||
const selectFile = useCallback(async (file: string) => {
|
||||
setLoading(true);
|
||||
const doc = await loadUpdateDocument(file);
|
||||
setActiveDoc(doc);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Persist updater metadata when a download finishes (version tracking after restart).
|
||||
useEffect(() => {
|
||||
const electron = window.electron;
|
||||
if (!electron?.updater?.onUpdateDownloaded) return undefined;
|
||||
|
||||
const onDownloaded = (info: UpdaterInfo) => {
|
||||
if (!info?.version) return;
|
||||
storePendingReleaseNotes(info.version, info.releaseNotes);
|
||||
};
|
||||
|
||||
electron.updater.onUpdateDownloaded(onDownloaded);
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
// Show What's New after the app version changes (post-update restart).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkVersion = async () => {
|
||||
const currentVersion = await getAppVersion();
|
||||
if (cancelled || currentVersion === 'unknown') return;
|
||||
|
||||
const lastSeen = getLastSeenAppVersion();
|
||||
const shouldAutoShow = Boolean(lastSeen && lastSeen !== currentVersion);
|
||||
|
||||
if (shouldAutoShow) {
|
||||
const loadedManifest = await loadUpdateManifest();
|
||||
if (cancelled || !loadedManifest) return;
|
||||
|
||||
const doc = await loadUpdateDocument(loadedManifest.current);
|
||||
if (cancelled || !doc) return;
|
||||
|
||||
setManifest(loadedManifest);
|
||||
setActiveDoc(doc);
|
||||
setDialogState({ open: true, manual: false });
|
||||
clearPendingReleaseNotes();
|
||||
}
|
||||
|
||||
if (!lastSeen || lastSeen !== currentVersion) {
|
||||
setLastSeenAppVersion(currentVersion);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = window.setTimeout(checkVersion, 1500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [setDialogState]);
|
||||
|
||||
// Load markdown when the dialog opens (manual open from About, etc.).
|
||||
useEffect(() => {
|
||||
if (!dialogState.open) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const loadedManifest = await loadUpdateManifest();
|
||||
if (cancelled) return;
|
||||
|
||||
if (!loadedManifest) {
|
||||
setManifest(null);
|
||||
setActiveDoc(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await loadUpdateDocument(loadedManifest.current);
|
||||
if (cancelled) return;
|
||||
|
||||
setManifest(loadedManifest);
|
||||
setActiveDoc(doc);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dialogState.open]);
|
||||
|
||||
if (!dialogState.open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UpdatesDialog
|
||||
manifest={manifest}
|
||||
activeDoc={activeDoc}
|
||||
loading={loading}
|
||||
onSelectFile={selectFile}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
);
|
||||
}
|
||||
3
src/app/components/updates-dialog/index.ts
Normal file
3
src/app/components/updates-dialog/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './UpdatesDialog';
|
||||
export * from './UpdatesDialogHost';
|
||||
export * from './UpdateMarkdownViewer';
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar, Box, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, toRem } from 'folds';
|
||||
import { Avatar, Box, Overlay, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import classNames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -14,7 +14,6 @@ import { ImageViewer } from '../image-viewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||
import { useOtherUserBanner } from '../../hooks/useUserBanner';
|
||||
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
|
||||
|
||||
type UserHeroProps = {
|
||||
userId: string;
|
||||
@@ -24,15 +23,7 @@ type UserHeroProps = {
|
||||
};
|
||||
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
|
||||
const [viewAvatar, setViewAvatar] = useState<string>();
|
||||
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
|
||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
||||
|
||||
console.log('[UserHero]', {
|
||||
userId,
|
||||
avatarMxc,
|
||||
bannerUrl,
|
||||
profileStyle,
|
||||
});
|
||||
const bannerUrl = useOtherUserBanner(userId);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -57,8 +48,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
||||
<div className={css.UserHeroAvatarContainer}>
|
||||
<AvatarPresence
|
||||
className={css.UserAvatarContainer}
|
||||
style={profileStyle.avatarBorderColor ? { backgroundColor: profileStyle.avatarBorderColor } : undefined}
|
||||
badgeBackgroundColor={profileStyle.avatarBorderColor}
|
||||
badge={
|
||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||
}
|
||||
@@ -71,10 +60,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
||||
style={{
|
||||
width: toRem(72),
|
||||
height: toRem(72),
|
||||
...(profileStyle.avatarBorderColor ? {
|
||||
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
|
||||
outlineOffset: toRem(-1),
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -87,25 +72,21 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
||||
</Avatar>
|
||||
</AvatarPresence>
|
||||
{viewAvatar && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewAvatar(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
|
||||
<ImageViewer
|
||||
src={viewAvatar}
|
||||
alt={userId}
|
||||
requestClose={() => setViewAvatar(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
<Overlay open backdrop={null}>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewAvatar(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<ImageViewer
|
||||
src={viewAvatar}
|
||||
alt={userId}
|
||||
requestClose={() => setViewAvatar(undefined)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
@@ -118,16 +99,9 @@ type UserHeroNameProps = {
|
||||
userId: string;
|
||||
avatarMxc?: string;
|
||||
};
|
||||
export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNameProps) {
|
||||
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
||||
const username = getMxIdLocalPart(userId);
|
||||
const userColor = useOtherUserColor(userId, avatarMxc);
|
||||
|
||||
console.log('[UserHeroName]', {
|
||||
userId,
|
||||
avatarMxc,
|
||||
userColor,
|
||||
fallbackColor: colorMXID(userId),
|
||||
});
|
||||
const userColor = useOtherUserColor(userId);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
|
||||
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
|
||||
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { PowerChip } from './PowerChip';
|
||||
@@ -24,7 +23,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
||||
import { CreatorChip } from './CreatorChip';
|
||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { DirectCreateSearchParams } from '../../pages/paths';
|
||||
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
|
||||
import { getTextShadowColor } from '../../utils/common';
|
||||
import classNames from 'classnames';
|
||||
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
||||
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
|
||||
@@ -65,13 +64,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
||||
|
||||
const presence = useUserPresence(userId);
|
||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
||||
const userColor = useOtherUserColor(userId, avatarMxc);
|
||||
|
||||
// Build gradient CSS if configured
|
||||
const gradientStyle = profileStyle.gradient
|
||||
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
|
||||
: undefined;
|
||||
const userColor = useOtherUserColor(userId);
|
||||
|
||||
const handleMessage = () => {
|
||||
closeUserRoomProfile();
|
||||
@@ -94,22 +87,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
|
||||
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
|
||||
|
||||
// Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent)
|
||||
const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00');
|
||||
const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined;
|
||||
|
||||
console.log('[UserRoomProfile]', {
|
||||
userId,
|
||||
avatarMxc,
|
||||
userColor,
|
||||
profileStyle,
|
||||
profileColor,
|
||||
profileTextShadow,
|
||||
hasVisibleBorder,
|
||||
pillBgColor,
|
||||
fallbackColorMXID: getColorMXIDValue(userId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box direction="Column">
|
||||
<UserHero
|
||||
@@ -126,8 +103,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
padding: config.space.S400,
|
||||
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
|
||||
marginTop: toRem(-36),
|
||||
background: gradientStyle,
|
||||
textAlign: 'center'
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Display Name */}
|
||||
@@ -152,20 +128,14 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
{presence?.status && (
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: pillBgColor || color.Surface.Container,
|
||||
border: pillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${toRem(6)} ${toRem(10)}`,
|
||||
borderRadius: toRem(16),
|
||||
maxWidth: toRem(250),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="T300"
|
||||
className={BreakWord}
|
||||
style={{
|
||||
color: pillBgColor ? getContrastingTextColor(pillBgColor) : undefined
|
||||
}}
|
||||
>
|
||||
<Text size="T300" className={BreakWord}>
|
||||
{presence.status}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -173,9 +143,9 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
|
||||
{/* Chips Row */}
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||
{server && <ServerChip server={server} borderColor={pillBgColor} />}
|
||||
<ShareChip userId={userId} borderColor={pillBgColor} />
|
||||
{creator ? <CreatorChip borderColor={pillBgColor} /> : <PowerChip userId={userId} borderColor={pillBgColor} />}
|
||||
{server && <ServerChip server={server} />}
|
||||
<ShareChip userId={userId} />
|
||||
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
|
||||
</Box>
|
||||
|
||||
{/* Message Button */}
|
||||
@@ -195,8 +165,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
{/* Additional Chips if needed */}
|
||||
{userId !== myUserId && (
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||
<MutualRoomsChip userId={userId} borderColor={pillBgColor} />
|
||||
<OptionsChip userId={userId} borderColor={pillBgColor} />
|
||||
<MutualRoomsChip userId={userId} />
|
||||
<OptionsChip userId={userId} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import FileSaver from 'file-saver';
|
||||
import classNames from 'classnames';
|
||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import * as css from './VideoViewer.css';
|
||||
import { downloadMedia } from '../../utils/matrix';
|
||||
import { downloadAndSaveMedia } from '../../utils/saveMedia';
|
||||
import { getCurrentAccessToken } from '../../utils/auth';
|
||||
|
||||
export type VideoViewerProps = {
|
||||
@@ -14,20 +13,16 @@ export type VideoViewerProps = {
|
||||
};
|
||||
|
||||
export const VideoViewer = as<'div', VideoViewerProps>(
|
||||
({ className, alt, src, requestClose, ...props }, ref) => { const handleDownload = async () => {
|
||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
||||
FileSaver.saveAs(fileContent, alt);
|
||||
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
||||
} catch (error) {
|
||||
console.warn('[VideoViewer] Failed to download media:', error);
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
FileSaver.saveAs(blob, alt);
|
||||
}
|
||||
} catch {
|
||||
window.open(src, '_blank');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
125
src/app/data/updateNotes.ts
Normal file
125
src/app/data/updateNotes.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { trimTrailingSlash } from '../utils/common';
|
||||
|
||||
const UPDATE_BASE_PATH = `${trimTrailingSlash(import.meta.env.BASE_URL)}/update`;
|
||||
|
||||
export type UpdateManifestEntry = {
|
||||
file: string;
|
||||
version?: string;
|
||||
title?: string;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
export type UpdateManifest = {
|
||||
current: string;
|
||||
older: UpdateManifestEntry[];
|
||||
};
|
||||
|
||||
export type ParsedUpdateDoc = {
|
||||
file: string;
|
||||
version?: string;
|
||||
title?: string;
|
||||
date?: string;
|
||||
summary?: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||
|
||||
function parseFrontmatter(source: string): { frontmatter: Record<string, string>; body: string } {
|
||||
const match = FRONTMATTER_RE.exec(source.trimStart());
|
||||
if (!match) {
|
||||
return { frontmatter: {}, body: source };
|
||||
}
|
||||
|
||||
const frontmatter: Record<string, string> = {};
|
||||
match[1].split('\n').forEach((line) => {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) return;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const raw = line.slice(colon + 1).trim();
|
||||
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
|
||||
});
|
||||
|
||||
return { frontmatter, body: match[2] };
|
||||
}
|
||||
|
||||
function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
|
||||
const { frontmatter, body } = parseFrontmatter(source);
|
||||
return {
|
||||
file,
|
||||
version: frontmatter.version,
|
||||
title: frontmatter.title,
|
||||
date: frontmatter.date,
|
||||
summary: frontmatter.summary,
|
||||
markdown: body.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
|
||||
if (!src) return undefined;
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) return src;
|
||||
if (src.startsWith('/')) return src;
|
||||
|
||||
const normalized = src.replace(/^\.\//, '');
|
||||
return `${UPDATE_BASE_PATH}/${normalized}`;
|
||||
}
|
||||
|
||||
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
|
||||
try {
|
||||
const response = await fetch(`${UPDATE_BASE_PATH}/manifest.json`, { cache: 'no-cache' });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = (await response.json()) as UpdateManifest;
|
||||
if (!data?.current || !Array.isArray(data.older)) return null;
|
||||
|
||||
return {
|
||||
current: data.current,
|
||||
older: data.older.filter((entry) => entry?.file),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc | null> {
|
||||
const safeFile = file.replace(/^\/+/, '');
|
||||
if (!safeFile || safeFile.includes('..')) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${UPDATE_BASE_PATH}/${safeFile}`, { cache: 'no-cache' });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const source = await response.text();
|
||||
return parseUpdateMarkdown(safeFile, source);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {
|
||||
const manifest = await loadUpdateManifest();
|
||||
if (!manifest) return null;
|
||||
return loadUpdateDocument(manifest.current);
|
||||
}
|
||||
|
||||
export async function getCurrentUpdatePreview(): Promise<{
|
||||
title: string;
|
||||
description: string;
|
||||
} | null> {
|
||||
const doc = await loadCurrentUpdateDocument();
|
||||
if (!doc) return null;
|
||||
|
||||
const title = doc.title ?? (doc.version ? `Paarrot ${doc.version}` : 'Release notes');
|
||||
const description =
|
||||
doc.summary ??
|
||||
doc.markdown
|
||||
.replace(/^#+\s+/gm, '')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^[-*•]\s*/, '').trim())
|
||||
.find(Boolean) ??
|
||||
'See what changed in this version.';
|
||||
|
||||
return { title, description };
|
||||
}
|
||||
@@ -105,7 +105,7 @@ function SearchResultItem({
|
||||
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
||||
|
||||
// Get custom user color from avatar metadata
|
||||
const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc);
|
||||
const customUserColor = useOtherUserColor(event.sender, room);
|
||||
|
||||
const memberPowerTag = getMemberPowerTag(event.sender);
|
||||
const tagColor = memberPowerTag?.color
|
||||
|
||||
@@ -183,7 +183,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
);
|
||||
|
||||
// Get custom user color from avatar metadata
|
||||
const replyCustomColor = useOtherUserColor(replyUserID ?? '', replyAvatarMxc);
|
||||
const replyCustomColor = useOtherUserColor(replyUserID ?? '', room);
|
||||
|
||||
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
|
||||
const replyPowerColor = replyPowerTag?.color
|
||||
|
||||
@@ -489,7 +489,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
const dmAvatarMxc =
|
||||
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
||||
(isDirect ? avatarMxc : undefined);
|
||||
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
|
||||
const dmCustomColor = useOtherUserColor(dmUserId ?? '');
|
||||
const dmFolderTabColor =
|
||||
stationery && isDirect
|
||||
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { EmojiConfettiBurst } from './types';
|
||||
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
|
||||
import { findJumboEmojiElement, getLocalBurstCanvasSize, measureJumboGlyph, setJumboEmojiHidden } from './findJumboMount';
|
||||
import {
|
||||
BurstParticle,
|
||||
drawBurstParticles,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FireworkSim,
|
||||
getFireworkDpr,
|
||||
stepFireworkSim,
|
||||
syncFireworkHero,
|
||||
} from './fireworkParticleEngine';
|
||||
|
||||
const MAX_DPR = 2;
|
||||
@@ -81,36 +82,85 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
|
||||
};
|
||||
|
||||
const { width, height } = resize();
|
||||
const jumbo = findJumboEmojiElement(burst.targetEventId);
|
||||
const jumboRect = jumbo?.getBoundingClientRect();
|
||||
const profile = getEmojiBurstProfile(primaryEmoji);
|
||||
const glyph = measureJumboGlyph(burst.targetEventId);
|
||||
const pinToJumbo = Boolean(profile.morphTo && glyph);
|
||||
|
||||
const origin = {
|
||||
x: jumboRect ? jumboRect.left + jumboRect.width / 2 : burst.origin.x,
|
||||
y: jumboRect ? jumboRect.top + jumboRect.height / 2 : burst.origin.y,
|
||||
maskRadius: burst.origin.maskRadius ?? 36,
|
||||
x: glyph ? glyph.x : burst.origin.x,
|
||||
y: glyph ? glyph.y : burst.origin.y,
|
||||
maskRadius: glyph ? glyph.size * 0.5 : burst.origin.maskRadius ?? 36,
|
||||
};
|
||||
|
||||
// Clamp origin into the viewport so off-screen messages still blast on-screen.
|
||||
origin.x = Math.min(width - 24, Math.max(24, origin.x));
|
||||
origin.y = Math.min(height - 24, Math.max(24, origin.y));
|
||||
// Keep morph heroes glued to the message emoji; only clamp undirected fireworks.
|
||||
if (!pinToJumbo) {
|
||||
origin.x = Math.min(width - 24, Math.max(24, origin.x));
|
||||
origin.y = Math.min(height - 24, Math.max(24, origin.y));
|
||||
}
|
||||
|
||||
const profile = getEmojiBurstProfile(primaryEmoji);
|
||||
simRef.current = createFireworkSim(width, height, origin, primaryEmoji, profile);
|
||||
simRef.current = createFireworkSim(
|
||||
width,
|
||||
height,
|
||||
origin,
|
||||
primaryEmoji,
|
||||
profile,
|
||||
performance.now(),
|
||||
glyph?.size
|
||||
);
|
||||
|
||||
const revealJumbo = () => {
|
||||
setJumboEmojiHidden(burst.targetEventId, false);
|
||||
};
|
||||
|
||||
const syncHero = () => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
if (!sim.hero) {
|
||||
revealJumbo();
|
||||
return;
|
||||
}
|
||||
const metrics = measureJumboGlyph(burst.targetEventId);
|
||||
syncFireworkHero(sim, metrics);
|
||||
// Re-query every frame so React remounts still stay hidden.
|
||||
setJumboEmojiHidden(burst.targetEventId, true);
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
// Extra Things: match CSS size; sim keeps its launch-time dimensions.
|
||||
// Extra Things: match CSS size; sim keeps its launch-time world size.
|
||||
resize();
|
||||
syncHero();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', syncHero, true);
|
||||
|
||||
const finish = () => {
|
||||
revealJumbo();
|
||||
onCompleteRef.current(burst.id);
|
||||
frameRef.current = null;
|
||||
lastFrameTimeRef.current = null;
|
||||
};
|
||||
|
||||
// Hide immediately before first paint of the stand-in.
|
||||
if (pinToJumbo) {
|
||||
setJumboEmojiHidden(burst.targetEventId, true);
|
||||
}
|
||||
|
||||
const tick = (now: number) => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
|
||||
syncHero();
|
||||
|
||||
const last = lastFrameTimeRef.current ?? now;
|
||||
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
||||
lastFrameTimeRef.current = now;
|
||||
|
||||
const alive = stepFireworkSim(sim, now, dtSeconds);
|
||||
// Hero may have just been dismissed — reveal real jumbo immediately.
|
||||
if (!sim.hero) {
|
||||
revealJumbo();
|
||||
}
|
||||
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
@@ -120,9 +170,7 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
|
||||
return;
|
||||
}
|
||||
|
||||
onCompleteRef.current(burst.id);
|
||||
frameRef.current = null;
|
||||
lastFrameTimeRef.current = null;
|
||||
finish();
|
||||
};
|
||||
|
||||
lastFrameTimeRef.current = performance.now();
|
||||
@@ -130,11 +178,15 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', syncHero, true);
|
||||
revealJumbo();
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
simRef.current = null;
|
||||
// Extra Things: allow Strict Mode remount to start a fresh burst.
|
||||
spawnedRef.current = false;
|
||||
};
|
||||
}, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ export type EmojiBurstProfile = {
|
||||
wobble?: boolean;
|
||||
/** Full-viewport overlay (Box2D pile for fireworks). */
|
||||
fullscreen?: boolean;
|
||||
/** Soft color wash applied to matching particle glyphs (e.g. green spit). */
|
||||
tintColor?: string;
|
||||
/** Emojis that receive `tintColor` when drawn. */
|
||||
tintEmojis?: string[];
|
||||
/** Swap the primary face to this emoji mid-burst (e.g. 🤢 → 🤮). */
|
||||
morphTo?: string;
|
||||
/** Delay before `morphTo` kicks in. */
|
||||
morphAfterMs?: number;
|
||||
/** How long the stand-in hero face stays before the real jumbo returns.
|
||||
* Omit to keep it up until the last particle spawns. */
|
||||
heroDurationMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
||||
@@ -382,6 +393,58 @@ const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
|
||||
companionChance: 0.5,
|
||||
twinkle: true,
|
||||
},
|
||||
// Full-viewport spew — downward cone, green-tinted droplets, piles on the floor.
|
||||
'🤢': {
|
||||
style: 'firework',
|
||||
fullscreen: true,
|
||||
particleCount: 420,
|
||||
gravity: 1200,
|
||||
drag: 1,
|
||||
speedMin: 380,
|
||||
speedMax: 980,
|
||||
launchUpMin: 0,
|
||||
launchUpMax: 40,
|
||||
spinMin: -560,
|
||||
spinMax: 560,
|
||||
fontSizeMin: 18,
|
||||
fontSizeMax: 34,
|
||||
heroFontSizeMin: 44,
|
||||
heroFontSizeMax: 58,
|
||||
// Downward throw-up cone (π/2 = down in canvas space).
|
||||
angleBias: Math.PI / 2,
|
||||
angleSpread: Math.PI * 1.05,
|
||||
companions: ['💦', '💧', '💚'],
|
||||
companionChance: 1,
|
||||
tintColor: 'rgba(72, 190, 48, 0.72)',
|
||||
tintEmojis: ['💦', '💧'],
|
||||
morphTo: '🤮',
|
||||
morphAfterMs: 220,
|
||||
wobble: true,
|
||||
},
|
||||
'🤮': {
|
||||
style: 'firework',
|
||||
fullscreen: true,
|
||||
particleCount: 460,
|
||||
gravity: 1250,
|
||||
drag: 1,
|
||||
speedMin: 420,
|
||||
speedMax: 1050,
|
||||
launchUpMin: 0,
|
||||
launchUpMax: 30,
|
||||
spinMin: -600,
|
||||
spinMax: 600,
|
||||
fontSizeMin: 18,
|
||||
fontSizeMax: 36,
|
||||
heroFontSizeMin: 46,
|
||||
heroFontSizeMax: 60,
|
||||
angleBias: Math.PI / 2,
|
||||
angleSpread: Math.PI * 1.15,
|
||||
companions: ['💦', '💧', '💚'],
|
||||
companionChance: 1,
|
||||
tintColor: 'rgba(72, 190, 48, 0.72)',
|
||||
tintEmojis: ['💦', '💧'],
|
||||
wobble: true,
|
||||
},
|
||||
'🐱': {
|
||||
style: 'bounce',
|
||||
particleCount: 20,
|
||||
|
||||
@@ -6,6 +6,60 @@ export function findJumboEmojiElement(targetEventId: string): HTMLElement | null
|
||||
return jumbo instanceof HTMLElement ? jumbo : null;
|
||||
}
|
||||
|
||||
export type JumboGlyphMetrics = {
|
||||
/** Outer jumbo mount (message body) — hide/show this. */
|
||||
mount: HTMLElement;
|
||||
/** Visual glyph center in viewport coords. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** CSS px size matching the rendered emoji (usually computed font-size). */
|
||||
size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Measure the on-screen jumbo glyph. Prefer computed font-size so the canvas
|
||||
* stand-in matches unicode emoji; fall back to the glyph box for images.
|
||||
*/
|
||||
export function measureJumboGlyph(targetEventId: string): JumboGlyphMetrics | null {
|
||||
const mount = findJumboEmojiElement(targetEventId);
|
||||
if (!mount) return null;
|
||||
|
||||
const glyph =
|
||||
(mount.querySelector('[data-emoticon]') as HTMLElement | null) ||
|
||||
(mount.querySelector('img') as HTMLElement | null) ||
|
||||
mount;
|
||||
|
||||
const rect = glyph.getBoundingClientRect();
|
||||
const fontSize =
|
||||
parseFloat(getComputedStyle(glyph).fontSize) ||
|
||||
parseFloat(getComputedStyle(mount).fontSize) ||
|
||||
0;
|
||||
|
||||
const isImg = glyph instanceof HTMLImageElement || glyph.tagName === 'IMG';
|
||||
const size = isImg
|
||||
? Math.max(rect.width, rect.height)
|
||||
: fontSize > 0
|
||||
? fontSize
|
||||
: Math.max(rect.width, rect.height);
|
||||
|
||||
return {
|
||||
mount,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
export function setJumboEmojiHidden(targetEventId: string, hidden: boolean) {
|
||||
const mount = findJumboEmojiElement(targetEventId);
|
||||
if (!mount) return;
|
||||
if (hidden) {
|
||||
mount.style.visibility = 'hidden';
|
||||
} else {
|
||||
mount.style.removeProperty('visibility');
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalBurstCanvasSize(maskRadius: number): number {
|
||||
return Math.max(300, Math.round(maskRadius * 6.5));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* Same vibe as late-90s/MSN page effects, just with emoji.
|
||||
*/
|
||||
import { BurstPoint } from './burstOrigin';
|
||||
import { pickParticleEmoji, type EmojiBurstProfile } from './emojiParticleProfiles';
|
||||
import {
|
||||
pickParticleEmoji,
|
||||
sampleBurstAngle,
|
||||
type EmojiBurstProfile,
|
||||
} from './emojiParticleProfiles';
|
||||
|
||||
const EMOJI_CACHE_PX = 40;
|
||||
const EMOJI_CACHE_SCALE = 2;
|
||||
@@ -43,9 +47,25 @@ export type FireworkParticle = {
|
||||
settled: boolean;
|
||||
};
|
||||
|
||||
type HeroFace = {
|
||||
emoji: string;
|
||||
emojiCanvas: HTMLCanvasElement;
|
||||
/** Target CSS pixel size of the glyph (matches jumbo font-size). */
|
||||
drawSize: number;
|
||||
halfSize: number;
|
||||
/** canvasPx / fontPx — draw box is drawSize * canvasScale so the glyph isn't cropped. */
|
||||
canvasScale: number;
|
||||
morphed: boolean;
|
||||
};
|
||||
|
||||
export type FireworkSim = {
|
||||
particles: FireworkParticle[];
|
||||
profile: EmojiBurstProfile;
|
||||
primaryEmoji: string;
|
||||
morphAtMs: number | null;
|
||||
/** When to drop the stand-in hero and reveal the real jumbo again. */
|
||||
heroUntilMs: number | null;
|
||||
hero: HeroFace | null;
|
||||
width: number;
|
||||
height: number;
|
||||
origin: BurstPoint;
|
||||
@@ -66,11 +86,35 @@ function lerp(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
||||
const cached = emojiCanvasCache.get(emoji);
|
||||
if (cached) return cached;
|
||||
const HERO_PAD_RATIO = 0.24;
|
||||
|
||||
type EmojiBitmap = {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** Multiply CSS glyph size by this when drawImage'ing to keep padding uncropped. */
|
||||
canvasScale: number;
|
||||
};
|
||||
|
||||
function getEmojiCanvas(emoji: string, tintColor?: string, cssPx?: number): HTMLCanvasElement {
|
||||
return getEmojiBitmap(emoji, tintColor, cssPx, 0).canvas;
|
||||
}
|
||||
|
||||
function getEmojiBitmap(
|
||||
emoji: string,
|
||||
tintColor: string | undefined,
|
||||
cssPx: number | undefined,
|
||||
padRatio: number
|
||||
): EmojiBitmap {
|
||||
const targetCss = Math.max(16, Math.round(cssPx ?? EMOJI_CACHE_PX));
|
||||
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1;
|
||||
const fontPx = Math.round(targetCss * dpr);
|
||||
const pad = Math.ceil(fontPx * padRatio);
|
||||
const size = fontPx + pad * 2;
|
||||
const cacheKey = `${emoji}|px:${fontPx}|pad:${pad}|tint:${tintColor ?? ''}`;
|
||||
const cached = emojiCanvasCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return { canvas: cached, canvasScale: size / fontPx };
|
||||
}
|
||||
|
||||
const size = EMOJI_CACHE_PX * EMOJI_CACHE_SCALE;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
@@ -79,11 +123,52 @@ function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.font = `${EMOJI_CACHE_PX}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||
ctx.fillText(emoji, size / 2, size / 2);
|
||||
ctx.font = `${fontPx}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||
// Slight optical nudge — color emoji fonts sit high in the em box.
|
||||
ctx.fillText(emoji, size / 2, size / 2 + fontPx * 0.06);
|
||||
|
||||
if (tintColor) {
|
||||
ctx.globalCompositeOperation = 'source-atop';
|
||||
ctx.fillStyle = tintColor;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
}
|
||||
emojiCanvasCache.set(emoji, canvas);
|
||||
return canvas;
|
||||
emojiCanvasCache.set(cacheKey, canvas);
|
||||
return { canvas, canvasScale: size / Math.max(fontPx, 1) };
|
||||
}
|
||||
|
||||
function setHeroEmoji(hero: HeroFace, emoji: string) {
|
||||
const bitmap = getEmojiBitmap(emoji, undefined, hero.drawSize, HERO_PAD_RATIO);
|
||||
hero.emoji = emoji;
|
||||
hero.emojiCanvas = bitmap.canvas;
|
||||
hero.canvasScale = bitmap.canvasScale;
|
||||
}
|
||||
|
||||
function setHeroSize(hero: HeroFace, cssPx: number) {
|
||||
if (!(cssPx > 0)) return;
|
||||
if (Math.abs(cssPx - hero.drawSize) < 0.5) return;
|
||||
hero.drawSize = cssPx;
|
||||
hero.halfSize = cssPx / 2;
|
||||
const bitmap = getEmojiBitmap(hero.emoji, undefined, cssPx, HERO_PAD_RATIO);
|
||||
hero.emojiCanvas = bitmap.canvas;
|
||||
hero.canvasScale = bitmap.canvasScale;
|
||||
}
|
||||
|
||||
function particleTint(profile: EmojiBurstProfile, emoji: string): string | undefined {
|
||||
if (!profile.tintColor || !profile.tintEmojis?.length) return undefined;
|
||||
return profile.tintEmojis.includes(emoji) ? profile.tintColor : undefined;
|
||||
}
|
||||
|
||||
/** Spew particles for morph bursts — never the morphTo face (that stays on the hero). */
|
||||
function pickSpewEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
||||
const morphTo = profile.morphTo;
|
||||
const companions = (profile.companions ?? []).filter((e) => e !== morphTo);
|
||||
if (morphTo && companions.length) {
|
||||
return companions[Math.floor(Math.random() * companions.length)] ?? primaryEmoji;
|
||||
}
|
||||
const picked = pickParticleEmoji(profile, primaryEmoji);
|
||||
return picked === morphTo ? primaryEmoji : picked;
|
||||
}
|
||||
|
||||
function buildSpawnPlan(
|
||||
@@ -91,13 +176,16 @@ function buildSpawnPlan(
|
||||
primaryEmoji: string,
|
||||
startMs: number
|
||||
): SpawnItem[] {
|
||||
const plan: SpawnItem[] = [
|
||||
{
|
||||
atMs: startMs,
|
||||
emoji: primaryEmoji,
|
||||
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
|
||||
},
|
||||
];
|
||||
// Anchored morphing hero is drawn separately — skip a flying hero twin.
|
||||
const plan: SpawnItem[] = profile.morphTo
|
||||
? []
|
||||
: [
|
||||
{
|
||||
atMs: startMs,
|
||||
emoji: primaryEmoji,
|
||||
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
|
||||
},
|
||||
];
|
||||
|
||||
const count = profile.particleCount;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
@@ -109,7 +197,7 @@ function buildSpawnPlan(
|
||||
80 +
|
||||
eased * SPAWN_WINDOW_MS +
|
||||
(Math.random() - 0.5) * 70,
|
||||
emoji: pickParticleEmoji(profile, primaryEmoji),
|
||||
emoji: pickSpewEmoji(profile, primaryEmoji),
|
||||
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
||||
});
|
||||
}
|
||||
@@ -118,6 +206,18 @@ function buildSpawnPlan(
|
||||
return plan;
|
||||
}
|
||||
|
||||
function applyMorph(sim: FireworkSim, now: number) {
|
||||
const to = sim.profile.morphTo;
|
||||
if (!to || sim.morphAtMs === null || now < sim.morphAtMs) return;
|
||||
if (sim.hero?.morphed) return;
|
||||
|
||||
// Only the stand-in face morphs — spray stays droplets / companions.
|
||||
if (sim.hero) {
|
||||
setHeroEmoji(sim.hero, to);
|
||||
sim.hero.morphed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePile(sim: FireworkSim): CanvasRenderingContext2D {
|
||||
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -145,20 +245,36 @@ function stampSettled(sim: FireworkSim, p: FireworkParticle) {
|
||||
function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) {
|
||||
const { profile, origin } = sim;
|
||||
const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25);
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const directed = profile.angleSpread !== undefined;
|
||||
const angle = directed ? sampleBurstAngle(profile) : Math.random() * Math.PI * 2;
|
||||
const speed = lerp(profile.speedMin, profile.speedMax);
|
||||
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
||||
const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85;
|
||||
const emoji = item.emoji;
|
||||
|
||||
// Directed cones (e.g. throw-up) push along the sampled angle.
|
||||
// Undirected fireworks keep the classic upward launch kick.
|
||||
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 40;
|
||||
let vy = Math.sin(angle) * speed;
|
||||
if (directed) {
|
||||
const bias = profile.angleBias ?? angle;
|
||||
vx += Math.cos(bias) * launchUp * 0.25;
|
||||
vy += Math.sin(bias) * launchUp;
|
||||
} else {
|
||||
vy -= launchUp;
|
||||
}
|
||||
|
||||
const tint = particleTint(profile, emoji);
|
||||
|
||||
sim.particles.push({
|
||||
x: origin.x + (Math.random() - 0.5) * jitter,
|
||||
y: origin.y + (Math.random() - 0.5) * jitter,
|
||||
vx: Math.cos(angle) * speed + (Math.random() - 0.5) * 40,
|
||||
vy: Math.sin(angle) * speed - launchUp,
|
||||
vx,
|
||||
vy,
|
||||
spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
|
||||
angle: (Math.random() - 0.5) * 40,
|
||||
emoji: item.emoji,
|
||||
emojiCanvas: getEmojiCanvas(item.emoji),
|
||||
emoji,
|
||||
emojiCanvas: getEmojiCanvas(emoji, tint),
|
||||
drawSize,
|
||||
halfSize: drawSize / 2,
|
||||
bornAt: now,
|
||||
@@ -203,17 +319,49 @@ export function createFireworkSim(
|
||||
originPx: BurstPoint,
|
||||
primaryEmoji: string,
|
||||
profile: EmojiBurstProfile,
|
||||
startMs = performance.now()
|
||||
startMs = performance.now(),
|
||||
/** Pixel size of the source jumbo emoji — hero face matches this when set. */
|
||||
heroSizePx?: number
|
||||
): FireworkSim {
|
||||
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL));
|
||||
const fallbackHero =
|
||||
lerp(profile.heroFontSizeMin, profile.heroFontSizeMax) * EMOJI_CACHE_SCALE * 0.95;
|
||||
const heroSize = heroSizePx && heroSizePx > 0 ? heroSizePx : fallbackHero;
|
||||
const hero: HeroFace | null = profile.morphTo
|
||||
? (() => {
|
||||
const bitmap = getEmojiBitmap(primaryEmoji, undefined, heroSize, HERO_PAD_RATIO);
|
||||
return {
|
||||
emoji: primaryEmoji,
|
||||
emojiCanvas: bitmap.canvas,
|
||||
drawSize: heroSize,
|
||||
halfSize: heroSize / 2,
|
||||
canvasScale: bitmap.canvasScale,
|
||||
morphed: false,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
const spawnPlan = buildSpawnPlan(profile, primaryEmoji, startMs);
|
||||
// Keep the spewing stand-in up for the whole fountain — not just a short flash.
|
||||
const lastSpawnAt = spawnPlan.length > 0 ? spawnPlan[spawnPlan.length - 1].atMs : startMs;
|
||||
const heroUntilMs = profile.morphTo
|
||||
? profile.heroDurationMs != null
|
||||
? startMs + profile.heroDurationMs
|
||||
: lastSpawnAt + 120
|
||||
: null;
|
||||
|
||||
return {
|
||||
particles: [],
|
||||
profile,
|
||||
primaryEmoji,
|
||||
morphAtMs: profile.morphTo ? startMs + (profile.morphAfterMs ?? 220) : null,
|
||||
heroUntilMs,
|
||||
hero,
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
origin: originPx,
|
||||
startMs,
|
||||
spawnPlan: buildSpawnPlan(profile, primaryEmoji, startMs),
|
||||
spawnPlan,
|
||||
spawnCursor: 0,
|
||||
floorY: heightPx - FLOOR_PAD,
|
||||
pileCols: new Uint16Array(colCount),
|
||||
@@ -223,10 +371,27 @@ export function createFireworkSim(
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the stand-in face glued to the live jumbo metrics. */
|
||||
export function syncFireworkHero(
|
||||
sim: FireworkSim,
|
||||
metrics: { x: number; y: number; size: number } | null
|
||||
) {
|
||||
if (!sim.hero || !metrics) return;
|
||||
sim.origin.x = metrics.x;
|
||||
sim.origin.y = metrics.y;
|
||||
setHeroSize(sim.hero, metrics.size);
|
||||
}
|
||||
|
||||
/** @returns false when the burst should be removed. */
|
||||
export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean {
|
||||
if (now - sim.startMs > FIREWORK_TOTAL_MS) return false;
|
||||
|
||||
applyMorph(sim, now);
|
||||
|
||||
if (sim.hero && sim.heroUntilMs !== null && now >= sim.heroUntilMs) {
|
||||
sim.hero = null;
|
||||
}
|
||||
|
||||
const dt = Math.min(dtSeconds, 0.05);
|
||||
const g = sim.profile.gravity;
|
||||
|
||||
@@ -299,6 +464,20 @@ export function drawFireworkSim(
|
||||
context.drawImage(sim.pileCanvas, 0, 0);
|
||||
}
|
||||
|
||||
// Anchored face at the spew origin — morphs 🤢 → 🤮 mid-throw, then drops away.
|
||||
if (sim.hero && fade > 0.02) {
|
||||
const hero = sim.hero;
|
||||
const heaveAmp = Math.max(1.5, hero.drawSize * 0.02);
|
||||
const heave = Math.sin(elapsed / 70) * (hero.morphed ? heaveAmp * 1.25 : heaveAmp);
|
||||
// Scale includes bitmap padding so the glyph matches jumbo size without clipping.
|
||||
const box = hero.drawSize * hero.canvasScale;
|
||||
const half = box / 2;
|
||||
context.globalAlpha = fade;
|
||||
context.setTransform(1, 0, 0, 1, sim.origin.x, sim.origin.y + heave);
|
||||
context.drawImage(hero.emojiCanvas, -half, -half, box, box);
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
for (let i = 0; i < sim.particles.length; i += 1) {
|
||||
const p = sim.particles[i];
|
||||
if (p.settled) continue;
|
||||
|
||||
@@ -785,7 +785,7 @@ export const Message = as<'div', MessageProps>(
|
||||
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
||||
|
||||
// Get custom user color from avatar metadata (takes priority)
|
||||
const customUserColor = useOtherUserColor(senderId, senderAvatarMxc);
|
||||
const customUserColor = useOtherUserColor(senderId, room);
|
||||
|
||||
const tagColor = memberPowerTag?.color
|
||||
? accessibleTagColors?.get(memberPowerTag.color)
|
||||
|
||||
@@ -116,7 +116,7 @@ function PinnedMessage({
|
||||
|
||||
const sender = pinnedEvent?.getSender();
|
||||
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
||||
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
|
||||
const customUserColor = useOtherUserColor(sender ?? '', room);
|
||||
|
||||
const handleOpenClick: MouseEventHandler = (evt) => {
|
||||
evt.stopPropagation();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { getAppVersion } from '../../../utils/appVersion';
|
||||
import { useOpenReleaseNotesDialog } from '../../../components/updates-dialog/UpdatesDialogHost';
|
||||
import { getCurrentUpdatePreview } from '../../../data/updateNotes';
|
||||
import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
@@ -15,6 +17,7 @@ type AboutProps = {
|
||||
};
|
||||
export function About({ requestClose }: AboutProps) {
|
||||
const mx = useMatrixClient();
|
||||
const openReleaseNotes = useOpenReleaseNotesDialog();
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
|
||||
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
|
||||
@@ -93,10 +96,13 @@ export function About({ requestClose }: AboutProps) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getVersion().then(setVersion).catch(() => setVersion('unknown'));
|
||||
getAppVersion().then(setVersion).catch(() => setVersion('unknown'));
|
||||
refreshProtocolStatus();
|
||||
getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null));
|
||||
}, [refreshProtocolStatus]);
|
||||
|
||||
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(null);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
@@ -151,6 +157,36 @@ export function About({ requestClose }: AboutProps) {
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">What's new</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
>
|
||||
<SettingTile
|
||||
title={updatePreview?.title ?? 'Release notes'}
|
||||
description={
|
||||
updatePreview
|
||||
? `Paarrot ${version} — ${updatePreview.description}`
|
||||
: 'View highlights from the latest Paarrot update.'
|
||||
}
|
||||
after={
|
||||
<Button
|
||||
onClick={openReleaseNotes}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
>
|
||||
<Text size="B300">View updates</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Options</Text>
|
||||
<SequenceCard
|
||||
|
||||
@@ -11,7 +11,7 @@ import React, {
|
||||
import classNames from 'classnames';
|
||||
import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
@@ -19,7 +19,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl';
|
||||
import { nameInitials, getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../../utils/common';
|
||||
import { getTextShadowColor } from '../../../utils/common';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||
@@ -29,30 +29,22 @@ import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
||||
import { AngleSelector } from '../../../components/AngleSelector';
|
||||
import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor';
|
||||
import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner';
|
||||
import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
|
||||
import { useUserColorPreference } from '../../../hooks/useUserColor';
|
||||
import { useUserBanner } from '../../../hooks/useUserBanner';
|
||||
import { useUserPresence } from '../../../hooks/useUserPresence';
|
||||
import { useTheme, ThemeKind } from '../../../hooks/useTheme';
|
||||
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
||||
import {
|
||||
embedMetadataInImage,
|
||||
detectImageFormat,
|
||||
getMimeType,
|
||||
getExtension,
|
||||
ImageMetadata,
|
||||
needsPngForMetadata,
|
||||
convertImageDataToPng,
|
||||
uint8ArrayToBlob,
|
||||
} from '../../../utils/imageMetadata';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import {
|
||||
ColorPreference,
|
||||
hasColorPreference,
|
||||
resolveColorForTheme,
|
||||
} from '../../../utils/profileFields';
|
||||
|
||||
/**
|
||||
* Banner upload component for user's profile banner
|
||||
* Stored in avatar image metadata, visible to other Paarrot users
|
||||
* Banner upload component for user's profile banner (MSC4427 via MSC4133).
|
||||
*/
|
||||
function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTMLDivElement>; nameRef: React.RefObject<HTMLDivElement> }) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -98,26 +90,25 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
: undefined;
|
||||
const authenticatedCoverUrl = useAuthenticatedMediaUrl(avatarCoverUrl, useAuthentication);
|
||||
|
||||
const [userColor, setUserColor] = useUserColor();
|
||||
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
|
||||
const theme = useTheme();
|
||||
const [colorPreference, setColorPreference, colorLoading] = useUserColorPreference();
|
||||
const [localOnDark, setLocalOnDark] = useState('#ffd9f5');
|
||||
const [localOnLight, setLocalOnLight] = useState('#440000');
|
||||
const [savingColor, setSavingColor] = useState(false);
|
||||
const [colorError, setColorError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (userColor) {
|
||||
setLocalColor(userColor);
|
||||
}
|
||||
}, [userColor]);
|
||||
if (colorPreference?.on_dark) setLocalOnDark(colorPreference.on_dark);
|
||||
if (colorPreference?.on_light) setLocalOnLight(colorPreference.on_light);
|
||||
}, [colorPreference]);
|
||||
|
||||
const handleColorSave = async () => {
|
||||
setSavingColor(true);
|
||||
setColorError(undefined);
|
||||
try {
|
||||
await setUserColor(localColor);
|
||||
// Sync user object to propagate changes throughout app
|
||||
await syncUserAvatar();
|
||||
await setColorPreference({ on_dark: localOnDark, on_light: localOnLight });
|
||||
} catch (e) {
|
||||
setColorError('Failed to save color');
|
||||
setColorError('Failed to save colors. Your server may not support profile fields (MSC4133).');
|
||||
}
|
||||
setSavingColor(false);
|
||||
};
|
||||
@@ -126,141 +117,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
setSavingColor(true);
|
||||
setColorError(undefined);
|
||||
try {
|
||||
await setUserColor(undefined);
|
||||
await setColorPreference(undefined);
|
||||
setLocalOnDark('#ffd9f5');
|
||||
setLocalOnLight('#440000');
|
||||
} catch (e) {
|
||||
setColorError('Failed to remove color');
|
||||
setColorError('Failed to remove colors');
|
||||
}
|
||||
setSavingColor(false);
|
||||
};
|
||||
|
||||
// Profile style settings (border color and gradient)
|
||||
const [profileStyle, setProfileStyle, styleLoading] = useUserProfileStyle();
|
||||
// Initialize with transparent alpha so saved values show in preview until user edits
|
||||
const [localBorderColor, setLocalBorderColor] = useState<RgbaColor>({ r: 59, g: 130, b: 246, a: 0 });
|
||||
const [localGradientStart, setLocalGradientStart] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
|
||||
const [localGradientStop, setLocalGradientStop] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
|
||||
const [localGradientDirection, setLocalGradientDirection] = useState(180); // degrees (180 = top to bottom)
|
||||
const [savingStyle, setSavingStyle] = useState(false);
|
||||
const [styleError, setStyleError] = useState<string>();
|
||||
// Track if user has started editing (to show local values in preview)
|
||||
const [editingBorder, setEditingBorder] = useState(false);
|
||||
const [editingGradient, setEditingGradient] = useState(false);
|
||||
|
||||
// Helper to convert RGBA to hex with alpha (#RRGGBBAA)
|
||||
const rgbaToHex = (rgba: RgbaColor): string => {
|
||||
const r = rgba.r.toString(16).padStart(2, '0');
|
||||
const g = rgba.g.toString(16).padStart(2, '0');
|
||||
const b = rgba.b.toString(16).padStart(2, '0');
|
||||
const a = Math.round(rgba.a * 255).toString(16).padStart(2, '0');
|
||||
return `#${r}${g}${b}${a}`;
|
||||
};
|
||||
|
||||
// Helper to convert hex with alpha to RGBA
|
||||
const hexToRgba = (hex: string): RgbaColor => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
|
||||
if (result) {
|
||||
return {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
a: result[4] ? parseInt(result[4], 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
return { r: 59, g: 130, b: 246, a: 1 };
|
||||
};
|
||||
|
||||
// Helper to parse degrees from direction string (e.g., "180deg" -> 180)
|
||||
const parseDirectionDegrees = (direction: string): number => {
|
||||
const degMatch = direction.match(/^(\d+)deg$/i);
|
||||
if (degMatch) return parseInt(degMatch[1], 10);
|
||||
// Fallback for legacy "to X" format
|
||||
const keywordMap: Record<string, number> = {
|
||||
'to top': 0,
|
||||
'to top right': 45,
|
||||
'to right': 90,
|
||||
'to bottom right': 135,
|
||||
'to bottom': 180,
|
||||
'to bottom left': 225,
|
||||
'to left': 270,
|
||||
'to top left': 315,
|
||||
};
|
||||
return keywordMap[direction.toLowerCase()] ?? 180;
|
||||
};
|
||||
|
||||
// Sync local style state with loaded profile style
|
||||
useEffect(() => {
|
||||
if (profileStyle.avatarBorderColor) {
|
||||
setLocalBorderColor(hexToRgba(profileStyle.avatarBorderColor));
|
||||
}
|
||||
if (profileStyle.gradient) {
|
||||
setLocalGradientStart(hexToRgba(profileStyle.gradient.startColor));
|
||||
setLocalGradientStop(hexToRgba(profileStyle.gradient.stopColor));
|
||||
setLocalGradientDirection(parseDirectionDegrees(profileStyle.gradient.direction));
|
||||
}
|
||||
}, [profileStyle]);
|
||||
|
||||
const handleBorderColorSave = async () => {
|
||||
setSavingStyle(true);
|
||||
setStyleError(undefined);
|
||||
try {
|
||||
await setProfileStyle({ avatarBorderColor: rgbaToHex(localBorderColor) });
|
||||
await syncUserAvatar();
|
||||
setEditingBorder(false);
|
||||
} catch (e) {
|
||||
setStyleError('Failed to save border color');
|
||||
}
|
||||
setSavingStyle(false);
|
||||
};
|
||||
|
||||
const handleBorderColorRemove = async () => {
|
||||
setSavingStyle(true);
|
||||
setStyleError(undefined);
|
||||
try {
|
||||
await setProfileStyle({ avatarBorderColor: undefined });
|
||||
await syncUserAvatar();
|
||||
setEditingBorder(false);
|
||||
setLocalBorderColor({ r: 59, g: 130, b: 246, a: 0 });
|
||||
} catch (e) {
|
||||
setStyleError('Failed to remove border color');
|
||||
}
|
||||
setSavingStyle(false);
|
||||
};
|
||||
|
||||
const handleGradientSave = async () => {
|
||||
setSavingStyle(true);
|
||||
setStyleError(undefined);
|
||||
try {
|
||||
await setProfileStyle({
|
||||
gradient: {
|
||||
direction: `${localGradientDirection}deg`,
|
||||
startColor: rgbaToHex(localGradientStart),
|
||||
stopColor: rgbaToHex(localGradientStop),
|
||||
},
|
||||
});
|
||||
await syncUserAvatar();
|
||||
setEditingGradient(false);
|
||||
} catch (e) {
|
||||
setStyleError('Failed to save gradient');
|
||||
}
|
||||
setSavingStyle(false);
|
||||
};
|
||||
|
||||
const handleGradientRemove = async () => {
|
||||
setSavingStyle(true);
|
||||
setStyleError(undefined);
|
||||
try {
|
||||
await setProfileStyle({ gradient: undefined });
|
||||
await syncUserAvatar();
|
||||
setEditingGradient(false);
|
||||
setLocalGradientStart({ r: 0, g: 0, b: 0, a: 0 });
|
||||
setLocalGradientStop({ r: 0, g: 0, b: 0, a: 0 });
|
||||
setLocalGradientDirection(180);
|
||||
} catch (e) {
|
||||
setStyleError('Failed to remove gradient');
|
||||
}
|
||||
setSavingStyle(false);
|
||||
};
|
||||
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editedName, setEditedName] = useState(profile.displayName || '');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
@@ -475,82 +340,14 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
// 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');
|
||||
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
let response = await fetch(httpUrl, {
|
||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
|
||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
||||
console.warn('[Profile] Auth failed (401), attempting unauthenticated fallback for avatar fetch');
|
||||
response = await fetch(httpUrl);
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
|
||||
let avatarData: ArrayBuffer | Uint8Array = await response.arrayBuffer();
|
||||
|
||||
let format = detectImageFormat(avatarData);
|
||||
if (format === 'unknown') throw new Error('Unsupported avatar image format');
|
||||
|
||||
// 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 = uint8ArrayToBlob(modifiedData, mimeType);
|
||||
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
|
||||
|
||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
||||
|
||||
await mx.setAvatarUrl(upload.mxc);
|
||||
const userId = mx.getUserId();
|
||||
if (userId) {
|
||||
const user = mx.getUser(userId);
|
||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
||||
user.setAvatarUrl(uploadResponse.content_uri);
|
||||
if (user && user.avatarUrl !== upload.mxc) {
|
||||
user.setAvatarUrl(upload.mxc);
|
||||
}
|
||||
}
|
||||
|
||||
setAvatarFile(undefined);
|
||||
await syncUserAvatar();
|
||||
} catch (e: any) {
|
||||
@@ -558,7 +355,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
}
|
||||
setSaving(false);
|
||||
},
|
||||
[mx, useAuthentication, userBanner, userColor, profileStyle, syncUserAvatar]
|
||||
[mx, syncUserAvatar]
|
||||
);
|
||||
|
||||
const handleRemoveBanner = async () => {
|
||||
@@ -574,24 +371,11 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
// Build gradient CSS for preview (shows local editing values when user is editing,
|
||||
// otherwise falls back to saved profile style)
|
||||
const previewGradient = editingGradient
|
||||
? `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`
|
||||
: profileStyle.gradient
|
||||
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
|
||||
: undefined;
|
||||
|
||||
// Build border color for preview (shows local editing value when user is editing,
|
||||
// otherwise falls back to saved profile style)
|
||||
const previewBorderColor = editingBorder
|
||||
? rgbaToHex(localBorderColor)
|
||||
: profileStyle.avatarBorderColor;
|
||||
|
||||
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
|
||||
const previewProfileColor = userColor || getColorMXIDValue(userId);
|
||||
|
||||
const previewPreference: ColorPreference = { on_dark: localOnDark, on_light: localOnLight };
|
||||
const previewProfileColor =
|
||||
resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark);
|
||||
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
||||
const hasSavedColors = hasColorPreference(colorPreference);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
@@ -627,7 +411,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
backgroundColor: bannerBlobUrl ? 'transparent' : (userColor || colorMXID(userId)),
|
||||
backgroundColor: bannerBlobUrl ? 'transparent' : colorMXID(userId),
|
||||
filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -740,7 +524,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
as="button"
|
||||
onClick={handleAvatarClick}
|
||||
style={{
|
||||
backgroundColor: previewBorderColor || color.Surface.Container,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
@@ -748,7 +532,6 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
}}
|
||||
>
|
||||
<AvatarPresence
|
||||
badgeBackgroundColor={previewBorderColor}
|
||||
badge={
|
||||
presence && (
|
||||
<PresenceBadge presence={presence.presence} status={presence.status} />
|
||||
@@ -760,9 +543,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
style={{
|
||||
width: toRem(72),
|
||||
height: toRem(72),
|
||||
outline: previewBorderColor
|
||||
? `${toRem(4)} solid ${previewBorderColor}`
|
||||
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -832,73 +613,20 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Profile Info Section - matches UserRoomProfile gradient section */}
|
||||
{/* Profile Info Section */}
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="400"
|
||||
gap="200"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
padding: config.space.S400,
|
||||
paddingTop: `calc(${config.space.S400} + ${toRem(36)})`,
|
||||
marginTop: toRem(-36),
|
||||
background: previewGradient,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Display Name with Color Picker */}
|
||||
<Box alignItems="Center" gap="200" justifyContent="Center">
|
||||
<HexColorPickerPopOut
|
||||
picker={
|
||||
<Box direction="Column" gap="200">
|
||||
<HexColorPicker color={localColor} onChange={setLocalColor} />
|
||||
<Box gap="100" alignItems="Center">
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(100) }}
|
||||
value={localColor}
|
||||
onChange={(e) => {
|
||||
setLocalColor(e.target.value);
|
||||
setColorError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleColorSave}
|
||||
disabled={savingColor}
|
||||
>
|
||||
<Text size="B300">{savingColor ? 'Saving...' : 'Save'}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{colorError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{colorError}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
onRemove={userColor ? handleColorRemove : undefined}
|
||||
>
|
||||
{(onOpen) => (
|
||||
<Box
|
||||
as="button"
|
||||
onClick={onOpen}
|
||||
disabled={savingColor}
|
||||
style={{
|
||||
width: toRem(24),
|
||||
height: toRem(24),
|
||||
borderRadius: toRem(6),
|
||||
backgroundColor: userColor ?? localColor,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</HexColorPickerPopOut>
|
||||
{/* Display Name */}
|
||||
<Box style={{ width: '100%' }}>
|
||||
{isEditingName ? (
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -910,6 +638,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
size="400"
|
||||
disabled={savingName}
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: 'var(--token.font-size.H400)',
|
||||
fontWeight: 'var(--token.font-weight.H400)',
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
@@ -926,19 +655,28 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
onMouseEnter={() => setHoveredArea('name')}
|
||||
onMouseLeave={() => setHoveredArea(null)}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
margin: `${toRem(-4)} ${toRem(-8)}`,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="H4"
|
||||
className={classNames(BreakWord, LineClamp3)}
|
||||
title={profile.displayName || getMxIdLocalPart(userId)}
|
||||
style={{ color: previewProfileColor, textShadow: previewTextShadow }}
|
||||
style={{
|
||||
color: previewProfileColor,
|
||||
textShadow: previewTextShadow,
|
||||
textAlign: 'center',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{profile.displayName || getMxIdLocalPart(userId) || userId}
|
||||
</Text>
|
||||
@@ -946,8 +684,9 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: toRem(-32),
|
||||
top: '50%',
|
||||
right: 0,
|
||||
transform: 'translateY(-50%)',
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderRadius: toRem(20),
|
||||
padding: toRem(6),
|
||||
@@ -980,8 +719,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
onMouseEnter={() => setHoveredArea('status')}
|
||||
onMouseLeave={() => setHoveredArea(null)}
|
||||
style={{
|
||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${toRem(6)} ${toRem(10)}`,
|
||||
borderRadius: toRem(16),
|
||||
maxWidth: toRem(250),
|
||||
@@ -1032,14 +771,13 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
className={BreakWord}
|
||||
style={{
|
||||
fontStyle: presence?.status ? 'normal' : 'italic',
|
||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
||||
}}
|
||||
>
|
||||
{presence?.status || 'Click to set a custom status...'}</Text>
|
||||
</Box>
|
||||
{presence?.status && hoveredArea === 'status' && (
|
||||
<>
|
||||
<Icon size="50" src={Icons.Pencil} style={{ color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined }} />
|
||||
<Icon size="50" src={Icons.Pencil} />
|
||||
<Box
|
||||
as="button"
|
||||
onClick={(e) => {
|
||||
@@ -1070,63 +808,42 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
borderRadius: toRem(16),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="B300"
|
||||
style={{
|
||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
||||
}}
|
||||
>
|
||||
ruv.wtf
|
||||
</Text>
|
||||
<Text size="B300">ruv.wtf</Text>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
borderRadius: toRem(16),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="B300"
|
||||
style={{
|
||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Text>
|
||||
<Text size="B300">Share</Text>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
borderRadius: toRem(16),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="B300"
|
||||
style={{
|
||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</Text>
|
||||
<Text size="B300">Admin</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Profile Style Settings */}
|
||||
{/* MSC4522 username colors */}
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="200"
|
||||
gap="300"
|
||||
style={{
|
||||
padding: config.space.S300,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
@@ -1134,111 +851,91 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Text size="H6">Profile Style</Text>
|
||||
<Text size="H6">Username colors</Text>
|
||||
<Text size="T200" style={{ opacity: 0.8 }}>
|
||||
Set how your name appears on dark and light themes (MSC4522). Other clients that support this
|
||||
spec will see your chosen colors.
|
||||
</Text>
|
||||
|
||||
{/* Avatar Border Color */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300">Avatar Border Color</Text>
|
||||
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||
<RgbaColorPicker color={localBorderColor} onChange={(c) => { setLocalBorderColor(c); setEditingBorder(true); }} />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200" style={{ opacity: 0.7 }}>
|
||||
Preview: {rgbaToHex(localBorderColor)}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
borderRadius: '50%',
|
||||
border: `${toRem(4)} solid ${rgbaToHex(localBorderColor)}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
}}
|
||||
/>
|
||||
<Box gap="100">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleBorderColorSave}
|
||||
disabled={savingStyle}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
{profileStyle.avatarBorderColor && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleBorderColorRemove}
|
||||
disabled={savingStyle}
|
||||
>
|
||||
<Text size="B300">Remove</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box gap="400" wrap="Wrap">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="T300">On dark themes</Text>
|
||||
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
|
||||
<HexColorPicker color={localOnDark} onChange={(c) => { setLocalOnDark(c); setColorError(undefined); }} />
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(120) }}
|
||||
value={localOnDark}
|
||||
onChange={(e) => {
|
||||
setLocalOnDark(e.target.value);
|
||||
setColorError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
borderRadius: toRem(8),
|
||||
backgroundColor: localOnDark,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="T300">On light themes</Text>
|
||||
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
|
||||
<HexColorPicker color={localOnLight} onChange={(c) => { setLocalOnLight(c); setColorError(undefined); }} />
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(120) }}
|
||||
value={localOnLight}
|
||||
onChange={(e) => {
|
||||
setLocalOnLight(e.target.value);
|
||||
setColorError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
borderRadius: toRem(8),
|
||||
backgroundColor: localOnLight,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Profile Gradient */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300">Profile Card Gradient</Text>
|
||||
<Box gap="200" alignItems="Start" wrap="Wrap">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200">Start Color</Text>
|
||||
<RgbaColorPicker color={localGradientStart} onChange={(c) => { setLocalGradientStart(c); setEditingGradient(true); }} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200">End Color</Text>
|
||||
<RgbaColorPicker color={localGradientStop} onChange={(c) => { setLocalGradientStop(c); setEditingGradient(true); }} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200">Direction</Text>
|
||||
<AngleSelector
|
||||
value={localGradientDirection}
|
||||
onChange={(deg) => { setLocalGradientDirection(deg); setEditingGradient(true); }}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(100),
|
||||
height: toRem(60),
|
||||
borderRadius: toRem(8),
|
||||
background: `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
<Box gap="100">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleGradientSave}
|
||||
disabled={savingStyle}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
{profileStyle.gradient && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleGradientRemove}
|
||||
disabled={savingStyle}
|
||||
>
|
||||
<Text size="B300">Remove</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleColorSave}
|
||||
disabled={savingColor || colorLoading}
|
||||
>
|
||||
<Text size="B300">{savingColor ? 'Saving...' : 'Save colors'}</Text>
|
||||
</Button>
|
||||
{hasSavedColors && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleColorRemove}
|
||||
disabled={savingColor}
|
||||
>
|
||||
<Text size="B300">Remove colors</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{styleError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>{styleError}</Text>
|
||||
{colorError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>{colorError}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,56 +1,17 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../utils/auth';
|
||||
import {
|
||||
fetchAndExtractMetadata,
|
||||
extractMetadataFromImage,
|
||||
embedMetadataInImage,
|
||||
detectImageFormat,
|
||||
getMimeType,
|
||||
getExtension,
|
||||
ImageMetadata,
|
||||
convertImageDataToPng,
|
||||
uint8ArrayToBlob,
|
||||
} from '../utils/imageMetadata';
|
||||
deleteBannerUrl,
|
||||
loadBannerUrl,
|
||||
registerBannerCacheClear,
|
||||
saveBannerUrl,
|
||||
} from '../utils/profileFields';
|
||||
|
||||
/**
|
||||
* Fetches the user's current avatar as raw image data
|
||||
*/
|
||||
async function fetchAvatarData(
|
||||
mx: MatrixClient,
|
||||
avatarMxc: string,
|
||||
useAuthentication: boolean
|
||||
): Promise<ArrayBuffer | null> {
|
||||
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||
if (!url) return null;
|
||||
|
||||
try {
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
|
||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
||||
response = await fetch(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
if (!response.ok) return null;
|
||||
return await response.arrayBuffer();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage the user's chosen profile banner, stored in avatar image metadata
|
||||
* The banner URL is embedded in avatar metadata and syncs via the avatar
|
||||
* @returns The current banner URL, a setter function, and loading state
|
||||
* Hook to manage the user's profile banner (MSC4427 via MSC4133).
|
||||
*/
|
||||
export function useUserBanner(): [
|
||||
string | undefined,
|
||||
@@ -58,280 +19,124 @@ export function useUserBanner(): [
|
||||
boolean
|
||||
] {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [banner, setBanner] = useState<string | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Extract banner from current avatar on mount and when profile changes
|
||||
useEffect(() => {
|
||||
const loadBanner = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await mx.getProfileInfo(userId);
|
||||
const avatarUrl = profile.avatar_url;
|
||||
console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl);
|
||||
|
||||
if (!avatarUrl) {
|
||||
setBanner(undefined);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
||||
if (!httpUrl) {
|
||||
setBanner(undefined);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||
console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner);
|
||||
setBanner(metadata.banner);
|
||||
} catch {
|
||||
setBanner(undefined);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadBanner();
|
||||
|
||||
// Listen for avatar changes and reload banner
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) return undefined;
|
||||
|
||||
const user = mx.getUser(userId);
|
||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => {
|
||||
console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl);
|
||||
loadBanner();
|
||||
};
|
||||
|
||||
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
||||
|
||||
return () => {
|
||||
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
||||
};
|
||||
}, [mx, useAuthentication]);
|
||||
|
||||
const updateBanner = useCallback(async (newBanner: string | undefined) => {
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) {
|
||||
throw new Error('No user ID');
|
||||
setLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const profile = await mx.getProfileInfo(userId);
|
||||
const avatarUrl = profile.avatar_url;
|
||||
let cancelled = false;
|
||||
|
||||
if (!avatarUrl) {
|
||||
throw new Error('No avatar set. Please upload an avatar first.');
|
||||
}
|
||||
|
||||
// Fetch current avatar
|
||||
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
||||
if (!avatarData) {
|
||||
throw new Error('Failed to fetch current avatar');
|
||||
}
|
||||
|
||||
// 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');
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const bannerMxc = await loadBannerUrl(mx, userId);
|
||||
if (!cancelled) setBanner(bannerMxc);
|
||||
} catch {
|
||||
if (!cancelled) setBanner(undefined);
|
||||
}
|
||||
workingData = pngData;
|
||||
format = 'png';
|
||||
}
|
||||
|
||||
// Get existing metadata to preserve
|
||||
const existingMetadata = extractMetadataFromImage(workingData);
|
||||
console.log('[updateBanner] Existing metadata:', existingMetadata);
|
||||
|
||||
// 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 ?? '',
|
||||
avatarBorderColor: existingMetadata.avatarBorderColor,
|
||||
gradient: existingMetadata.gradient,
|
||||
if (!cancelled) setLoading(false);
|
||||
};
|
||||
console.log('[updateBanner] New metadata to embed:', newMetadata);
|
||||
|
||||
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
if (!newAvatarData) {
|
||||
throw new Error('Failed to embed banner in avatar metadata');
|
||||
}
|
||||
|
||||
// Verify the banner was embedded correctly before uploading
|
||||
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
||||
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
|
||||
|
||||
if ((newBanner || undefined) !== verifyMetadata.banner) {
|
||||
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
|
||||
throw new Error('Banner verification failed');
|
||||
}
|
||||
console.log('[updateBanner] Banner verification passed');
|
||||
|
||||
// Upload modified avatar with correct MIME type
|
||||
const mimeType = getMimeType(format);
|
||||
const extension = getExtension(format);
|
||||
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
||||
|
||||
const uploadResponse = await mx.uploadContent(blob, {
|
||||
name: `avatar.${extension}`,
|
||||
type: mimeType,
|
||||
});
|
||||
console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri);
|
||||
|
||||
// Update profile with new avatar
|
||||
try {
|
||||
console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri);
|
||||
await Promise.race([
|
||||
mx.setAvatarUrl(uploadResponse.content_uri),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
|
||||
})
|
||||
]);
|
||||
console.log('[updateBanner] setAvatarUrl completed');
|
||||
|
||||
// Manually sync user object to ensure event listeners are triggered
|
||||
const user = mx.getUser(userId);
|
||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
||||
console.log('[updateBanner] Manually syncing user avatar URL');
|
||||
user.setAvatarUrl(uploadResponse.content_uri);
|
||||
const updateBanner = useCallback(
|
||||
async (newBanner: string | undefined) => {
|
||||
if (!newBanner) {
|
||||
await deleteBannerUrl(mx);
|
||||
setBanner(undefined);
|
||||
return;
|
||||
}
|
||||
console.log('[updateBanner] Banner update complete');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Note: Don't set banner here - let the avatar change event handle it
|
||||
// setBanner(newBanner);
|
||||
}, [mx, useAuthentication]);
|
||||
await saveBannerUrl(mx, newBanner);
|
||||
setBanner(newBanner);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
return [banner, updateBanner, loading];
|
||||
}
|
||||
|
||||
// Cache for user banners to avoid refetching for each message
|
||||
const userBannerCache = new Map<string, { bannerMxc: string | undefined; timestamp: number }>();
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
registerBannerCacheClear((userId: string) => {
|
||||
userBannerCache.delete(userId);
|
||||
});
|
||||
|
||||
async function fetchBannerBlobUrl(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
bannerMxc: string,
|
||||
useAuthentication: boolean
|
||||
): Promise<string | undefined> {
|
||||
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
||||
if (!bannerHttpUrl) return undefined;
|
||||
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (useAuthentication && accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(bannerHttpUrl, { headers });
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get another user's chosen profile banner from their avatar metadata
|
||||
* @param userId - The user ID to get banner for
|
||||
* @param avatarMxc - The user's avatar MXC URL
|
||||
* @returns The user's chosen banner as a blob URL or undefined
|
||||
* Hook to get another user's profile banner as a blob URL.
|
||||
*/
|
||||
export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined {
|
||||
export function useOtherUserBanner(userId: string): string | undefined {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
let blobUrl: string | undefined;
|
||||
|
||||
if (!avatarMxc) {
|
||||
console.log('[useOtherUserBanner] No avatarMxc provided for', userId);
|
||||
if (!userId) {
|
||||
setBannerBlobUrl(undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const loadBanner = async () => {
|
||||
console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc);
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `${userId}:${avatarMxc}`;
|
||||
const cached = userBannerCache.get(cacheKey);
|
||||
let blobUrl: string | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
const cached = userBannerCache.get(userId);
|
||||
let bannerMxc: string | undefined;
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
// Use cached banner MXC
|
||||
console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc);
|
||||
bannerMxc = cached.bannerMxc;
|
||||
} else {
|
||||
// Fetch banner MXC from avatar metadata
|
||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||
console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl);
|
||||
|
||||
if (!httpUrl) {
|
||||
console.log('[useOtherUserBanner] Failed to get HTTP URL');
|
||||
setBannerBlobUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||
|
||||
console.log('[useOtherUserBanner] Extracted metadata:', metadata);
|
||||
|
||||
bannerMxc = metadata.banner;
|
||||
|
||||
// Cache the banner MXC (not the blob URL)
|
||||
userBannerCache.set(cacheKey, { bannerMxc, timestamp: Date.now() });
|
||||
bannerMxc = await loadBannerUrl(mx, userId);
|
||||
userBannerCache.set(userId, { bannerMxc, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
if (!bannerMxc) {
|
||||
console.log('[useOtherUserBanner] No banner MXC found');
|
||||
setBannerBlobUrl(undefined);
|
||||
if (!cancelled) setBannerBlobUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc);
|
||||
|
||||
// Fetch the banner image data with authentication
|
||||
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
||||
if (!bannerHttpUrl) {
|
||||
console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL');
|
||||
setBannerBlobUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl);
|
||||
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (useAuthentication && accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(bannerHttpUrl, { headers });
|
||||
if (!response.ok) {
|
||||
setBannerBlobUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
setBannerBlobUrl(blobUrl);
|
||||
blobUrl = await fetchBannerBlobUrl(mx, bannerMxc, useAuthentication);
|
||||
if (!cancelled) setBannerBlobUrl(blobUrl);
|
||||
};
|
||||
|
||||
loadBanner();
|
||||
load();
|
||||
|
||||
// Cleanup blob URL when component unmounts or deps change
|
||||
return () => {
|
||||
if (blobUrl) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
cancelled = true;
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
};
|
||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
||||
}, [mx, useAuthentication, userId]);
|
||||
|
||||
return bannerBlobUrl;
|
||||
}
|
||||
|
||||
@@ -1,227 +1,82 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../utils/auth';
|
||||
import { ThemeKind, useTheme } from './useTheme';
|
||||
import {
|
||||
embedColorInImage,
|
||||
extractColorFromImage,
|
||||
removeColorFromImage,
|
||||
fetchAndExtractColor,
|
||||
detectImageFormat,
|
||||
getMimeType,
|
||||
getExtension,
|
||||
uint8ArrayToBlob,
|
||||
} from '../utils/imageMetadata';
|
||||
ColorPreference,
|
||||
deleteColorPreference,
|
||||
extractMemberColorPreference,
|
||||
hasColorPreference,
|
||||
loadColorPreference,
|
||||
registerPreferenceCacheClear,
|
||||
resolveColorForTheme,
|
||||
saveColorPreference,
|
||||
} from '../utils/profileFields';
|
||||
|
||||
/**
|
||||
* Fetches the user's current avatar as raw image data
|
||||
* Hook to manage the user's MSC4522 color preference on their global profile.
|
||||
*/
|
||||
async function fetchAvatarData(
|
||||
mx: MatrixClient,
|
||||
avatarMxc: string,
|
||||
useAuthentication: boolean
|
||||
): Promise<ArrayBuffer | null> {
|
||||
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||
if (!url) return null;
|
||||
|
||||
try {
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
|
||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
||||
console.warn('[fetchAvatarData] Auth failed (401), attempting unauthenticated fallback');
|
||||
response = await fetch(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
if (!response.ok) return null;
|
||||
return await response.arrayBuffer();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage the user's chosen profile color, stored in avatar image metadata
|
||||
* The color is embedded in PNG tEXt chunk or WebP XMP chunk and syncs via the avatar
|
||||
* @returns The current color, a setter function, and loading state
|
||||
*/
|
||||
export function useUserColor(): [
|
||||
string | undefined,
|
||||
(color: string | undefined) => Promise<void>,
|
||||
export function useUserColorPreference(): [
|
||||
ColorPreference | undefined,
|
||||
(preference: ColorPreference | undefined) => Promise<void>,
|
||||
boolean
|
||||
] {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [color, setColor] = useState<string | undefined>();
|
||||
const [preference, setPreference] = useState<ColorPreference | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Extract color from current avatar on mount and when profile changes
|
||||
useEffect(() => {
|
||||
const loadColor = async () => {
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) {
|
||||
setLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await mx.getProfileInfo(userId);
|
||||
const avatarUrl = profile.avatar_url;
|
||||
|
||||
if (!avatarUrl) {
|
||||
setColor(undefined);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
||||
if (!httpUrl) {
|
||||
setColor(undefined);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
||||
setColor(extractedColor);
|
||||
const loaded = await loadColorPreference(mx, userId);
|
||||
if (!cancelled) setPreference(loaded);
|
||||
} catch (e) {
|
||||
console.error('Failed to load user color from avatar:', e);
|
||||
setColor(undefined);
|
||||
console.error('Failed to load color preference:', e);
|
||||
if (!cancelled) setPreference(undefined);
|
||||
}
|
||||
setLoading(false);
|
||||
if (!cancelled) setLoading(false);
|
||||
};
|
||||
|
||||
loadColor();
|
||||
|
||||
// Listen for avatar changes and reload color
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) return undefined;
|
||||
|
||||
const user = mx.getUser(userId);
|
||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
|
||||
loadColor();
|
||||
};
|
||||
|
||||
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
||||
|
||||
load();
|
||||
return () => {
|
||||
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, useAuthentication]);
|
||||
}, [mx]);
|
||||
|
||||
const updateColor = useCallback(async (newColor: string | undefined) => {
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const profile = await mx.getProfileInfo(userId);
|
||||
const avatarUrl = profile.avatar_url;
|
||||
|
||||
if (!avatarUrl) {
|
||||
// No avatar to embed color in
|
||||
console.warn('Cannot set color: no avatar image set');
|
||||
throw new Error('No avatar set');
|
||||
const updatePreference = useCallback(
|
||||
async (newPreference: ColorPreference | undefined) => {
|
||||
if (!hasColorPreference(newPreference)) {
|
||||
await deleteColorPreference(mx);
|
||||
setPreference(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch current avatar
|
||||
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
||||
if (!avatarData) {
|
||||
console.error('Failed to fetch current avatar');
|
||||
throw new Error('Failed to fetch avatar');
|
||||
}
|
||||
await saveColorPreference(mx, newPreference!);
|
||||
setPreference(newPreference);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
console.log('Original avatar size:', avatarData.byteLength, 'bytes');
|
||||
|
||||
// Detect image format
|
||||
const format = detectImageFormat(avatarData);
|
||||
console.log('Detected image format:', format);
|
||||
|
||||
if (format === 'unknown') {
|
||||
console.error('Unsupported avatar image format - must be PNG, WebP, JPEG, or GIF');
|
||||
throw new Error('Unsupported image format');
|
||||
}
|
||||
|
||||
// Modify image metadata
|
||||
let newAvatarData: Uint8Array | null;
|
||||
if (newColor) {
|
||||
newAvatarData = embedColorInImage(avatarData, newColor);
|
||||
} else {
|
||||
newAvatarData = removeColorFromImage(avatarData);
|
||||
}
|
||||
|
||||
if (!newAvatarData) {
|
||||
console.error('Failed to modify avatar image metadata');
|
||||
throw new Error('Failed to modify image metadata');
|
||||
}
|
||||
|
||||
console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes');
|
||||
|
||||
// Verify the color was embedded correctly before uploading
|
||||
const verifyColor = extractColorFromImage(newAvatarData);
|
||||
console.log('Verification - embedded color:', verifyColor);
|
||||
|
||||
if (newColor && verifyColor !== newColor) {
|
||||
console.error('Color verification failed! Expected:', newColor, 'Got:', verifyColor);
|
||||
throw new Error('Color embedding verification failed');
|
||||
}
|
||||
|
||||
// Upload modified avatar with correct MIME type
|
||||
const mimeType = getMimeType(format);
|
||||
const extension = getExtension(format);
|
||||
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
||||
const uploadResponse = await mx.uploadContent(blob, {
|
||||
name: `avatar.${extension}`,
|
||||
type: mimeType,
|
||||
});
|
||||
|
||||
console.log('Uploaded new avatar:', uploadResponse.content_uri);
|
||||
|
||||
// Update profile with new avatar
|
||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
||||
|
||||
// Manually sync user object to ensure event listeners are triggered
|
||||
const user = mx.getUser(userId);
|
||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
||||
user.setAvatarUrl(uploadResponse.content_uri);
|
||||
}
|
||||
|
||||
setColor(newColor);
|
||||
console.log('User color updated in avatar:', newColor);
|
||||
} catch (e) {
|
||||
console.error('Failed to update user color in avatar:', e);
|
||||
throw e;
|
||||
}
|
||||
}, [mx, useAuthentication]);
|
||||
|
||||
return [color, updateColor, loading];
|
||||
return [preference, updatePreference, loading];
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU Cache implementation with max size limit
|
||||
*/
|
||||
class LRUCache<K, V> {
|
||||
private cache: Map<K, V>;
|
||||
private cache = new Map<K, V>();
|
||||
|
||||
private maxSize: number;
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this.cache = new Map();
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
constructor(private maxSize: number) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.cache.get(key);
|
||||
if (value !== undefined) {
|
||||
// Move to end (most recently used)
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
@@ -229,88 +84,106 @@ class LRUCache<K, V> {
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
// If key exists, delete it first to re-add at end
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
// If at max size, remove oldest (first) entry
|
||||
else if (this.cache.size >= this.maxSize) {
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
if (firstKey !== undefined) this.cache.delete(firstKey);
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
delete(key: K): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// LRU cache for user colors to avoid refetching for each message
|
||||
const userColorCache = new LRUCache<string, { color: string | undefined; timestamp: number }>(100);
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const preferenceCache = new LRUCache<string, { preference: ColorPreference | undefined; timestamp: number }>(200);
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
registerPreferenceCacheClear((userId: string) => {
|
||||
preferenceCache.delete(userId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hook to get another user's chosen profile color from their avatar metadata
|
||||
* @param userId - The user ID to get color for
|
||||
* @param avatarMxc - The user's avatar MXC URL
|
||||
* @returns The user's chosen color or undefined
|
||||
* Hook to get another user's MSC4522 color preference from their global profile.
|
||||
*/
|
||||
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
|
||||
export function useOtherUserColorPreference(userId: string): ColorPreference | undefined {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const cachedEntry = preferenceCache.get(userId);
|
||||
const cachedPreference =
|
||||
cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS ? cachedEntry.preference : undefined;
|
||||
|
||||
// Check cache synchronously BEFORE initializing state to avoid flash
|
||||
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
|
||||
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
|
||||
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS
|
||||
? cachedEntry.color
|
||||
: undefined;
|
||||
|
||||
const [color, setColor] = useState<string | undefined>(cachedColor);
|
||||
const [preference, setPreference] = useState<ColorPreference | undefined>(cachedPreference);
|
||||
|
||||
useEffect(() => {
|
||||
if (!avatarMxc) {
|
||||
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
|
||||
setColor(undefined);
|
||||
if (!userId) {
|
||||
setPreference(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `${userId}:${avatarMxc}`;
|
||||
const cached = userColorCache.get(cacheKey);
|
||||
const cached = preferenceCache.get(userId);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color);
|
||||
setColor(cached.color);
|
||||
setPreference(cached.preference);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColor = async () => {
|
||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
|
||||
if (!httpUrl) {
|
||||
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc);
|
||||
setColor(undefined);
|
||||
return;
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const loaded = await loadColorPreference(mx, userId);
|
||||
preferenceCache.set(userId, { preference: loaded, timestamp: Date.now() });
|
||||
if (!cancelled) setPreference(loaded);
|
||||
} catch {
|
||||
if (!cancelled) setPreference(undefined);
|
||||
}
|
||||
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
||||
|
||||
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor);
|
||||
|
||||
// Cache the result
|
||||
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
||||
|
||||
setColor(extractedColor);
|
||||
};
|
||||
|
||||
loadColor();
|
||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
return color;
|
||||
return preference;
|
||||
}
|
||||
|
||||
/**
|
||||
* MSC4522 per-room member color preference (overrides global profile when set).
|
||||
*/
|
||||
export function useMemberColorPreference(userId: string, room?: Room): ColorPreference | undefined {
|
||||
const [preference, setPreference] = useState<ColorPreference | undefined>(() =>
|
||||
extractMemberColorPreference(room, userId)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPreference(extractMemberColorPreference(room, userId));
|
||||
}, [room, userId]);
|
||||
|
||||
return preference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user's username color for the active theme (MSC4522).
|
||||
* Per-room member colors take precedence over global profile colors.
|
||||
*/
|
||||
export function useOtherUserColor(userId: string, room?: Room): string | undefined {
|
||||
const theme = useTheme();
|
||||
const profilePreference = useOtherUserColorPreference(userId);
|
||||
const memberPreference = useMemberColorPreference(userId, room);
|
||||
const preference = memberPreference ?? profilePreference;
|
||||
return resolveColorForTheme(preference, theme.kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current user's username color for the active theme.
|
||||
*/
|
||||
export function useUsernameColor(userId: string, room?: Room, themeKind?: ThemeKind): string | undefined {
|
||||
const theme = useTheme();
|
||||
const kind = themeKind ?? theme.kind;
|
||||
const profilePreference = useOtherUserColorPreference(userId);
|
||||
const memberPreference = useMemberColorPreference(userId, room);
|
||||
const preference = memberPreference ?? profilePreference;
|
||||
return resolveColorForTheme(preference, kind);
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken } from '../utils/auth';import {
|
||||
fetchAndExtractMetadata,
|
||||
extractMetadataFromImage,
|
||||
embedMetadataInImage,
|
||||
detectImageFormat,
|
||||
getMimeType,
|
||||
getExtension,
|
||||
ImageMetadata,
|
||||
ProfileGradient,
|
||||
convertImageDataToPng,
|
||||
uint8ArrayToBlob,
|
||||
} from '../utils/imageMetadata';
|
||||
|
||||
/**
|
||||
* Fetches the user's current avatar as raw image data
|
||||
*/
|
||||
async function fetchAvatarData(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
avatarMxc: string,
|
||||
useAuthentication: boolean
|
||||
): Promise<ArrayBuffer | null> {
|
||||
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||
if (!url) return null;
|
||||
|
||||
try {
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
|
||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
||||
response = await fetch(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
if (!response.ok) return null;
|
||||
return await response.arrayBuffer();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type ProfileStyleData = {
|
||||
avatarBorderColor?: string;
|
||||
gradient?: ProfileGradient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage the user's profile style (avatar border color and gradient)
|
||||
* Stored in avatar image metadata
|
||||
* @returns Current style data, a setter function, and loading state
|
||||
*/
|
||||
export function useUserProfileStyle(): [
|
||||
ProfileStyleData,
|
||||
(style: Partial<ProfileStyleData>) => Promise<void>,
|
||||
boolean
|
||||
] {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [style, setStyle] = useState<ProfileStyleData>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Extract style from current avatar on mount and when profile changes
|
||||
useEffect(() => {
|
||||
const loadStyle = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await mx.getProfileInfo(userId);
|
||||
const avatarUrl = profile.avatar_url;
|
||||
|
||||
if (!avatarUrl) {
|
||||
setStyle({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
||||
if (!httpUrl) {
|
||||
setStyle({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||
setStyle({
|
||||
avatarBorderColor: metadata.avatarBorderColor,
|
||||
gradient: metadata.gradient,
|
||||
});
|
||||
} catch {
|
||||
setStyle({});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadStyle();
|
||||
|
||||
// Listen for avatar changes and reload style
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) return undefined;
|
||||
|
||||
const user = mx.getUser(userId);
|
||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
|
||||
loadStyle();
|
||||
};
|
||||
|
||||
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
||||
|
||||
return () => {
|
||||
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
||||
};
|
||||
}, [mx, useAuthentication]);
|
||||
|
||||
const updateStyle = useCallback(
|
||||
async (newStyle: Partial<ProfileStyleData>) => {
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) {
|
||||
throw new Error('No user ID');
|
||||
}
|
||||
|
||||
const profile = await mx.getProfileInfo(userId);
|
||||
const avatarUrl = profile.avatar_url;
|
||||
|
||||
if (!avatarUrl) {
|
||||
throw new Error('No avatar set. Please upload an avatar first.');
|
||||
}
|
||||
|
||||
// Fetch current avatar
|
||||
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
||||
if (!avatarData) {
|
||||
throw new Error('Failed to fetch current avatar');
|
||||
}
|
||||
|
||||
// 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(workingData);
|
||||
|
||||
// Merge with new style
|
||||
const newMetadata: ImageMetadata = {
|
||||
...existingMetadata,
|
||||
avatarBorderColor: newStyle.avatarBorderColor !== undefined ? newStyle.avatarBorderColor : existingMetadata.avatarBorderColor,
|
||||
gradient: newStyle.gradient !== undefined ? newStyle.gradient : existingMetadata.gradient,
|
||||
};
|
||||
|
||||
// Embed updated metadata
|
||||
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
|
||||
if (!newAvatarData) {
|
||||
throw new Error('Failed to embed style in avatar metadata');
|
||||
}
|
||||
|
||||
// Upload modified avatar with correct MIME type
|
||||
const mimeType = getMimeType(format);
|
||||
const extension = getExtension(format);
|
||||
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
||||
|
||||
const uploadResponse = await mx.uploadContent(blob, {
|
||||
name: `avatar.${extension}`,
|
||||
type: mimeType,
|
||||
});
|
||||
|
||||
// Update profile with new avatar
|
||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
||||
|
||||
// Manually sync user object
|
||||
const user = mx.getUser(userId);
|
||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
||||
user.setAvatarUrl(uploadResponse.content_uri);
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setStyle((prev) => ({
|
||||
...prev,
|
||||
...newStyle,
|
||||
}));
|
||||
},
|
||||
[mx, useAuthentication]
|
||||
);
|
||||
|
||||
return [style, updateStyle, loading];
|
||||
}
|
||||
|
||||
// Cache for other users' profile styles
|
||||
const userStyleCache = new Map<string, { style: ProfileStyleData; timestamp: number }>();
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Hook to get another user's profile style from their avatar metadata
|
||||
* @param userId - The user ID to get style for
|
||||
* @param avatarMxc - The user's avatar MXC URL
|
||||
* @returns The user's profile style data
|
||||
*/
|
||||
export function useOtherUserProfileStyle(userId: string, avatarMxc: string | undefined): ProfileStyleData {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [style, setStyle] = useState<ProfileStyleData>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!avatarMxc) {
|
||||
console.log('[useOtherUserProfileStyle] No avatarMxc provided for', userId);
|
||||
setStyle({});
|
||||
return;
|
||||
}
|
||||
|
||||
const loadStyle = async () => {
|
||||
console.log('[useOtherUserProfileStyle] Loading style for', userId, 'avatarMxc:', avatarMxc);
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `${userId}:${avatarMxc}`;
|
||||
const cached = userStyleCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
console.log('[useOtherUserProfileStyle] Using cached style:', cached.style);
|
||||
setStyle(cached.style);
|
||||
return;
|
||||
}
|
||||
|
||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||
console.log('[useOtherUserProfileStyle] Fetching metadata from:', httpUrl);
|
||||
|
||||
if (!httpUrl) {
|
||||
console.log('[useOtherUserProfileStyle] Failed to get HTTP URL');
|
||||
setStyle({});
|
||||
return;
|
||||
}
|
||||
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||
|
||||
console.log('[useOtherUserProfileStyle] Extracted metadata:', metadata);
|
||||
|
||||
const styleData: ProfileStyleData = {
|
||||
avatarBorderColor: metadata.avatarBorderColor,
|
||||
gradient: metadata.gradient,
|
||||
};
|
||||
|
||||
console.log('[useOtherUserProfileStyle] Final styleData:', styleData);
|
||||
|
||||
// Cache the result
|
||||
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
|
||||
setStyle(styleData);
|
||||
};
|
||||
|
||||
loadStyle();
|
||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
||||
|
||||
return style;
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
listenForAndroidShares,
|
||||
materializeSharedFile,
|
||||
} from '../../utils/androidShare';
|
||||
import { UpdatesDialogHost } from '../../components/updates-dialog';
|
||||
|
||||
/**
|
||||
* Applies the selected emoji style font to the document.
|
||||
@@ -698,6 +699,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<MessageNotifications />
|
||||
<BackgroundSyncSetup />
|
||||
<PaarrotAPIInitializer />
|
||||
<UpdatesDialogHost />
|
||||
<TaskbarFlashStopper />
|
||||
<AndroidShareIntentHandler />
|
||||
{children}
|
||||
|
||||
@@ -237,7 +237,7 @@ function NotificationItem({
|
||||
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
||||
|
||||
// Get custom user color from avatar metadata
|
||||
const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc);
|
||||
const customUserColor = useOtherUserColor(event.sender, room);
|
||||
|
||||
const memberPowerTag = getMemberPowerTag(event.sender);
|
||||
const tagColor = memberPowerTag?.color
|
||||
|
||||
55
src/app/state/releaseNotes.ts
Normal file
55
src/app/state/releaseNotes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
const LAST_SEEN_VERSION_KEY = 'paarrot:lastSeenVersion';
|
||||
const PENDING_RELEASE_NOTES_KEY = 'paarrot:pendingReleaseNotes';
|
||||
|
||||
export type PendingReleaseNotes = {
|
||||
version: string;
|
||||
releaseNotes: unknown;
|
||||
};
|
||||
|
||||
export type ReleaseNotesDialogState = {
|
||||
open: boolean;
|
||||
/** Opened from About instead of an automatic post-update prompt. */
|
||||
manual: boolean;
|
||||
};
|
||||
|
||||
export const releaseNotesDialogAtom = atom<ReleaseNotesDialogState>({
|
||||
open: false,
|
||||
manual: false,
|
||||
});
|
||||
|
||||
export function getLastSeenAppVersion(): string | null {
|
||||
return localStorage.getItem(LAST_SEEN_VERSION_KEY);
|
||||
}
|
||||
|
||||
export function setLastSeenAppVersion(version: string): void {
|
||||
localStorage.setItem(LAST_SEEN_VERSION_KEY, version);
|
||||
}
|
||||
|
||||
export function storePendingReleaseNotes(version: string, releaseNotes: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PENDING_RELEASE_NOTES_KEY,
|
||||
JSON.stringify({ version, releaseNotes } satisfies PendingReleaseNotes)
|
||||
);
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}
|
||||
|
||||
export function getPendingReleaseNotes(): PendingReleaseNotes | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PENDING_RELEASE_NOTES_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as PendingReleaseNotes;
|
||||
if (typeof parsed?.version !== 'string') return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingReleaseNotes(): void {
|
||||
localStorage.removeItem(PENDING_RELEASE_NOTES_KEY);
|
||||
}
|
||||
10
src/app/utils/appVersion.ts
Normal file
10
src/app/utils/appVersion.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
|
||||
/** Returns the running app version (Electron shim or Tauri). */
|
||||
export async function getAppVersion(): Promise<string> {
|
||||
try {
|
||||
return await getVersion();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
192
src/app/utils/profileFields.ts
Normal file
192
src/app/utils/profileFields.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||
import { ThemeKind } from '../hooks/useTheme';
|
||||
|
||||
/** MSC4522 stable profile field for username colors */
|
||||
export const PROFILE_KEY_COLOR_PREFERENCE_STABLE = 'm.color_preference';
|
||||
|
||||
/** MSC4522 unstable profile field for username colors */
|
||||
export const PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE = 'eu.she-a.color';
|
||||
|
||||
/** MSC4427 unstable profile field for banner */
|
||||
export const PROFILE_KEY_BANNER_URL_UNSTABLE = 'chat.commet.profile_banner';
|
||||
|
||||
/** MSC4427 stable profile field for banner */
|
||||
export const PROFILE_KEY_BANNER_URL_STABLE = 'm.banner_url';
|
||||
|
||||
export type ColorPreference = {
|
||||
on_dark?: string;
|
||||
on_light?: string;
|
||||
};
|
||||
|
||||
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
export function isValidHexColor(color: string): boolean {
|
||||
return HEX_COLOR_RE.test(color);
|
||||
}
|
||||
|
||||
export function parseColorPreference(value: unknown): ColorPreference | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
const on_dark = typeof obj.on_dark === 'string' && isValidHexColor(obj.on_dark) ? obj.on_dark : undefined;
|
||||
const on_light = typeof obj.on_light === 'string' && isValidHexColor(obj.on_light) ? obj.on_light : undefined;
|
||||
|
||||
if (!on_dark && !on_light) return undefined;
|
||||
return { on_dark, on_light };
|
||||
}
|
||||
|
||||
export function extractColorPreferenceFromProfile(profile: Record<string, unknown>): ColorPreference | undefined {
|
||||
const stable = parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_STABLE]);
|
||||
if (stable) return stable;
|
||||
return parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE]);
|
||||
}
|
||||
|
||||
export function resolveColorForTheme(
|
||||
preference: ColorPreference | undefined,
|
||||
themeKind: ThemeKind
|
||||
): string | undefined {
|
||||
if (!preference) return undefined;
|
||||
if (themeKind === ThemeKind.Dark) {
|
||||
return preference.on_dark ?? preference.on_light;
|
||||
}
|
||||
return preference.on_light ?? preference.on_dark;
|
||||
}
|
||||
|
||||
export function hasColorPreference(preference: ColorPreference | undefined): boolean {
|
||||
return Boolean(preference?.on_dark || preference?.on_light);
|
||||
}
|
||||
|
||||
export function extractBannerUrlFromProfile(profile: Record<string, unknown>): string | undefined {
|
||||
const stable = profile[PROFILE_KEY_BANNER_URL_STABLE];
|
||||
if (typeof stable === 'string' && stable.startsWith('mxc://')) return stable;
|
||||
|
||||
const unstable = profile[PROFILE_KEY_BANNER_URL_UNSTABLE];
|
||||
if (typeof unstable === 'string' && unstable.startsWith('mxc://')) return unstable;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getBannerUrlProfileKey(mx: MatrixClient): Promise<string> {
|
||||
if (await mx.isVersionSupported('v1.16')) {
|
||||
return PROFILE_KEY_BANNER_URL_STABLE;
|
||||
}
|
||||
return PROFILE_KEY_BANNER_URL_UNSTABLE;
|
||||
}
|
||||
|
||||
export async function loadBannerUrl(mx: MatrixClient, userId: string): Promise<string | undefined> {
|
||||
if (await mx.doesServerSupportExtendedProfiles()) {
|
||||
try {
|
||||
const profile = await mx.getExtendedProfile(userId);
|
||||
return extractBannerUrlFromProfile(profile);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
|
||||
return extractBannerUrlFromProfile(profile);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBannerUrl(mx: MatrixClient, bannerMxc: string): Promise<void> {
|
||||
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||
}
|
||||
|
||||
const key = await getBannerUrlProfileKey(mx);
|
||||
await mx.setExtendedProfileProperty(key, bannerMxc);
|
||||
const userId = mx.getUserId();
|
||||
if (userId) clearBannerCache(userId);
|
||||
}
|
||||
|
||||
export async function deleteBannerUrl(mx: MatrixClient): Promise<void> {
|
||||
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||
}
|
||||
|
||||
const key = await getBannerUrlProfileKey(mx);
|
||||
await mx.deleteExtendedProfileProperty(key);
|
||||
const userId = mx.getUserId();
|
||||
if (userId) clearBannerCache(userId);
|
||||
}
|
||||
|
||||
let bannerCacheClearImpl: (userId: string) => void = () => {};
|
||||
|
||||
export function registerBannerCacheClear(fn: (userId: string) => void): void {
|
||||
bannerCacheClearImpl = fn;
|
||||
}
|
||||
|
||||
function clearBannerCache(userId: string): void {
|
||||
bannerCacheClearImpl(userId);
|
||||
}
|
||||
|
||||
export async function getColorPreferenceProfileKey(mx: MatrixClient): Promise<string> {
|
||||
if (await mx.isVersionSupported('v1.16')) {
|
||||
return PROFILE_KEY_COLOR_PREFERENCE_STABLE;
|
||||
}
|
||||
return PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE;
|
||||
}
|
||||
|
||||
export async function loadColorPreference(
|
||||
mx: MatrixClient,
|
||||
userId: string
|
||||
): Promise<ColorPreference | undefined> {
|
||||
if (await mx.doesServerSupportExtendedProfiles()) {
|
||||
try {
|
||||
const profile = await mx.getExtendedProfile(userId);
|
||||
return extractColorPreferenceFromProfile(profile);
|
||||
} catch {
|
||||
// fall through to standard profile endpoint
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
|
||||
return extractColorPreferenceFromProfile(profile);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMemberColorPreference(room: Room | undefined, userId: string): ColorPreference | undefined {
|
||||
if (!room) return undefined;
|
||||
const member = room.getMember(userId);
|
||||
const content = member?.events.member?.getContent();
|
||||
if (!content) return undefined;
|
||||
return extractColorPreferenceFromProfile(content as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export async function saveColorPreference(mx: MatrixClient, preference: ColorPreference): Promise<void> {
|
||||
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||
}
|
||||
|
||||
const key = await getColorPreferenceProfileKey(mx);
|
||||
await mx.setExtendedProfileProperty(key, preference);
|
||||
const userId = mx.getUserId();
|
||||
if (userId) clearPreferenceCache(userId);
|
||||
}
|
||||
|
||||
let preferenceCacheClearImpl: (userId: string) => void = () => {};
|
||||
|
||||
export function registerPreferenceCacheClear(fn: (userId: string) => void): void {
|
||||
preferenceCacheClearImpl = fn;
|
||||
}
|
||||
|
||||
function clearPreferenceCache(userId: string): void {
|
||||
preferenceCacheClearImpl(userId);
|
||||
}
|
||||
|
||||
export async function deleteColorPreference(mx: MatrixClient): Promise<void> {
|
||||
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||
}
|
||||
|
||||
const key = await getColorPreferenceProfileKey(mx);
|
||||
await mx.deleteExtendedProfileProperty(key);
|
||||
const userId = mx.getUserId();
|
||||
if (userId) clearPreferenceCache(userId);
|
||||
}
|
||||
111
src/app/utils/registerDesktopMediaSaver.ts
Normal file
111
src/app/utils/registerDesktopMediaSaver.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { registerMediaSaver } from './saveMedia';
|
||||
import { isElectron, isTauri } from './tauri';
|
||||
|
||||
type ElectronSaveResult = {
|
||||
success?: boolean;
|
||||
canceled?: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ElectronMediaApi = {
|
||||
saveFile?: (payload: {
|
||||
filename: string;
|
||||
mimeType?: string;
|
||||
data: Uint8Array;
|
||||
}) => Promise<ElectronSaveResult>;
|
||||
};
|
||||
|
||||
async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
function guessFilters(filename: string, mimeType?: string): Array<{ name: string; extensions: string[] }> {
|
||||
const ext = filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : '';
|
||||
const mime = (mimeType || '').toLowerCase();
|
||||
|
||||
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) {
|
||||
return [
|
||||
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
];
|
||||
}
|
||||
if (mime.startsWith('video/') || ['mp4', 'webm', 'mkv', 'mov'].includes(ext)) {
|
||||
return [
|
||||
{ name: 'Videos', extensions: ['mp4', 'webm', 'mkv', 'mov'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
];
|
||||
}
|
||||
if (mime.startsWith('audio/') || ['mp3', 'ogg', 'wav', 'm4a', 'flac'].includes(ext)) {
|
||||
return [
|
||||
{ name: 'Audio', extensions: ['mp3', 'ogg', 'wav', 'm4a', 'flac'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
];
|
||||
}
|
||||
if (mime === 'application/pdf' || ext === 'pdf') {
|
||||
return [
|
||||
{ name: 'PDF', extensions: ['pdf'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
];
|
||||
}
|
||||
if (ext) {
|
||||
return [
|
||||
{ name: ext.toUpperCase(), extensions: [ext] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
];
|
||||
}
|
||||
return [{ name: 'All Files', extensions: ['*'] }];
|
||||
}
|
||||
|
||||
async function saveWithElectron(blob: Blob, filename: string): Promise<void> {
|
||||
const media = (window.electron as { media?: ElectronMediaApi } | undefined)?.media;
|
||||
if (!media?.saveFile) {
|
||||
throw new Error('Electron media.saveFile is unavailable');
|
||||
}
|
||||
|
||||
const data = await blobToUint8Array(blob);
|
||||
const result = await media.saveFile({
|
||||
filename,
|
||||
mimeType: blob.type || 'application/octet-stream',
|
||||
data,
|
||||
});
|
||||
|
||||
if (result?.canceled) return;
|
||||
if (result?.success === false) {
|
||||
throw new Error(result.error || 'Failed to save file');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWithTauri(blob: Blob, filename: string): Promise<void> {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const { writeFile } = await import('@tauri-apps/plugin-fs');
|
||||
|
||||
const path = await save({
|
||||
defaultPath: filename,
|
||||
filters: guessFilters(filename, blob.type),
|
||||
});
|
||||
if (!path) return;
|
||||
|
||||
const data = await blobToUint8Array(blob);
|
||||
await writeFile(path, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire Electron / Tauri native save dialogs into core saveMedia helpers.
|
||||
* Call once at desktop app startup. No-ops in plain browser.
|
||||
*/
|
||||
export function registerDesktopMediaSaver(): void {
|
||||
if (isElectron()) {
|
||||
registerMediaSaver(async (blob, filename) => {
|
||||
await saveWithElectron(blob, filename);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTauri()) {
|
||||
registerMediaSaver(async (blob, filename) => {
|
||||
await saveWithTauri(blob, filename);
|
||||
});
|
||||
}
|
||||
}
|
||||
55
src/app/utils/saveMedia.ts
Normal file
55
src/app/utils/saveMedia.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import FileSaver from 'file-saver';
|
||||
import { downloadMedia } from './matrix';
|
||||
import { getCurrentAccessToken } from './auth';
|
||||
|
||||
export type MediaSaver = (blob: Blob, filename: string) => void | Promise<void>;
|
||||
|
||||
let customMediaSaver: MediaSaver | null = null;
|
||||
|
||||
const defaultMediaSaver: MediaSaver = (blob, filename) => {
|
||||
FileSaver.saveAs(blob, filename);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a platform-specific media saver (Android MediaStore, Electron dialog, etc.).
|
||||
* Pass null to restore the default FileSaver path.
|
||||
*
|
||||
* Platforms should call this once at startup (mobile overlay / desktop shell entry).
|
||||
*/
|
||||
export const registerMediaSaver = (saver: MediaSaver | null): void => {
|
||||
customMediaSaver = saver;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist a Blob to disk via the registered platform saver, or FileSaver by default.
|
||||
*/
|
||||
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
|
||||
const saver = customMediaSaver ?? defaultMediaSaver;
|
||||
await saver(blob, filename);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch media (auth download or blob:/data:/http URL) and save it via [saveMediaBlob].
|
||||
*/
|
||||
export const downloadAndSaveMedia = async (
|
||||
src: string,
|
||||
filename: string,
|
||||
accessToken?: string | null
|
||||
): Promise<void> => {
|
||||
let blob: Blob;
|
||||
try {
|
||||
if (src.startsWith('blob:') || src.startsWith('data:')) {
|
||||
const res = await fetch(src);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${src}`);
|
||||
blob = await res.blob();
|
||||
} else {
|
||||
blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken());
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error);
|
||||
const res = await fetch(src);
|
||||
if (!res.ok) throw error;
|
||||
blob = await res.blob();
|
||||
}
|
||||
await saveMediaBlob(blob, filename);
|
||||
};
|
||||
18
src/ext.d.ts
vendored
18
src/ext.d.ts
vendored
@@ -56,6 +56,18 @@ interface ElectronAPI {
|
||||
clipboard?: {
|
||||
writeText: (text: string) => void;
|
||||
};
|
||||
media?: {
|
||||
saveFile: (payload: {
|
||||
filename: string;
|
||||
mimeType?: string;
|
||||
data: Uint8Array;
|
||||
}) => Promise<{
|
||||
success?: boolean;
|
||||
canceled?: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
plugins?: PluginAPI;
|
||||
protocol?: {
|
||||
getStatus: () => Promise<{
|
||||
@@ -95,6 +107,12 @@ interface ElectronAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
updater?: {
|
||||
onUpdateDownloaded: (callback: (info: {
|
||||
version: string;
|
||||
releaseNotes?: unknown;
|
||||
}) => void) => void;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { trimTrailingSlash } from './app/utils/common';
|
||||
import App from './app/pages/App';
|
||||
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
||||
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
|
||||
import { registerDesktopMediaSaver } from './app/utils/registerDesktopMediaSaver';
|
||||
|
||||
// import i18n (needs to be bundled ;))
|
||||
import './app/i18n';
|
||||
@@ -29,6 +30,9 @@ applySafeAreaInsets();
|
||||
// Enable View Transitions API for smooth navigation
|
||||
enableViewTransitionsForNavigation();
|
||||
|
||||
// Desktop shells: native Save dialog instead of browser FileSaver downloads.
|
||||
registerDesktopMediaSaver();
|
||||
|
||||
// Register Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const swUrl =
|
||||
|
||||
@@ -53,6 +53,11 @@ const copyFiles = {
|
||||
src: 'public/font',
|
||||
dest: 'public/',
|
||||
},
|
||||
{
|
||||
src: 'public/update/**/*',
|
||||
dest: 'update',
|
||||
rename: (_name, _ext, fullPath) => fullPath.replace(/^public[/\\]update[/\\]/, ''),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -124,6 +129,37 @@ function servePublicAssets() {
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
|
||||
const fileStream = fs.createReadStream(resolvedPath);
|
||||
fileStream.pipe(res);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('File not found');
|
||||
}
|
||||
} else if (req.url?.startsWith('/update/')) {
|
||||
const fileName = req.url.replace('/update/', '');
|
||||
const resolvedPath = path.join(path.resolve(), 'public', 'update', fileName);
|
||||
|
||||
if (fs.existsSync(resolvedPath) && !resolvedPath.includes('..')) {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
const contentType =
|
||||
ext === '.md'
|
||||
? 'text/markdown; charset=utf-8'
|
||||
: ext === '.json'
|
||||
? 'application/json'
|
||||
: ext === '.svg'
|
||||
? 'image/svg+xml'
|
||||
: ext === '.png'
|
||||
? 'image/png'
|
||||
: ext === '.jpg' || ext === '.jpeg'
|
||||
? 'image/jpeg'
|
||||
: ext === '.webp'
|
||||
? 'image/webp'
|
||||
: ext === '.gif'
|
||||
? 'image/gif'
|
||||
: 'application/octet-stream';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
|
||||
const fileStream = fs.createReadStream(resolvedPath);
|
||||
fileStream.pipe(res);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user