feat: Enhance user color and banner management
- Updated `useUserColor` hook to support fetching and embedding user color from avatar metadata with authentication. - Introduced `useUserBanner` hook for managing user banners stored in avatar metadata, including fetching and embedding functionality. - Added `fetchAndExtractMetadata` utility to retrieve both color and banner from images. - Implemented `CustomStatusDialog` component for users to set and clear custom status messages. - Modified `SettingsTab` to include custom status functionality and improved user experience. - Enhanced image metadata utilities to support banner extraction and embedding in PNG format. - Updated sidebar settings to handle user profile changes more gracefully.
This commit is contained in:
@@ -1,15 +1,61 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Box, Text, Icon, Icons, MenuItem, config } from 'folds';
|
import { Avatar, Box, Icon, Icons, MenuItem, Text, config } from 'folds';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { sessionsAtom, Session } from '../../state/sessions';
|
import { sessionsAtom, Session } from '../../state/sessions';
|
||||||
import { setLocalStorageItem } from '../../state/utils/atomWithLocalStorage';
|
import { setLocalStorageItem } from '../../state/utils/atomWithLocalStorage';
|
||||||
import { getMxIdServer } from '../../utils/matrix';
|
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useUserProfile } from '../../hooks/useUserProfile';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { UserAvatar } from '../user-avatar';
|
||||||
|
|
||||||
type AccountSwitcherProps = {
|
type AccountSwitcherProps = {
|
||||||
currentUserId?: string;
|
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<string | undefined>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
|
||||||
|
<UserAvatar
|
||||||
|
userId={session.userId}
|
||||||
|
src={src}
|
||||||
|
alt={session.userId}
|
||||||
|
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const deleteAllMatrixDatabases = async (): Promise<void> => {
|
const deleteAllMatrixDatabases = async (): Promise<void> => {
|
||||||
console.log('[AccountSwitcher] Deleting all Matrix IndexedDB databases...');
|
console.log('[AccountSwitcher] Deleting all Matrix IndexedDB databases...');
|
||||||
|
|
||||||
@@ -79,6 +125,11 @@ const deleteAllMatrixDatabases = async (): Promise<void> => {
|
|||||||
export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
|
export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
|
||||||
const sessions = useAtomValue(sessionsAtom);
|
const sessions = useAtomValue(sessionsAtom);
|
||||||
const mx = useMatrixClient();
|
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) => {
|
const handleSwitchAccount = async (session: Session) => {
|
||||||
if (session.userId === currentUserId) return;
|
if (session.userId === currentUserId) return;
|
||||||
@@ -120,8 +171,8 @@ export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
|
|||||||
const hasMultipleAccounts = sessions.length > 1;
|
const hasMultipleAccounts = sessions.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="L400" style={{ padding: config.space.S200 }}>
|
<Text size="L400" style={{ paddingInline: config.space.S200, paddingBlock: config.space.S100 }}>
|
||||||
{hasMultipleAccounts ? 'Switch Account' : 'Accounts'}
|
{hasMultipleAccounts ? 'Switch Account' : 'Accounts'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -133,11 +184,19 @@ export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
|
|||||||
radii="300"
|
radii="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
disabled
|
disabled
|
||||||
|
style={{ paddingInline: config.space.S200 }}
|
||||||
after={<Icon size="100" src={Icons.Check} filled />}
|
after={<Icon size="100" src={Icons.Check} filled />}
|
||||||
>
|
>
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
<Icon size="100" src={Icons.User} />
|
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
|
||||||
<Box direction="Column" gap="100">
|
<UserAvatar
|
||||||
|
userId={currentUserId}
|
||||||
|
src={currentAvatarUrl}
|
||||||
|
alt={currentUserId}
|
||||||
|
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Box direction="Column" style={{ gap: '2px' }}>
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
{currentUserId}
|
{currentUserId}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -162,10 +221,11 @@ export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
|
|||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => handleSwitchAccount(session)}
|
onClick={() => handleSwitchAccount(session)}
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
|
style={{ paddingInline: config.space.S200 }}
|
||||||
>
|
>
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
<Icon size="100" src={Icons.User} />
|
<OtherSessionAvatar session={session} />
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" style={{ gap: '2px' }}>
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
{session.userId}
|
{session.userId}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
180
src/app/components/custom-status/CustomStatusDialog.tsx
Normal file
180
src/app/components/custom-status/CustomStatusDialog.tsx
Normal file
@@ -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<HTMLInputElement> = (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 (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog>
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">Set Custom Status</Text>
|
||||||
|
<Text size="T300" priority="400">
|
||||||
|
Your custom status will be visible to other users
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Input
|
||||||
|
value={statusText}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="What's on your mind?"
|
||||||
|
maxLength={100}
|
||||||
|
autoFocus
|
||||||
|
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
|
||||||
|
/>
|
||||||
|
{statusText.length > 0 && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{statusText.length}/100 characters
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{(saveState.status === AsyncStatus.Error || clearState.status === AsyncStatus.Error) && (
|
||||||
|
<Text size="T300" priority="400" style={{ color: 'var(--color-critical-main)' }}>
|
||||||
|
Failed to update status. Please try again.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box direction="Row" gap="200" justifyContent="End">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="Secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
{presence?.status && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
|
||||||
|
>
|
||||||
|
<Text size="B400">Clear Status</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="Primary"
|
||||||
|
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
|
||||||
|
>
|
||||||
|
<Text size="B400">
|
||||||
|
{saveState.status === AsyncStatus.Loading ? 'Saving...' : 'Save'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/components/custom-status/index.ts
Normal file
1
src/app/components/custom-status/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { CustomStatusDialog, type CustomStatusDialogProps } from './CustomStatusDialog';
|
||||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
color,
|
||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -9,38 +10,51 @@ import {
|
|||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
OverlayCenter,
|
OverlayCenter,
|
||||||
Text,
|
Text,
|
||||||
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { UserAvatar } from '../user-avatar';
|
import { UserAvatar } from '../user-avatar';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
||||||
import { UserPresence } from '../../hooks/useUserPresence';
|
import { UserPresence } from '../../hooks/useUserPresence';
|
||||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||||
import { ImageViewer } from '../image-viewer';
|
import { ImageViewer } from '../image-viewer';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
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 = {
|
type UserHeroProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
avatarMxc?: string;
|
||||||
presence?: UserPresence;
|
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<string>();
|
const [viewAvatar, setViewAvatar] = useState<string>();
|
||||||
|
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" className={css.UserHero}>
|
<Box direction="Column" className={css.UserHero}>
|
||||||
<div
|
<div
|
||||||
className={css.UserHeroCoverContainer}
|
className={css.UserHeroCoverContainer}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colorMXID(userId),
|
backgroundColor: bannerUrl ? undefined : colorMXID(userId),
|
||||||
filter: avatarUrl ? undefined : 'brightness(50%)',
|
filter: bannerUrl || avatarUrl ? undefined : 'brightness(50%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{avatarUrl && (
|
{bannerUrl ? (
|
||||||
|
<img className={css.UserHeroBanner} src={bannerUrl} alt={userId} draggable="false" />
|
||||||
|
) : (
|
||||||
|
avatarUrl && (
|
||||||
<img className={css.UserHeroCover} src={avatarUrl} alt={userId} draggable="false" />
|
<img className={css.UserHeroCover} src={avatarUrl} alt={userId} draggable="false" />
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={css.UserHeroAvatarContainer}>
|
<div className={css.UserHeroAvatarContainer}>
|
||||||
@@ -65,6 +79,23 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</AvatarPresence>
|
</AvatarPresence>
|
||||||
|
{presence?.status && (
|
||||||
|
<Box
|
||||||
|
className={css.UserStatusBubble}
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
|
padding: `${toRem(6)} ${toRem(10)}`,
|
||||||
|
borderRadius: toRem(16),
|
||||||
|
maxWidth: toRem(250),
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T300" className={BreakWord}>
|
||||||
|
{presence.status}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{viewAvatar && (
|
{viewAvatar && (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
@@ -76,7 +107,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
|
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={viewAvatar}
|
src={viewAvatar}
|
||||||
alt={userId}
|
alt={userId}
|
||||||
@@ -95,12 +126,14 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
|||||||
type UserHeroNameProps = {
|
type UserHeroNameProps = {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
avatarMxc?: string;
|
||||||
};
|
};
|
||||||
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNameProps) {
|
||||||
const username = getMxIdLocalPart(userId);
|
const username = getMxIdLocalPart(userId);
|
||||||
|
const userColor = useOtherUserColor(userId, avatarMxc);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column" gap="0">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
<Box alignItems="Baseline" gap="200" wrap="Wrap">
|
<Box alignItems="Baseline" gap="200" wrap="Wrap">
|
||||||
<Text
|
<Text
|
||||||
size="H4"
|
size="H4"
|
||||||
@@ -111,8 +144,13 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box alignItems="Center" gap="100" wrap="Wrap">
|
<Box alignItems="Center" gap="100" wrap="Wrap">
|
||||||
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
|
<Text
|
||||||
@{username}
|
size="T200"
|
||||||
|
className={BreakWord}
|
||||||
|
title={userId}
|
||||||
|
style={{ color: userColor || colorMXID(userId) }}
|
||||||
|
>
|
||||||
|
{userId}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -72,12 +72,17 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
<UserHero
|
<UserHero
|
||||||
userId={userId}
|
userId={userId}
|
||||||
avatarUrl={avatarUrl}
|
avatarUrl={avatarUrl}
|
||||||
|
avatarMxc={avatarMxc}
|
||||||
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
||||||
/>
|
/>
|
||||||
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
|
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Box gap="400" alignItems="Start">
|
<Box gap="400" alignItems="Start">
|
||||||
<UserHeroName displayName={displayName} userId={userId} />
|
<UserHeroName
|
||||||
|
displayName={displayName}
|
||||||
|
userId={userId}
|
||||||
|
avatarMxc={avatarMxc}
|
||||||
|
/>
|
||||||
{userId !== myUserId && (
|
{userId !== myUserId && (
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export const UserHeroCover = style({
|
|||||||
transform: 'scale(2)',
|
transform: 'scale(2)',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UserHeroBanner = style({
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
});
|
||||||
|
|
||||||
export const UserHeroAvatarContainer = style({
|
export const UserHeroAvatarContainer = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: toRem(29),
|
height: toRem(29),
|
||||||
@@ -37,6 +43,14 @@ export const UserAvatarContainer = style({
|
|||||||
transform: 'translateY(-50%)',
|
transform: 'translateY(-50%)',
|
||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UserStatusBubble = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: `calc(${config.space.S400} + ${toRem(64)} + ${config.space.S200})`,
|
||||||
|
bottom: toRem(-8),
|
||||||
|
zIndex: 2,
|
||||||
|
});
|
||||||
|
|
||||||
export const UserHeroAvatar = style({
|
export const UserHeroAvatar = style({
|
||||||
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||||
selectors: {
|
selectors: {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
295
src/app/hooks/useUserBanner.ts
Normal file
295
src/app/hooks/useUserBanner.ts
Normal file
@@ -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<ArrayBuffer | null> {
|
||||||
|
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<void>,
|
||||||
|
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;
|
||||||
|
|
||||||
|
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<string, { banner: string | undefined; timestamp: number }>();
|
||||||
|
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<string | undefined>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
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 { useMatrixClient } from './useMatrixClient';
|
||||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
import { useMediaAuthentication } from './useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../utils/matrix';
|
import { mxcUrlToHttp } from '../utils/matrix';
|
||||||
@@ -25,7 +25,11 @@ async function fetchAvatarData(
|
|||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
try {
|
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;
|
if (!response.ok) return null;
|
||||||
return await response.arrayBuffer();
|
return await response.arrayBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -75,7 +79,8 @@ export function useUserColor(): [
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractedColor = await fetchAndExtractColor(httpUrl);
|
const accessToken = mx.getAccessToken();
|
||||||
|
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
||||||
setColor(extractedColor);
|
setColor(extractedColor);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load user color from avatar:', e);
|
console.error('Failed to load user color from avatar:', e);
|
||||||
@@ -85,6 +90,21 @@ export function useUserColor(): [
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadColor();
|
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]);
|
}, [mx, useAuthentication]);
|
||||||
|
|
||||||
const updateColor = useCallback(async (newColor: string | undefined) => {
|
const updateColor = useCallback(async (newColor: string | undefined) => {
|
||||||
@@ -157,6 +177,12 @@ export function useUserColor(): [
|
|||||||
// Update profile with new avatar
|
// Update profile with new avatar
|
||||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
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);
|
setColor(newColor);
|
||||||
console.log('User color updated in avatar:', newColor);
|
console.log('User color updated in avatar:', newColor);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -204,7 +230,8 @@ export function useOtherUserColor(userId: string, avatarMxc: string | undefined)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractedColor = await fetchAndExtractColor(httpUrl);
|
const accessToken = mx.getAccessToken();
|
||||||
|
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { MouseEventHandler, useState } from 'react';
|
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 FocusTrap from 'focus-trap-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { UserAvatar } from '../../../components/user-avatar';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
@@ -13,21 +13,23 @@ import { useUserProfile } from '../../../hooks/useUserProfile';
|
|||||||
import { Modal500 } from '../../../components/Modal500';
|
import { Modal500 } from '../../../components/Modal500';
|
||||||
import { AccountSwitcher } from '../../../components/account-switcher';
|
import { AccountSwitcher } from '../../../components/account-switcher';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { getLoginPath, withSearchParam } from '../../pathUtils';
|
import { getLoginPath } from '../../pathUtils';
|
||||||
import { logoutClient } from '../../../../client/initMatrix';
|
import { logoutClient } from '../../../../client/initMatrix';
|
||||||
|
import { CustomStatusDialog } from '../../../components/custom-status';
|
||||||
|
|
||||||
export function SettingsTab() {
|
export function SettingsTab() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const userId = mx.getUserId()!;
|
const userId = mx.getUserId();
|
||||||
const profile = useUserProfile(userId);
|
const profile = useUserProfile(userId ?? '');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [settings, setSettings] = useState(false);
|
const [settings, setSettings] = useState(false);
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
|
const [customStatusOpen, setCustomStatusOpen] = useState(false);
|
||||||
|
|
||||||
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
const displayName = profile.displayName ?? getMxIdLocalPart(userId ?? '') ?? userId ?? '';
|
||||||
const avatarUrl = profile.avatarUrl
|
const avatarUrl = profile.avatarUrl && userId
|
||||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -42,22 +44,17 @@ export function SettingsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem active={settings}>
|
<SidebarItem active={settings}>
|
||||||
<SidebarItemTooltip tooltip="User Settings">
|
|
||||||
{(triggerRef) => (
|
|
||||||
<SidebarAvatar
|
<SidebarAvatar
|
||||||
as="button"
|
as="button"
|
||||||
ref={triggerRef}
|
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
userId={userId}
|
userId={userId ?? ''}
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
|
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
|
||||||
/>
|
/>
|
||||||
</SidebarAvatar>
|
</SidebarAvatar>
|
||||||
)}
|
|
||||||
</SidebarItemTooltip>
|
|
||||||
|
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={menuAnchor}
|
anchor={menuAnchor}
|
||||||
@@ -78,18 +75,14 @@ export function SettingsTab() {
|
|||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
<AccountSwitcher currentUserId={userId} />
|
<AccountSwitcher currentUserId={userId ?? ''} />
|
||||||
|
</Box>
|
||||||
|
<Line variant="Surface" size="300" />
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMenuAnchor(undefined);
|
setMenuAnchor(undefined);
|
||||||
const loginPath = getLoginPath() + '?add-account=true';
|
navigate(`${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);
|
|
||||||
}}
|
}}
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
@@ -116,11 +109,34 @@ export function SettingsTab() {
|
|||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Line variant="Surface" size="300" />
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setMenuAnchor(undefined);
|
||||||
|
setCustomStatusOpen(true);
|
||||||
|
}}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Icon size="100" src={Icons.Message} />
|
||||||
|
<Text as="span" size="T300" truncate>
|
||||||
|
Set Custom Status
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
</Box>
|
||||||
</Menu>
|
</Menu>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CustomStatusDialog
|
||||||
|
open={customStatusOpen}
|
||||||
|
onClose={() => setCustomStatusOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{settings && (
|
{settings && (
|
||||||
<Modal500 requestClose={closeSettings}>
|
<Modal500 requestClose={closeSettings}>
|
||||||
<Settings requestClose={closeSettings} />
|
<Settings requestClose={closeSettings} />
|
||||||
|
|||||||
@@ -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
|
* Supports PNG, WebP, JPEG, and GIF formats
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint-disable no-plusplus */
|
/* eslint-disable no-plusplus */
|
||||||
/* eslint-disable no-console */
|
/* 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 { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './webpMetadata';
|
||||||
import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata';
|
import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata';
|
||||||
import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata';
|
import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata';
|
||||||
|
|
||||||
|
export type ImageMetadata = {
|
||||||
|
color?: string;
|
||||||
|
banner?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** PNG signature bytes */
|
/** PNG signature bytes */
|
||||||
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
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)
|
* Embed paarrot color into image data (PNG, WebP, JPEG, or GIF)
|
||||||
* @param imageData - Raw image data as ArrayBuffer or Uint8Array
|
* @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)
|
* Remove paarrot color from image data (PNG, WebP, JPEG, or GIF)
|
||||||
* @param imageData - Raw image data as ArrayBuffer or Uint8Array
|
* @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 url - HTTP URL to fetch the image from
|
||||||
|
* @param accessToken - Optional access token for authenticated media
|
||||||
* @returns The color string if found, or undefined
|
* @returns The color string if found, or undefined
|
||||||
*/
|
*/
|
||||||
export async function fetchAndExtractColor(url: string): Promise<string | undefined> {
|
export async function fetchAndExtractColor(url: string, accessToken?: string | null): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
|
});
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
const data = await response.arrayBuffer();
|
const data = await response.arrayBuffer();
|
||||||
@@ -213,3 +301,24 @@ export async function fetchAndExtractColor(url: string): Promise<string | undefi
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and extract all metadata from an image URL
|
||||||
|
* @param url - HTTP URL to fetch the image from
|
||||||
|
* @param accessToken - Optional access token for authenticated media
|
||||||
|
* @returns Object with color and banner if found
|
||||||
|
*/
|
||||||
|
export async function fetchAndExtractMetadata(url: string, accessToken?: string | null): Promise<ImageMetadata> {
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 */
|
/** Key used to store user color in PNG metadata */
|
||||||
export const PAARROT_COLOR_KEY = 'paarrot:color';
|
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;
|
let crc32Table: Uint32Array | null = null;
|
||||||
function getCRC32Table(): Uint32Array {
|
function getCRC32Table(): Uint32Array {
|
||||||
if (crc32Table) return crc32Table;
|
if (crc32Table) return crc32Table;
|
||||||
@@ -162,6 +165,69 @@ export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string
|
|||||||
return undefined;
|
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
|
* Embed paarrot color into PNG image data
|
||||||
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
|
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
|
||||||
@@ -221,6 +287,103 @@ export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: stri
|
|||||||
return newData;
|
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
|
* Remove paarrot color from PNG image data
|
||||||
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
|
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
|
||||||
|
|||||||
Reference in New Issue
Block a user