This commit is contained in:
2026-02-21 17:50:49 +11:00
parent 6c741577ce
commit 8b0960d366
7 changed files with 133 additions and 155 deletions

View File

@@ -8,13 +8,31 @@ type Modal500Props = {
children: ReactNode;
};
export function Modal500({ requestClose, children }: Modal500Props) {
const handleClickOutside = (e: MouseEvent | TouchEvent | FocusEvent) => {
const target = e.target as HTMLElement;
// Don't close if clicking on a popout menu, overlay, or other portaled content
// Check for common portal container elements and popout menus
if (
target.closest('[role="menu"]') ||
target.closest('[role="dialog"]') ||
target.closest('[data-floating-ui-portal]') ||
target.closest('.folds-overlay') ||
target.closest('.popout')
) {
return false;
}
return true;
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
clickOutsideDeactivates: handleClickOutside,
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
}}

View File

@@ -1,5 +1,5 @@
import { AvatarFallback, AvatarImage, color } from 'folds';
import React, { ReactEventHandler, ReactNode, useState } from 'react';
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import * as css from './UserAvatar.css';
import colorMXID from '../../../util/colorMXID';
@@ -7,6 +7,13 @@ import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
// Check if image is already in browser cache
function isImageInBrowserCache(url: string): boolean {
const img = new Image();
img.src = url;
return img.complete && img.naturalWidth > 0;
}
type UserAvatarProps = {
className?: string;
userId: string;
@@ -16,10 +23,29 @@ type UserAvatarProps = {
};
export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
const [error, setError] = useState(false);
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
// Check both our cache and browser's native cache
const [loaded, setLoaded] = useState(() => {
if (isAvatarCached(src)) return true;
if (authenticatedSrc && isImageInBrowserCache(authenticatedSrc)) {
cacheAvatar(src);
return true;
}
return false;
});
const imgRef = useRef<HTMLImageElement>(null);
// Also check on mount if image is already complete (browser cached)
useEffect(() => {
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setLoaded(true);
cacheAvatar(src);
}
}, [authenticatedSrc, loaded, src]);
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
setLoaded(true);
@@ -54,7 +80,8 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
);
}
// Loading state - render fallback with image overlay
// Loading state - render image with hidden fallback behind it
// Image loads invisibly, then we show it once complete
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<AvatarFallback
@@ -69,6 +96,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
{renderFallback()}
</AvatarFallback>
<AvatarImage
ref={imgRef}
className={classNames(css.UserAvatar, className)}
style={{ position: 'relative', zIndex: 1 }}
src={authenticatedSrc}

View File

@@ -30,6 +30,7 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom';
import { TitleBar } from '../../components/title-bar';
const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) {
@@ -123,6 +124,8 @@ export function AuthLayout() {
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
return (
<>
<TitleBar />
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
<Box
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
@@ -205,5 +208,6 @@ export function AuthLayout() {
<AuthFooter />
</Box>
</Scroll>
</>
);
}

View File

@@ -185,10 +185,7 @@ function createPaarrotExtension(color: string): Uint8Array {
export function extractColorFromGIF(imageData: ArrayBuffer | Uint8Array): string | undefined {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[extractColorFromGIF] Input size:', data.length, 'bytes');
if (!isGIF(data)) {
console.error('[extractColorFromGIF] Not a GIF file');
return undefined;
}
@@ -196,19 +193,16 @@ export function extractColorFromGIF(imageData: ArrayBuffer | Uint8Array): string
const startOffset = 13 + gctSize; // Header(6) + LSD(7) + GCT
const extensions = findAppExtensions(data, startOffset);
console.log('[extractColorFromGIF] Found', extensions.length, 'application extensions');
for (const ext of extensions) {
if (ext.appId.startsWith('PAARROT')) {
if (ext.dataBlocks.length > 0) {
const color = new TextDecoder().decode(ext.dataBlocks[0]);
console.log('[extractColorFromGIF] Found color:', color);
return color;
}
}
}
console.log('[extractColorFromGIF] No color found');
return undefined;
}
@@ -218,10 +212,7 @@ export function extractColorFromGIF(imageData: ArrayBuffer | Uint8Array): string
export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[embedColorInGIF] Input size:', data.length, 'bytes');
if (!isGIF(data)) {
console.error('[embedColorInGIF] Not a GIF file');
return null;
}
@@ -229,7 +220,6 @@ export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: stri
const insertOffset = 13 + gctSize; // After Header + LSD + GCT
const extensions = findAppExtensions(data, insertOffset);
console.log('[embedColorInGIF] Found', extensions.length, 'application extensions');
// Find existing PAARROT extension
let existingExt: { offset: number; length: number } | null = null;
@@ -243,14 +233,10 @@ export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: stri
// Create new extension
const newExtension = createPaarrotExtension(color);
console.log('[embedColorInGIF] Created extension, size:', newExtension.length, 'bytes');
console.log('[embedColorInGIF] Color being embedded:', color);
let newData: Uint8Array;
if (existingExt) {
// Replace existing extension
console.log('[embedColorInGIF] Replacing existing extension at offset', existingExt.offset);
newData = new Uint8Array(data.length - existingExt.length + newExtension.length);
newData.set(data.slice(0, existingExt.offset), 0);
newData.set(newExtension, existingExt.offset);
@@ -260,15 +246,12 @@ export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: stri
);
} else {
// Insert after GCT (before any other blocks)
console.log('[embedColorInGIF] Inserting new extension at offset', insertOffset);
newData = new Uint8Array(data.length + newExtension.length);
newData.set(data.slice(0, insertOffset), 0);
newData.set(newExtension, insertOffset);
newData.set(data.slice(insertOffset), insertOffset + newExtension.length);
}
console.log('[embedColorInGIF] Output size:', newData.length, 'bytes');
return newData;
}

View File

@@ -145,15 +145,11 @@ function isPaarrotXMP(segmentData: Uint8Array): boolean {
export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): string | undefined {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[extractColorFromJPEG] Input size:', data.length, 'bytes');
if (!isJPEG(data)) {
console.error('[extractColorFromJPEG] Not a JPEG file');
return undefined;
}
const segments = parseSegments(data);
console.log('[extractColorFromJPEG] Found', segments.length, 'segments');
for (const segment of segments) {
// APP1 marker (0xE1) is used for XMP
@@ -172,7 +168,6 @@ export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): strin
const xmpString = new TextDecoder().decode(segment.data.slice(XMP_NAMESPACE_BYTES.length));
const color = extractColorFromXMP(xmpString);
if (color) {
console.log('[extractColorFromJPEG] Found color:', color);
return color;
}
}
@@ -180,7 +175,6 @@ export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): strin
}
}
console.log('[extractColorFromJPEG] No color found');
return undefined;
}
@@ -190,15 +184,11 @@ export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): strin
export function embedColorInJPEG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[embedColorInJPEG] Input size:', data.length, 'bytes');
if (!isJPEG(data)) {
console.error('[embedColorInJPEG] Not a JPEG file');
return null;
}
const segments = parseSegments(data);
console.log('[embedColorInJPEG] Found', segments.length, 'segments');
// Find existing paarrot XMP segment
let existingSegment: { offset: number; length: number } | null = null;
@@ -225,14 +215,10 @@ export function embedColorInJPEG(imageData: ArrayBuffer | Uint8Array, color: str
app1Segment.set(writeUint16BE(segmentLength), 2);
app1Segment.set(segmentData, 4);
console.log('[embedColorInJPEG] Created APP1 segment, size:', app1Segment.length, 'bytes');
console.log('[embedColorInJPEG] Color being embedded:', color);
let newData: Uint8Array;
if (existingSegment) {
// Replace existing segment
console.log('[embedColorInJPEG] Replacing existing segment at offset', existingSegment.offset);
newData = new Uint8Array(data.length - existingSegment.length + app1Segment.length);
newData.set(data.slice(0, existingSegment.offset), 0);
newData.set(app1Segment, existingSegment.offset);
@@ -242,15 +228,12 @@ export function embedColorInJPEG(imageData: ArrayBuffer | Uint8Array, color: str
);
} else {
// Insert after SOI marker (at offset 2)
console.log('[embedColorInJPEG] Inserting new segment after SOI');
newData = new Uint8Array(data.length + app1Segment.length);
newData.set(JPEG_SOI, 0);
newData.set(app1Segment, 2);
newData.set(data.slice(2), 2 + app1Segment.length);
}
console.log('[embedColorInJPEG] Output size:', newData.length, 'bytes');
return newData;
}

View File

@@ -141,31 +141,24 @@ function parseTextChunk(data: Uint8Array): { key: string; value: string } | null
export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string | undefined {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[extractColorFromPNG] Input size:', data.length, 'bytes');
// Verify PNG signature
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIGNATURE[i]) {
console.error('[extractColorFromPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]);
return undefined; // Not a PNG
}
}
const chunks = parseChunks(data);
console.log('[extractColorFromPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', '));
for (const chunk of chunks) {
if (chunk.type === 'tEXt') {
const text = parseTextChunk(chunk.data);
console.log('[extractColorFromPNG] tEXt chunk found, key:', text?.key);
if (text && text.key === PAARROT_COLOR_KEY) {
console.log('[extractColorFromPNG] Found color:', text.value);
return text.value;
}
}
}
console.log('[extractColorFromPNG] No color found');
return undefined;
}
@@ -178,22 +171,14 @@ export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string
export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[embedColorInPNG] Input size:', data.length, 'bytes');
console.log('[embedColorInPNG] First 8 bytes:', Array.from(data.slice(0, 8)));
console.log('[embedColorInPNG] PNG signature expected:', Array.from(PNG_SIGNATURE));
// Verify PNG signature
for (let i = 0; i < 8; i++) {
if (data[i] !== PNG_SIGNATURE[i]) {
console.error('[embedColorInPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]);
return null; // Not a PNG
}
}
console.log('[embedColorInPNG] PNG signature verified OK');
const chunks = parseChunks(data);
console.log('[embedColorInPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', '));
// Find IHDR chunk (always first) and any existing paarrot tEXt chunk
let insertOffset = 8; // After signature
@@ -212,15 +197,12 @@ export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: stri
// Create new tEXt chunk with color
const colorChunk = createTextChunk(PAARROT_COLOR_KEY, color);
console.log('[embedColorInPNG] Created color chunk, size:', colorChunk.length, 'bytes');
console.log('[embedColorInPNG] Color being embedded:', color);
// Build new PNG data
let newData: Uint8Array;
if (existingPaarrotChunk) {
// Replace existing chunk
console.log('[embedColorInPNG] Replacing existing chunk at offset', existingPaarrotChunk.offset);
newData = new Uint8Array(data.length - existingPaarrotChunk.length + colorChunk.length);
newData.set(data.slice(0, existingPaarrotChunk.offset), 0);
newData.set(colorChunk, existingPaarrotChunk.offset);
@@ -230,15 +212,12 @@ export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: stri
);
} else {
// Insert new chunk after IHDR
console.log('[embedColorInPNG] Inserting new chunk at offset', insertOffset);
newData = new Uint8Array(data.length + colorChunk.length);
newData.set(data.slice(0, insertOffset), 0);
newData.set(colorChunk, insertOffset);
newData.set(data.slice(insertOffset), insertOffset + colorChunk.length);
}
console.log('[embedColorInPNG] Output size:', newData.length, 'bytes');
return newData;
}

View File

@@ -123,28 +123,22 @@ function parseChunks(data: Uint8Array): Array<{ id: string; data: Uint8Array; of
export function extractColorFromWebP(imageData: ArrayBuffer | Uint8Array): string | undefined {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[extractColorFromWebP] Input size:', data.length, 'bytes');
if (!isWebP(data)) {
console.error('[extractColorFromWebP] Not a WebP file');
return undefined;
}
const chunks = parseChunks(data);
console.log('[extractColorFromWebP] Found', chunks.length, 'chunks:', chunks.map(c => c.id).join(', '));
for (const chunk of chunks) {
if (chunk.id === 'XMP ') {
const xmpString = new TextDecoder().decode(chunk.data);
const color = extractColorFromXMP(xmpString);
if (color) {
console.log('[extractColorFromWebP] Found color:', color);
return color;
}
}
}
console.log('[extractColorFromWebP] No color found');
return undefined;
}
@@ -157,15 +151,11 @@ export function extractColorFromWebP(imageData: ArrayBuffer | Uint8Array): strin
export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
console.log('[embedColorInWebP] Input size:', data.length, 'bytes');
if (!isWebP(data)) {
console.error('[embedColorInWebP] Not a WebP file');
return null;
}
const chunks = parseChunks(data);
console.log('[embedColorInWebP] Found', chunks.length, 'chunks:', chunks.map(c => c.id).join(', '));
// Find existing XMP chunk with our color
let existingXMPChunk: { offset: number; length: number } | null = null;
@@ -192,14 +182,10 @@ export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: str
xmpChunk.set(xmpBytes, 8);
// Padding byte is already 0 from Uint8Array initialization
console.log('[embedColorInWebP] Created XMP chunk, size:', xmpChunk.length, 'bytes');
console.log('[embedColorInWebP] Color being embedded:', color);
let newData: Uint8Array;
if (existingXMPChunk) {
// Replace existing chunk
console.log('[embedColorInWebP] Replacing existing XMP chunk at offset', existingXMPChunk.offset);
newData = new Uint8Array(data.length - existingXMPChunk.length + xmpChunk.length);
newData.set(data.slice(0, existingXMPChunk.offset), 0);
newData.set(xmpChunk, existingXMPChunk.offset);
@@ -209,7 +195,6 @@ export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: str
);
} else {
// Insert new chunk at end (before any trailing data)
console.log('[embedColorInWebP] Inserting new XMP chunk at end');
newData = new Uint8Array(data.length + xmpChunk.length);
newData.set(data, 0);
newData.set(xmpChunk, data.length);
@@ -219,8 +204,6 @@ export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: str
const newFileSize = newData.length - 8; // RIFF size excludes first 8 bytes
newData.set(writeUint32LE(newFileSize), 4);
console.log('[embedColorInWebP] Output size:', newData.length, 'bytes');
return newData;
}