2 Commits

Author SHA1 Message Date
2603ca8e5c Fix avatar upload failing on JPEG/WebP/GIF without a profile color.
embedMetadataInImage returned null for non-PNG when there was nothing to embed, which blocked plain avatar saves after format/library churn.
2026-07-25 20:09:22 +10:00
99a294791e Open DMs during sync catch-up instead of blocking on parent maps.
Prefer the direct route for m.direct rooms and allow joined space children before roomToParents finishes backfilling.
2026-07-24 16:52:34 +10:00
10 changed files with 232 additions and 54 deletions

View File

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

View File

@@ -56,6 +56,19 @@ export const useRoomNavigate = () => {
} }
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
// Prefer Direct for m.direct rooms unless we are already browsing a parent space
// of this room (keep space chrome). Opening via space while roomToParents is still
// catching up used to hit JoinBeforeNavigate even though the DM was listed.
if (mDirects.has(roomId)) {
const stayInSelectedSpace =
Boolean(spaceSelectedId) && orphanParents.includes(spaceSelectedId!);
if (!stayInSelectedSpace) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return;
}
}
if (orphanParents.length > 0) { if (orphanParents.length > 0) {
let parentSpace: string; let parentSpace: string;
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
@@ -73,11 +86,6 @@ export const useRoomNavigate = () => {
return; return;
} }
if (mDirects.has(roomId)) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return;
}
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
}, },
[ [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -887,6 +887,12 @@ body.stationery-dark-theme {
width: calc(100% + 25px) !important; width: calc(100% + 25px) !important;
max-width: none !important; max-width: none !important;
box-sizing: border-box !important; box-sizing: border-box !important;
/* Gutter is only for post-it overflow paint — do not steal clicks from page-nav */
pointer-events: none;
}
.stationery [data-sidebar-scroll] > * {
pointer-events: auto;
} }
.stationery [data-sidebar] > *, .stationery [data-sidebar] > *,