Add MSC4522 username colors, release notes dialog, and profile layout fixes.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Replace legacy profile style metadata with MSC4133 profile fields, add in-app update notes, tighten account profile name spacing, and wire desktop media save helpers through the client.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user