diff --git a/src/app/components/account-switcher/AccountSwitcher.tsx b/src/app/components/account-switcher/AccountSwitcher.tsx index 171e63c..03a649c 100644 --- a/src/app/components/account-switcher/AccountSwitcher.tsx +++ b/src/app/components/account-switcher/AccountSwitcher.tsx @@ -1,15 +1,61 @@ -import React from 'react'; -import { Box, Text, Icon, Icons, MenuItem, config } from 'folds'; +import React, { useEffect, useState } from 'react'; +import { Avatar, Box, Icon, Icons, MenuItem, Text, config } from 'folds'; import { useAtomValue } from 'jotai'; import { sessionsAtom, Session } from '../../state/sessions'; import { setLocalStorageItem } from '../../state/utils/atomWithLocalStorage'; -import { getMxIdServer } from '../../utils/matrix'; +import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useUserProfile } from '../../hooks/useUserProfile'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { UserAvatar } from '../user-avatar'; type AccountSwitcherProps = { currentUserId?: string; }; +/** + * Loads and displays an avatar for a session that is not the active client. + * Fetches the profile directly via the homeserver REST API using stored credentials. + */ +function OtherSessionAvatar({ session }: { session: Session }) { + const [src, setSrc] = useState(); + + useEffect(() => { + const load = async () => { + try { + const res = await fetch( + `${session.baseUrl}/_matrix/client/v3/profile/${encodeURIComponent(session.userId)}`, + { headers: { Authorization: `Bearer ${session.accessToken}` } } + ); + if (!res.ok) return; + const data = await res.json(); + if (data.avatar_url) { + const match = (data.avatar_url as string).match(/^mxc:\/\/([^/]+)\/(.+)$/); + if (match) { + setSrc( + `${session.baseUrl}/_matrix/media/v3/thumbnail/${match[1]}/${match[2]}?width=32&height=32&method=crop` + ); + } + } + } catch { + // ignore — avatar just won't show + } + }; + load(); + }, [session.userId, session.baseUrl, session.accessToken]); + + return ( + + } + /> + + ); +} + const deleteAllMatrixDatabases = async (): Promise => { console.log('[AccountSwitcher] Deleting all Matrix IndexedDB databases...'); @@ -79,6 +125,11 @@ const deleteAllMatrixDatabases = async (): Promise => { export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) { const sessions = useAtomValue(sessionsAtom); const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const currentProfile = useUserProfile(currentUserId ?? ''); + const currentAvatarUrl = currentProfile.avatarUrl + ? mxcUrlToHttp(mx, currentProfile.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined + : undefined; const handleSwitchAccount = async (session: Session) => { if (session.userId === currentUserId) return; @@ -120,8 +171,8 @@ export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) { const hasMultipleAccounts = sessions.length > 1; return ( - - + + {hasMultipleAccounts ? 'Switch Account' : 'Accounts'} @@ -133,11 +184,19 @@ export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) { radii="300" variant="SurfaceVariant" disabled + style={{ paddingInline: config.space.S200 }} after={} > - - + + } + /> + + {currentUserId} @@ -162,10 +221,11 @@ export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) { radii="300" onClick={() => handleSwitchAccount(session)} variant="Surface" + style={{ paddingInline: config.space.S200 }} > - - + + {session.userId} diff --git a/src/app/components/custom-status/CustomStatusDialog.tsx b/src/app/components/custom-status/CustomStatusDialog.tsx new file mode 100644 index 0000000..e587aa3 --- /dev/null +++ b/src/app/components/custom-status/CustomStatusDialog.tsx @@ -0,0 +1,180 @@ +import React, { useState, useCallback, ChangeEventHandler } from 'react'; +import { + Box, + Button, + config, + Dialog, + Input, + Text, + Overlay, + OverlayBackdrop, + OverlayCenter, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useUserPresence } from '../../hooks/useUserPresence'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; + +/** + * Props for the CustomStatusDialog component + */ +export interface CustomStatusDialogProps { + /** Whether the dialog is currently open */ + open: boolean; + /** Callback function invoked when the dialog should close */ + onClose: () => void; +} + +/** + * CustomStatusDialog Component + * + * Provides a dialog interface for users to set or clear their custom status message. + * The status is stored in Matrix presence and will be visible to other users. + * + * @param props - Component props + * @returns The custom status dialog component + */ +export function CustomStatusDialog({ open, onClose }: CustomStatusDialogProps) { + const mx = useMatrixClient(); + const userId = mx.getUserId(); + const presence = useUserPresence(userId ?? ''); + + const [statusText, setStatusText] = useState(presence?.status || ''); + + /** + * Handles input changes for the status text field + * @param evt - The change event from the input + */ + const handleChange: ChangeEventHandler = (evt) => { + setStatusText(evt.currentTarget.value); + }; + + /** + * Async callback to save the status to Matrix presence + */ + const [saveState, saveStatus] = useAsyncCallback( + useCallback(async () => { + await mx.setPresence({ + presence: presence?.presence || 'online', + status_msg: statusText, + }); + onClose(); + }, [mx, presence?.presence, statusText, onClose]) + ); + + /** + * Async callback to clear the status from Matrix presence + */ + const [clearState, clearStatus] = useAsyncCallback( + useCallback(async () => { + await mx.setPresence({ + presence: presence?.presence || 'online', + status_msg: '', + }); + setStatusText(''); + onClose(); + }, [mx, presence?.presence, onClose]) + ); + + /** + * Handles form submission to save the custom status + * @param evt - The form submit event + */ + const handleSubmit = (evt: React.FormEvent) => { + evt.preventDefault(); + saveStatus(); + }; + + /** + * Handles the clear status button click + */ + const handleClear = () => { + clearStatus(); + }; + + if (!open) return null; + + return ( + }> + + + + + + Set Custom Status + + Your custom status will be visible to other users + + + + + + {statusText.length > 0 && ( + + {statusText.length}/100 characters + + )} + + + {(saveState.status === AsyncStatus.Error || clearState.status === AsyncStatus.Error) && ( + + Failed to update status. Please try again. + + )} + + + + {presence?.status && ( + + )} + + + + + + + + ); +} diff --git a/src/app/components/custom-status/index.ts b/src/app/components/custom-status/index.ts new file mode 100644 index 0000000..73d2ff1 --- /dev/null +++ b/src/app/components/custom-status/index.ts @@ -0,0 +1 @@ +export { CustomStatusDialog, type CustomStatusDialogProps } from './CustomStatusDialog'; diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 0e7fb74..aacb06f 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Avatar, Box, + color, Icon, Icons, Modal, @@ -9,38 +10,51 @@ import { OverlayBackdrop, OverlayCenter, Text, + toRem, } from 'folds'; import classNames from 'classnames'; import FocusTrap from 'focus-trap-react'; import * as css from './styles.css'; import { UserAvatar } from '../user-avatar'; import colorMXID from '../../../util/colorMXID'; -import { getMxIdLocalPart } from '../../utils/matrix'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { BreakWord, LineClamp3 } from '../../styles/Text.css'; import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; import { ImageViewer } from '../image-viewer'; import { stopPropagation } from '../../utils/keyboard'; +import { useOtherUserColor } from '../../hooks/useUserColor'; +import { useOtherUserBanner } from '../../hooks/useUserBanner'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; type UserHeroProps = { userId: string; avatarUrl?: string; + avatarMxc?: string; presence?: UserPresence; }; -export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { +export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); const [viewAvatar, setViewAvatar] = useState(); + const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly return (
- {avatarUrl && ( - {userId} + {bannerUrl ? ( + {userId} + ) : ( + avatarUrl && ( + {userId} + ) )}
@@ -65,6 +79,23 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { /> + {presence?.status && ( + + + {presence.status} + + + )} {viewAvatar && ( }> @@ -76,7 +107,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { escapeDeactivates: stopPropagation, }} > - evt.stopPropagation()}> + evt.stopPropagation()}> + - - @{username} + + {userId} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 78d201e..fcfe167 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -72,12 +72,17 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { - + {userId !== myUserId && ( + + {colorError && ( + + {colorError} + + )} + + } + onRemove={userColor ? handleColorRemove : undefined} + > + {(onOpen) => ( + + )} + + {isEditingName ? ( + setEditedName(e.target.value)} + onKeyDown={handleNameKeyDown} + onBlur={handleNameSave} + variant="Background" + size="400" + disabled={savingName} + style={{ + fontSize: 'var(--token.font-size.H400)', + fontWeight: 'var(--token.font-weight.H400)', + padding: `${toRem(4)} ${toRem(8)}`, + }} + /> + ) : ( + { + setEditedName(profile.displayName || getMxIdLocalPart(userId) || userId); + setIsEditingName(true); + }} + onMouseEnter={() => setHoveredArea('name')} + onMouseLeave={() => setHoveredArea(null)} + style={{ + border: 'none', + background: 'none', + cursor: 'pointer', + padding: `${toRem(4)} ${toRem(8)}`, + margin: `${toRem(-4)} ${toRem(-8)}`, + position: 'relative', + textAlign: 'left', + }} + > + + {profile.displayName || getMxIdLocalPart(userId) || userId} + + {hoveredArea === 'name' && ( + + + + )} + + )} + + + + {userId} + + + + + + + {uploadAtom && ( + - ) : ( - - - {avatarUrl && ( - - )} + )} + {avatarUploadAtom && ( + + setAvatarFile(undefined)} + onComplete={handleAvatarUploaded} + /> )} - - {imageFileURL && ( - }> - - - - - - - - + {saving && ( + + + Saving to server… + )} - - }> - - setAlertRemove(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - Remove Avatar - - setAlertRemove(false)} radii="300"> - - -
- - - Are you sure you want to remove profile avatar? - - - -
-
-
-
- - ); -} - -function ProfileDisplayName({ profile, userId }: ProfileProps) { - const mx = useMatrixClient(); - const capabilities = useCapabilities(); - const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false; - - const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const [displayName, setDisplayName] = useState(defaultDisplayName); - - const [changeState, changeDisplayName] = useAsyncCallback( - useCallback((name: string) => mx.setDisplayName(name), [mx]) - ); - const changingDisplayName = changeState.status === AsyncStatus.Loading; - - useEffect(() => { - setDisplayName(defaultDisplayName); - }, [defaultDisplayName]); - - const handleChange: ChangeEventHandler = (evt) => { - const name = evt.currentTarget.value; - setDisplayName(name); - }; - - const handleReset = () => { - setDisplayName(defaultDisplayName); - }; - - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - if (changingDisplayName) return; - - const target = evt.target as HTMLFormElement | undefined; - const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined; - const name = displayNameInput?.value; - if (!name) return; - - changeDisplayName(name); - }; - - const hasChanges = displayName !== defaultDisplayName; - return ( - - Display Name - - } - > - - - - - - - ) - } - /> - - - - - - ); -} - -/** - * Color picker component for user's profile color - * Stored in avatar image metadata, visible to other Paarrot users - */ -function ProfileColor() { - const [userColor, setUserColor, loading] = useUserColor(); - const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6'); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(); - - useEffect(() => { - if (userColor) { - setLocalColor(userColor); - } - }, [userColor]); - - const handleColorChange = (newColor: string) => { - setLocalColor(newColor); - setError(undefined); - }; - - const handleSaveColor = async () => { - setSaving(true); - setError(undefined); - try { - await setUserColor(localColor); - } catch (e) { - setError('Failed to save. Make sure you have an avatar set.'); - } - setSaving(false); - }; - - const handleRemoveColor = async () => { - setSaving(true); - setError(undefined); - try { - await setUserColor(undefined); - setLocalColor('#3b82f6'); - } catch (e) { - setError('Failed to remove color.'); - } - setSaving(false); - }; - - return ( - - Profile Color - - } - description="Embedded in your avatar. Other Paarrot users will see this color." - after={ - - {loading ? ( - Loading... - ) : ( - - - - { - setLocalColor(e.target.value); - setError(undefined); - }} - /> - - - {error && ( - {error} - )} - - } - onRemove={userColor ? handleRemoveColor : undefined} - > - {(onOpen) => ( - - )} - - )} -
- } - /> + {error && ( + {error} + )} + ); } export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId()!; - const profile = useUserProfile(userId); + const avatarRef = useRef(null); + const nameRef = useRef(null); return ( - - Profile - - - - - - + ); } diff --git a/src/app/hooks/useUserBanner.ts b/src/app/hooks/useUserBanner.ts new file mode 100644 index 0000000..6a781ca --- /dev/null +++ b/src/app/hooks/useUserBanner.ts @@ -0,0 +1,295 @@ +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 { + fetchAndExtractMetadata, + extractMetadataFromImage, + embedMetadataInImage, + detectImageFormat, + getMimeType, + getExtension, + ImageMetadata, +} from '../utils/imageMetadata'; + +/** + * Fetches the user's current avatar as raw image data + */ +async function fetchAvatarData( + mx: MatrixClient, + avatarMxc: string, + useAuthentication: boolean +): Promise { + const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication); + if (!url) return null; + + try { + const accessToken = mx.getAccessToken(); + const response = await fetch(url, { + method: 'GET', + headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); + 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 + */ +export function useUserBanner(): [ + string | undefined, + (banner: string | undefined) => Promise, + boolean +] { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [banner, setBanner] = useState(); + 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; + + if (!avatarUrl) { + setBanner(undefined); + setLoading(false); + return; + } + + const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication); + if (!httpUrl) { + setBanner(undefined); + setLoading(false); + return; + } + + const accessToken = mx.getAccessToken(); + const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); + 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] = () => { + 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'); + } + + console.log('[useUserBanner] Starting banner update:', newBanner); + + const profile = await mx.getProfileInfo(userId); + const avatarUrl = profile.avatar_url; + + if (!avatarUrl) { + throw new Error('No avatar set. Please upload an avatar first.'); + } + + console.log('[useUserBanner] Current avatar URL:', avatarUrl); + + // Fetch current avatar + const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication); + if (!avatarData) { + throw new Error('Failed to fetch current avatar'); + } + + console.log('[useUserBanner] Fetched avatar data, size:', avatarData.byteLength); + + // Detect image format + const format = detectImageFormat(avatarData); + console.log('[useUserBanner] Detected format:', format); + + if (format === 'unknown') { + throw new Error('Unsupported avatar image format'); + } + + // Get existing metadata to preserve + const existingMetadata = extractMetadataFromImage(avatarData); + console.log('[useUserBanner] Existing metadata:', existingMetadata); + + // Modify image metadata with new banner, preserving color + const newMetadata: ImageMetadata = { + color: existingMetadata.color, + banner: newBanner, + }; + + console.log('[useUserBanner] Embedding metadata:', newMetadata); + const newAvatarData = embedMetadataInImage(avatarData, newMetadata); + + if (!newAvatarData) { + throw new Error('Failed to embed banner in avatar metadata'); + } + + console.log('[useUserBanner] New avatar data size:', newAvatarData.byteLength); + + // Verify the banner was embedded correctly before uploading + const verifyMetadata = extractMetadataFromImage(newAvatarData); + console.log('[useUserBanner] Verification metadata:', verifyMetadata); + + if (newBanner && verifyMetadata.banner !== newBanner) { + throw new Error('Banner verification failed'); + } + + // Upload modified avatar with correct MIME type + const mimeType = getMimeType(format); + const extension = getExtension(format); + const blob = new Blob([newAvatarData], { type: mimeType }); + + console.log('[useUserBanner] Uploading avatar blob:', blob.size, 'bytes, type:', mimeType); + const uploadResponse = await mx.uploadContent(blob, { + name: `avatar.${extension}`, + type: mimeType, + }); + + console.log('[useUserBanner] Upload response:', uploadResponse.content_uri); + + // Update profile with new avatar + console.log('[useUserBanner] Calling setAvatarUrl...'); + try { + await Promise.race([ + mx.setAvatarUrl(uploadResponse.content_uri), + new Promise((_, reject) => setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000)) + ]); + console.log('[useUserBanner] Avatar URL updated successfully'); + + // 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); + } + } catch (error) { + console.error('[useUserBanner] setAvatarUrl failed:', 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]); + + return [banner, updateBanner, loading]; +} + +// Cache for user banners to avoid refetching for each message +const userBannerCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * 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 + */ +export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [bannerBlobUrl, setBannerBlobUrl] = useState(); + + useEffect(() => { + let blobUrl: string | undefined; + + if (!avatarMxc) { + setBannerBlobUrl(undefined); + return; + } + + // Check cache first + const cacheKey = `${userId}:${avatarMxc}`; + const cached = userBannerCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + setBannerBlobUrl(cached.banner); + return; + } + + const loadBanner = async () => { + const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); + if (!httpUrl) { + setBannerBlobUrl(undefined); + return; + } + + const accessToken = mx.getAccessToken(); + const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); + + if (!metadata.banner) { + // Cache the result + userBannerCache.set(cacheKey, { banner: undefined, timestamp: Date.now() }); + setBannerBlobUrl(undefined); + return; + } + + // Fetch the banner image data with authentication + const bannerHttpUrl = mxcUrlToHttp(mx, metadata.banner, useAuthentication); + if (!bannerHttpUrl) { + setBannerBlobUrl(undefined); + return; + } + + const headers: HeadersInit = {}; + if (useAuthentication && accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + + const response = await fetch(bannerHttpUrl, { headers }); + if (!response.ok) { + console.warn('Failed to fetch banner image:', response.status); + setBannerBlobUrl(undefined); + return; + } + + const blob = await response.blob(); + blobUrl = URL.createObjectURL(blob); + + // Cache the blob URL + userBannerCache.set(cacheKey, { banner: blobUrl, timestamp: Date.now() }); + + setBannerBlobUrl(blobUrl); + }; + + loadBanner(); + + // Cleanup blob URL when component unmounts or deps change + return () => { + if (blobUrl) { + URL.revokeObjectURL(blobUrl); + } + }; + }, [mx, useAuthentication, avatarMxc, userId]); + + return bannerBlobUrl; +} diff --git a/src/app/hooks/useUserColor.ts b/src/app/hooks/useUserColor.ts index 33cddc9..ed1b8c7 100644 --- a/src/app/hooks/useUserColor.ts +++ b/src/app/hooks/useUserColor.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { MatrixClient } from 'matrix-js-sdk'; +import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useMediaAuthentication } from './useMediaAuthentication'; import { mxcUrlToHttp } from '../utils/matrix'; @@ -25,7 +25,11 @@ async function fetchAvatarData( if (!url) return null; try { - const response = await fetch(url); + const accessToken = mx.getAccessToken(); + const response = await fetch(url, { + method: 'GET', + headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); if (!response.ok) return null; return await response.arrayBuffer(); } catch { @@ -75,7 +79,8 @@ export function useUserColor(): [ return; } - const extractedColor = await fetchAndExtractColor(httpUrl); + const accessToken = mx.getAccessToken(); + const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); setColor(extractedColor); } catch (e) { console.error('Failed to load user color from avatar:', e); @@ -85,6 +90,21 @@ export function useUserColor(): [ }; 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); + + return () => { + user?.removeListener(UserEvent.AvatarUrl, onAvatarChange); + }; }, [mx, useAuthentication]); const updateColor = useCallback(async (newColor: string | undefined) => { @@ -157,6 +177,12 @@ export function useUserColor(): [ // 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) { @@ -204,7 +230,8 @@ export function useOtherUserColor(userId: string, avatarMxc: string | undefined) return; } - const extractedColor = await fetchAndExtractColor(httpUrl); + const accessToken = mx.getAccessToken(); + const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); // Cache the result userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() }); diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index 5063c02..01d6c01 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -1,8 +1,8 @@ import React, { MouseEventHandler, useState } from 'react'; -import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; +import { Box, config, Icon, Icons, Line, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; import FocusTrap from 'focus-trap-react'; import { useNavigate } from 'react-router-dom'; -import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; +import { SidebarItem, SidebarAvatar } from '../../../components/sidebar'; import { UserAvatar } from '../../../components/user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; @@ -13,21 +13,23 @@ import { useUserProfile } from '../../../hooks/useUserProfile'; import { Modal500 } from '../../../components/Modal500'; import { AccountSwitcher } from '../../../components/account-switcher'; import { stopPropagation } from '../../../utils/keyboard'; -import { getLoginPath, withSearchParam } from '../../pathUtils'; +import { getLoginPath } from '../../pathUtils'; import { logoutClient } from '../../../../client/initMatrix'; +import { CustomStatusDialog } from '../../../components/custom-status'; export function SettingsTab() { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const userId = mx.getUserId()!; - const profile = useUserProfile(userId); + const userId = mx.getUserId(); + const profile = useUserProfile(userId ?? ''); const navigate = useNavigate(); const [settings, setSettings] = useState(false); const [menuAnchor, setMenuAnchor] = useState(); + const [customStatusOpen, setCustomStatusOpen] = useState(false); - const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; - const avatarUrl = profile.avatarUrl + const displayName = profile.displayName ?? getMxIdLocalPart(userId ?? '') ?? userId ?? ''; + const avatarUrl = profile.avatarUrl && userId ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; @@ -42,22 +44,17 @@ export function SettingsTab() { return ( - - {(triggerRef) => ( - - {nameInitials(displayName)}} - /> - - )} - + + {nameInitials(displayName)}} + /> + - + + + + { setMenuAnchor(undefined); - const loginPath = getLoginPath() + '?add-account=true'; - console.log('[SettingsTab] Navigating to add account:', loginPath); - localStorage.setItem('debug-add-account-nav', JSON.stringify({ - timestamp: new Date().toISOString(), - loginPath, - currentUrl: window.location.href - })); - navigate(loginPath); + navigate(`${getLoginPath()}?add-account=true`); }} size="300" radii="300" @@ -116,11 +109,34 @@ export function SettingsTab() { + + + { + setMenuAnchor(undefined); + setCustomStatusOpen(true); + }} + size="300" + radii="300" + > + + + + Set Custom Status + + + + } /> + setCustomStatusOpen(false)} + /> + {settings && ( diff --git a/src/app/utils/imageMetadata.ts b/src/app/utils/imageMetadata.ts index 98af550..33d86b8 100644 --- a/src/app/utils/imageMetadata.ts +++ b/src/app/utils/imageMetadata.ts @@ -1,16 +1,28 @@ /** - * Unified image metadata utilities for embedding/extracting user color + * Unified image metadata utilities for embedding/extracting user color and banner * Supports PNG, WebP, JPEG, and GIF formats */ /* eslint-disable no-plusplus */ /* eslint-disable no-console */ -import { embedColorInPNG, extractColorFromPNG, removeColorFromPNG } from './pngMetadata'; +import { + embedColorInPNG, + extractColorFromPNG, + removeColorFromPNG, + embedMetadataInPNG, + extractMetadataFromPNG, + extractBannerFromPNG, +} from './pngMetadata'; import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './webpMetadata'; import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata'; import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata'; +export type ImageMetadata = { + color?: string; + banner?: string; +}; + /** PNG signature bytes */ const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; @@ -114,6 +126,48 @@ export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): stri } } +/** + * Extract paarrot banner URL from image data (any supported format) + * @param imageData - Raw image data as ArrayBuffer or Uint8Array + * @returns The banner MXC URL if found, or undefined + */ +export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined { + const format = detectImageFormat(imageData); + + switch (format) { + case 'png': + return extractBannerFromPNG(imageData); + // For now, only PNG supports banner. Add other formats later + default: + console.warn('[extractBannerFromImage] Format not yet supported for banner'); + return undefined; + } +} + +/** + * Extract all paarrot metadata from image data (any supported format) + * @param imageData - Raw image data as ArrayBuffer or Uint8Array + * @returns Object with color and banner if found + */ +export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata { + const format = detectImageFormat(imageData); + + switch (format) { + case 'png': + return extractMetadataFromPNG(imageData); + // For other formats, extract color only for now + case 'webp': + case 'jpeg': + case 'gif': { + const color = extractColorFromImage(imageData); + return color ? { color } : {}; + } + default: + console.warn('[extractMetadataFromImage] Unknown image format'); + return {}; + } +} + /** * Embed paarrot color into image data (PNG, WebP, JPEG, or GIF) * @param imageData - Raw image data as ArrayBuffer or Uint8Array @@ -138,6 +192,36 @@ export function embedColorInImage(imageData: ArrayBuffer | Uint8Array, color: st } } +/** + * Embed paarrot metadata (color and/or banner) into image data + * Preserves existing metadata that is not being updated + * @param imageData - Raw image data as ArrayBuffer or Uint8Array + * @param metadata - Object with color and/or banner to embed + * @returns New image data with embedded metadata, or null if not a supported format + */ +export function embedMetadataInImage( + imageData: ArrayBuffer | Uint8Array, + metadata: ImageMetadata +): Uint8Array | null { + const format = detectImageFormat(imageData); + + switch (format) { + case 'png': + return embedMetadataInPNG(imageData, metadata); + // For other formats, only embed color for now + case 'webp': + case 'jpeg': + case 'gif': + if (metadata.color) { + return embedColorInImage(imageData, metadata.color); + } + return null; + default: + console.warn('[embedMetadataInImage] Unknown image format, cannot embed metadata'); + return null; + } +} + /** * Remove paarrot color from image data (PNG, WebP, JPEG, or GIF) * @param imageData - Raw image data as ArrayBuffer or Uint8Array @@ -198,13 +282,17 @@ export function getExtension(format: ImageFormat): string { } /** - * Fetch and extract color from an image URL (supports PNG and WebP) + * Fetch and extract color from an image URL (supports all formats) * @param url - HTTP URL to fetch the image from + * @param accessToken - Optional access token for authenticated media * @returns The color string if found, or undefined */ -export async function fetchAndExtractColor(url: string): Promise { +export async function fetchAndExtractColor(url: string, accessToken?: string | null): Promise { try { - const response = await fetch(url); + const response = await fetch(url, { + method: 'GET', + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); if (!response.ok) return undefined; const data = await response.arrayBuffer(); @@ -213,3 +301,24 @@ export async function fetchAndExtractColor(url: string): Promise { + try { + const response = await fetch(url, { + method: 'GET', + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); + if (!response.ok) return {}; + + const data = await response.arrayBuffer(); + return extractMetadataFromImage(data); + } catch { + return {}; + } +} diff --git a/src/app/utils/pngMetadata.ts b/src/app/utils/pngMetadata.ts index ea28870..b3dbf08 100644 --- a/src/app/utils/pngMetadata.ts +++ b/src/app/utils/pngMetadata.ts @@ -14,6 +14,9 @@ const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); /** Key used to store user color in PNG metadata */ export const PAARROT_COLOR_KEY = 'paarrot:color'; +/** Key used to store banner URL in PNG metadata */ +export const PAARROT_BANNER_KEY = 'paarrot:banner'; + let crc32Table: Uint32Array | null = null; function getCRC32Table(): Uint32Array { if (crc32Table) return crc32Table; @@ -162,6 +165,69 @@ export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string return undefined; } +/** + * Extract paarrot banner URL from PNG image data + * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array + * @returns The banner MXC URL if found, or undefined + */ +export function extractBannerFromPNG(imageData: ArrayBuffer | Uint8Array): string | undefined { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + // Verify PNG signature + for (let i = 0; i < 8; i++) { + if (data[i] !== PNG_SIGNATURE[i]) { + return undefined; // Not a PNG + } + } + + const chunks = parseChunks(data); + + for (const chunk of chunks) { + if (chunk.type === 'tEXt') { + const text = parseTextChunk(chunk.data); + if (text && text.key === PAARROT_BANNER_KEY) { + return text.value; + } + } + } + + return undefined; +} + +/** + * Extract all paarrot metadata from PNG image data + * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array + * @returns Object with color and banner if found + */ +export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): { color?: string; banner?: string } { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + const metadata: { color?: string; banner?: string } = {}; + + // Verify PNG signature + for (let i = 0; i < 8; i++) { + if (data[i] !== PNG_SIGNATURE[i]) { + return metadata; // Not a PNG + } + } + + const chunks = parseChunks(data); + + for (const chunk of chunks) { + if (chunk.type === 'tEXt') { + const text = parseTextChunk(chunk.data); + if (text) { + if (text.key === PAARROT_COLOR_KEY) { + metadata.color = text.value; + } else if (text.key === PAARROT_BANNER_KEY) { + metadata.banner = text.value; + } + } + } + } + + return metadata; +} + /** * Embed paarrot color into PNG image data * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array @@ -221,6 +287,103 @@ export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: stri return newData; } +/** + * Embed paarrot metadata (color and/or banner) into PNG image data + * Preserves existing metadata that is not being updated + * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array + * @param metadata - Object with color and/or banner to embed + * @returns New PNG data with embedded metadata, or null if not a valid PNG + */ +export function embedMetadataInPNG( + imageData: ArrayBuffer | Uint8Array, + metadata: { color?: string; banner?: string } +): Uint8Array | null { + const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); + + // Verify PNG signature + for (let i = 0; i < 8; i++) { + if (data[i] !== PNG_SIGNATURE[i]) { + return null; // Not a PNG + } + } + + const chunks = parseChunks(data); + + // Get existing metadata + const existing = extractMetadataFromPNG(data); + + // Merge with new metadata + const finalMetadata = { + color: metadata.color !== undefined ? metadata.color : existing.color, + banner: metadata.banner !== undefined ? metadata.banner : existing.banner, + }; + + // Find IHDR chunk and existing paarrot chunks + let insertOffset = 8; + const existingChunks: { key: string; offset: number; length: number }[] = []; + + for (const chunk of chunks) { + if (chunk.type === 'IHDR') { + insertOffset = chunk.offset + chunk.length; + } else if (chunk.type === 'tEXt') { + const text = parseTextChunk(chunk.data); + if (text && (text.key === PAARROT_COLOR_KEY || text.key === PAARROT_BANNER_KEY)) { + existingChunks.push({ key: text.key, offset: chunk.offset, length: chunk.length }); + } + } + } + + // Create new chunks + const newChunks: Uint8Array[] = []; + if (finalMetadata.color) { + newChunks.push(createTextChunk(PAARROT_COLOR_KEY, finalMetadata.color)); + } + if (finalMetadata.banner) { + newChunks.push(createTextChunk(PAARROT_BANNER_KEY, finalMetadata.banner)); + } + + // Calculate new size + const removedSize = existingChunks.reduce((sum, chunk) => sum + chunk.length, 0); + const addedSize = newChunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const newSize = data.length - removedSize + addedSize; + + // Build new PNG + const newData = new Uint8Array(newSize); + let writeOffset = 0; + let readOffset = 0; + + // Sort existing chunks by offset + existingChunks.sort((a, b) => a.offset - b.offset); + + // Copy data, skipping old chunks and inserting new ones + for (const existingChunk of existingChunks) { + // Copy data before this chunk + newData.set(data.slice(readOffset, existingChunk.offset), writeOffset); + writeOffset += existingChunk.offset - readOffset; + + // Skip the old chunk + readOffset = existingChunk.offset + existingChunk.length; + } + + // If no existing chunks, insert at insertOffset + if (existingChunks.length === 0) { + newData.set(data.slice(0, insertOffset), 0); + writeOffset = insertOffset; + readOffset = insertOffset; + } + + // Insert new chunks + for (const newChunk of newChunks) { + newData.set(newChunk, writeOffset); + writeOffset += newChunk.byteLength; + } + + // Copy remaining data + newData.set(data.slice(readOffset), writeOffset); + + return newData; +} + /** * Remove paarrot color from PNG image data * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array