Add MSC4522 username colors, release notes dialog, and profile layout fixes.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Replace legacy profile style metadata with MSC4133 profile fields, add in-app update notes, tighten account profile name spacing, and wire desktop media save helpers through the client.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { getAppVersion } from '../../../utils/appVersion';
|
||||
import { useOpenReleaseNotesDialog } from '../../../components/updates-dialog/UpdatesDialogHost';
|
||||
import { getCurrentUpdatePreview } from '../../../data/updateNotes';
|
||||
import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
@@ -15,6 +17,7 @@ type AboutProps = {
|
||||
};
|
||||
export function About({ requestClose }: AboutProps) {
|
||||
const mx = useMatrixClient();
|
||||
const openReleaseNotes = useOpenReleaseNotesDialog();
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
|
||||
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
|
||||
@@ -93,10 +96,13 @@ export function About({ requestClose }: AboutProps) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getVersion().then(setVersion).catch(() => setVersion('unknown'));
|
||||
getAppVersion().then(setVersion).catch(() => setVersion('unknown'));
|
||||
refreshProtocolStatus();
|
||||
getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null));
|
||||
}, [refreshProtocolStatus]);
|
||||
|
||||
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(null);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
@@ -151,6 +157,36 @@ export function About({ requestClose }: AboutProps) {
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">What's new</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
>
|
||||
<SettingTile
|
||||
title={updatePreview?.title ?? 'Release notes'}
|
||||
description={
|
||||
updatePreview
|
||||
? `Paarrot ${version} — ${updatePreview.description}`
|
||||
: 'View highlights from the latest Paarrot update.'
|
||||
}
|
||||
after={
|
||||
<Button
|
||||
onClick={openReleaseNotes}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
>
|
||||
<Text size="B300">View updates</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Options</Text>
|
||||
<SequenceCard
|
||||
|
||||
@@ -11,7 +11,7 @@ import React, {
|
||||
import classNames from 'classnames';
|
||||
import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
@@ -19,7 +19,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl';
|
||||
import { nameInitials, getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../../utils/common';
|
||||
import { getTextShadowColor } from '../../../utils/common';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||
@@ -29,30 +29,22 @@ import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
||||
import { AngleSelector } from '../../../components/AngleSelector';
|
||||
import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor';
|
||||
import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner';
|
||||
import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
|
||||
import { useUserColorPreference } from '../../../hooks/useUserColor';
|
||||
import { useUserBanner } from '../../../hooks/useUserBanner';
|
||||
import { useUserPresence } from '../../../hooks/useUserPresence';
|
||||
import { useTheme, ThemeKind } from '../../../hooks/useTheme';
|
||||
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
||||
import {
|
||||
embedMetadataInImage,
|
||||
detectImageFormat,
|
||||
getMimeType,
|
||||
getExtension,
|
||||
ImageMetadata,
|
||||
needsPngForMetadata,
|
||||
convertImageDataToPng,
|
||||
uint8ArrayToBlob,
|
||||
} from '../../../utils/imageMetadata';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import {
|
||||
ColorPreference,
|
||||
hasColorPreference,
|
||||
resolveColorForTheme,
|
||||
} from '../../../utils/profileFields';
|
||||
|
||||
/**
|
||||
* Banner upload component for user's profile banner
|
||||
* Stored in avatar image metadata, visible to other Paarrot users
|
||||
* Banner upload component for user's profile banner (MSC4427 via MSC4133).
|
||||
*/
|
||||
function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTMLDivElement>; nameRef: React.RefObject<HTMLDivElement> }) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -98,26 +90,25 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
: undefined;
|
||||
const authenticatedCoverUrl = useAuthenticatedMediaUrl(avatarCoverUrl, useAuthentication);
|
||||
|
||||
const [userColor, setUserColor] = useUserColor();
|
||||
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
|
||||
const theme = useTheme();
|
||||
const [colorPreference, setColorPreference, colorLoading] = useUserColorPreference();
|
||||
const [localOnDark, setLocalOnDark] = useState('#ffd9f5');
|
||||
const [localOnLight, setLocalOnLight] = useState('#440000');
|
||||
const [savingColor, setSavingColor] = useState(false);
|
||||
const [colorError, setColorError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (userColor) {
|
||||
setLocalColor(userColor);
|
||||
}
|
||||
}, [userColor]);
|
||||
if (colorPreference?.on_dark) setLocalOnDark(colorPreference.on_dark);
|
||||
if (colorPreference?.on_light) setLocalOnLight(colorPreference.on_light);
|
||||
}, [colorPreference]);
|
||||
|
||||
const handleColorSave = async () => {
|
||||
setSavingColor(true);
|
||||
setColorError(undefined);
|
||||
try {
|
||||
await setUserColor(localColor);
|
||||
// Sync user object to propagate changes throughout app
|
||||
await syncUserAvatar();
|
||||
await setColorPreference({ on_dark: localOnDark, on_light: localOnLight });
|
||||
} catch (e) {
|
||||
setColorError('Failed to save color');
|
||||
setColorError('Failed to save colors. Your server may not support profile fields (MSC4133).');
|
||||
}
|
||||
setSavingColor(false);
|
||||
};
|
||||
@@ -126,141 +117,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
setSavingColor(true);
|
||||
setColorError(undefined);
|
||||
try {
|
||||
await setUserColor(undefined);
|
||||
await setColorPreference(undefined);
|
||||
setLocalOnDark('#ffd9f5');
|
||||
setLocalOnLight('#440000');
|
||||
} catch (e) {
|
||||
setColorError('Failed to remove color');
|
||||
setColorError('Failed to remove colors');
|
||||
}
|
||||
setSavingColor(false);
|
||||
};
|
||||
|
||||
// Profile style settings (border color and gradient)
|
||||
const [profileStyle, setProfileStyle, styleLoading] = useUserProfileStyle();
|
||||
// Initialize with transparent alpha so saved values show in preview until user edits
|
||||
const [localBorderColor, setLocalBorderColor] = useState<RgbaColor>({ r: 59, g: 130, b: 246, a: 0 });
|
||||
const [localGradientStart, setLocalGradientStart] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
|
||||
const [localGradientStop, setLocalGradientStop] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
|
||||
const [localGradientDirection, setLocalGradientDirection] = useState(180); // degrees (180 = top to bottom)
|
||||
const [savingStyle, setSavingStyle] = useState(false);
|
||||
const [styleError, setStyleError] = useState<string>();
|
||||
// Track if user has started editing (to show local values in preview)
|
||||
const [editingBorder, setEditingBorder] = useState(false);
|
||||
const [editingGradient, setEditingGradient] = useState(false);
|
||||
|
||||
// Helper to convert RGBA to hex with alpha (#RRGGBBAA)
|
||||
const rgbaToHex = (rgba: RgbaColor): string => {
|
||||
const r = rgba.r.toString(16).padStart(2, '0');
|
||||
const g = rgba.g.toString(16).padStart(2, '0');
|
||||
const b = rgba.b.toString(16).padStart(2, '0');
|
||||
const a = Math.round(rgba.a * 255).toString(16).padStart(2, '0');
|
||||
return `#${r}${g}${b}${a}`;
|
||||
};
|
||||
|
||||
// Helper to convert hex with alpha to RGBA
|
||||
const hexToRgba = (hex: string): RgbaColor => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
|
||||
if (result) {
|
||||
return {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
a: result[4] ? parseInt(result[4], 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
return { r: 59, g: 130, b: 246, a: 1 };
|
||||
};
|
||||
|
||||
// Helper to parse degrees from direction string (e.g., "180deg" -> 180)
|
||||
const parseDirectionDegrees = (direction: string): number => {
|
||||
const degMatch = direction.match(/^(\d+)deg$/i);
|
||||
if (degMatch) return parseInt(degMatch[1], 10);
|
||||
// Fallback for legacy "to X" format
|
||||
const keywordMap: Record<string, number> = {
|
||||
'to top': 0,
|
||||
'to top right': 45,
|
||||
'to right': 90,
|
||||
'to bottom right': 135,
|
||||
'to bottom': 180,
|
||||
'to bottom left': 225,
|
||||
'to left': 270,
|
||||
'to top left': 315,
|
||||
};
|
||||
return keywordMap[direction.toLowerCase()] ?? 180;
|
||||
};
|
||||
|
||||
// Sync local style state with loaded profile style
|
||||
useEffect(() => {
|
||||
if (profileStyle.avatarBorderColor) {
|
||||
setLocalBorderColor(hexToRgba(profileStyle.avatarBorderColor));
|
||||
}
|
||||
if (profileStyle.gradient) {
|
||||
setLocalGradientStart(hexToRgba(profileStyle.gradient.startColor));
|
||||
setLocalGradientStop(hexToRgba(profileStyle.gradient.stopColor));
|
||||
setLocalGradientDirection(parseDirectionDegrees(profileStyle.gradient.direction));
|
||||
}
|
||||
}, [profileStyle]);
|
||||
|
||||
const handleBorderColorSave = async () => {
|
||||
setSavingStyle(true);
|
||||
setStyleError(undefined);
|
||||
try {
|
||||
await setProfileStyle({ avatarBorderColor: rgbaToHex(localBorderColor) });
|
||||
await syncUserAvatar();
|
||||
setEditingBorder(false);
|
||||
} catch (e) {
|
||||
setStyleError('Failed to save border color');
|
||||
}
|
||||
setSavingStyle(false);
|
||||
};
|
||||
|
||||
const handleBorderColorRemove = async () => {
|
||||
setSavingStyle(true);
|
||||
setStyleError(undefined);
|
||||
try {
|
||||
await setProfileStyle({ avatarBorderColor: undefined });
|
||||
await syncUserAvatar();
|
||||
setEditingBorder(false);
|
||||
setLocalBorderColor({ r: 59, g: 130, b: 246, a: 0 });
|
||||
} catch (e) {
|
||||
setStyleError('Failed to remove border color');
|
||||
}
|
||||
setSavingStyle(false);
|
||||
};
|
||||
|
||||
const handleGradientSave = async () => {
|
||||
setSavingStyle(true);
|
||||
setStyleError(undefined);
|
||||
try {
|
||||
await setProfileStyle({
|
||||
gradient: {
|
||||
direction: `${localGradientDirection}deg`,
|
||||
startColor: rgbaToHex(localGradientStart),
|
||||
stopColor: rgbaToHex(localGradientStop),
|
||||
},
|
||||
});
|
||||
await syncUserAvatar();
|
||||
setEditingGradient(false);
|
||||
} catch (e) {
|
||||
setStyleError('Failed to save gradient');
|
||||
}
|
||||
setSavingStyle(false);
|
||||
};
|
||||
|
||||
const handleGradientRemove = async () => {
|
||||
setSavingStyle(true);
|
||||
setStyleError(undefined);
|
||||
try {
|
||||
await setProfileStyle({ gradient: undefined });
|
||||
await syncUserAvatar();
|
||||
setEditingGradient(false);
|
||||
setLocalGradientStart({ r: 0, g: 0, b: 0, a: 0 });
|
||||
setLocalGradientStop({ r: 0, g: 0, b: 0, a: 0 });
|
||||
setLocalGradientDirection(180);
|
||||
} catch (e) {
|
||||
setStyleError('Failed to remove gradient');
|
||||
}
|
||||
setSavingStyle(false);
|
||||
};
|
||||
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editedName, setEditedName] = useState(profile.displayName || '');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
@@ -475,82 +340,14 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
// Re-embed existing banner/color/style into the new avatar when present
|
||||
const metadata: ImageMetadata = {
|
||||
banner: userBanner,
|
||||
color: userColor,
|
||||
avatarBorderColor: profileStyle.avatarBorderColor,
|
||||
gradient: profileStyle.gradient,
|
||||
};
|
||||
const hasMetadata = Boolean(
|
||||
metadata.color ||
|
||||
metadata.banner ||
|
||||
metadata.avatarBorderColor ||
|
||||
metadata.gradient
|
||||
);
|
||||
|
||||
// Nothing to re-embed — use the already-uploaded MXC directly
|
||||
if (!hasMetadata) {
|
||||
await mx.setAvatarUrl(upload.mxc);
|
||||
const userId = mx.getUserId();
|
||||
if (userId) {
|
||||
const user = mx.getUser(userId);
|
||||
if (user && user.avatarUrl !== upload.mxc) {
|
||||
user.setAvatarUrl(upload.mxc);
|
||||
}
|
||||
}
|
||||
setAvatarFile(undefined);
|
||||
await syncUserAvatar();
|
||||
return;
|
||||
}
|
||||
|
||||
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
|
||||
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
|
||||
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
let response = await fetch(httpUrl, {
|
||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
|
||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
||||
console.warn('[Profile] Auth failed (401), attempting unauthenticated fallback for avatar fetch');
|
||||
response = await fetch(httpUrl);
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
|
||||
let avatarData: ArrayBuffer | Uint8Array = await response.arrayBuffer();
|
||||
|
||||
let format = detectImageFormat(avatarData);
|
||||
if (format === 'unknown') throw new Error('Unsupported avatar image format');
|
||||
|
||||
// Banner / border / gradient only live in PNG tEXt — convert JPEG/WebP/GIF first
|
||||
if (format !== 'png' && needsPngForMetadata(metadata)) {
|
||||
const pngData = await convertImageDataToPng(avatarData);
|
||||
if (!pngData) throw new Error('Failed to convert avatar to PNG for metadata');
|
||||
avatarData = pngData;
|
||||
format = 'png';
|
||||
}
|
||||
|
||||
const modifiedData = embedMetadataInImage(avatarData, metadata);
|
||||
if (!modifiedData) throw new Error('Failed to embed metadata in avatar');
|
||||
|
||||
const mimeType = getMimeType(format);
|
||||
const extension = getExtension(format);
|
||||
const blob = uint8ArrayToBlob(modifiedData, mimeType);
|
||||
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
|
||||
|
||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
||||
|
||||
await mx.setAvatarUrl(upload.mxc);
|
||||
const userId = mx.getUserId();
|
||||
if (userId) {
|
||||
const user = mx.getUser(userId);
|
||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
||||
user.setAvatarUrl(uploadResponse.content_uri);
|
||||
if (user && user.avatarUrl !== upload.mxc) {
|
||||
user.setAvatarUrl(upload.mxc);
|
||||
}
|
||||
}
|
||||
|
||||
setAvatarFile(undefined);
|
||||
await syncUserAvatar();
|
||||
} catch (e: any) {
|
||||
@@ -558,7 +355,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
}
|
||||
setSaving(false);
|
||||
},
|
||||
[mx, useAuthentication, userBanner, userColor, profileStyle, syncUserAvatar]
|
||||
[mx, syncUserAvatar]
|
||||
);
|
||||
|
||||
const handleRemoveBanner = async () => {
|
||||
@@ -574,24 +371,11 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
// Build gradient CSS for preview (shows local editing values when user is editing,
|
||||
// otherwise falls back to saved profile style)
|
||||
const previewGradient = editingGradient
|
||||
? `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`
|
||||
: profileStyle.gradient
|
||||
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
|
||||
: undefined;
|
||||
|
||||
// Build border color for preview (shows local editing value when user is editing,
|
||||
// otherwise falls back to saved profile style)
|
||||
const previewBorderColor = editingBorder
|
||||
? rgbaToHex(localBorderColor)
|
||||
: profileStyle.avatarBorderColor;
|
||||
|
||||
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
|
||||
const previewProfileColor = userColor || getColorMXIDValue(userId);
|
||||
|
||||
const previewPreference: ColorPreference = { on_dark: localOnDark, on_light: localOnLight };
|
||||
const previewProfileColor =
|
||||
resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark);
|
||||
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
||||
const hasSavedColors = hasColorPreference(colorPreference);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
@@ -627,7 +411,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
backgroundColor: bannerBlobUrl ? 'transparent' : (userColor || colorMXID(userId)),
|
||||
backgroundColor: bannerBlobUrl ? 'transparent' : colorMXID(userId),
|
||||
filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -740,7 +524,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
as="button"
|
||||
onClick={handleAvatarClick}
|
||||
style={{
|
||||
backgroundColor: previewBorderColor || color.Surface.Container,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
@@ -748,7 +532,6 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
}}
|
||||
>
|
||||
<AvatarPresence
|
||||
badgeBackgroundColor={previewBorderColor}
|
||||
badge={
|
||||
presence && (
|
||||
<PresenceBadge presence={presence.presence} status={presence.status} />
|
||||
@@ -760,9 +543,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
style={{
|
||||
width: toRem(72),
|
||||
height: toRem(72),
|
||||
outline: previewBorderColor
|
||||
? `${toRem(4)} solid ${previewBorderColor}`
|
||||
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -832,73 +613,20 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Profile Info Section - matches UserRoomProfile gradient section */}
|
||||
{/* Profile Info Section */}
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="400"
|
||||
gap="200"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
padding: config.space.S400,
|
||||
paddingTop: `calc(${config.space.S400} + ${toRem(36)})`,
|
||||
marginTop: toRem(-36),
|
||||
background: previewGradient,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Display Name with Color Picker */}
|
||||
<Box alignItems="Center" gap="200" justifyContent="Center">
|
||||
<HexColorPickerPopOut
|
||||
picker={
|
||||
<Box direction="Column" gap="200">
|
||||
<HexColorPicker color={localColor} onChange={setLocalColor} />
|
||||
<Box gap="100" alignItems="Center">
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(100) }}
|
||||
value={localColor}
|
||||
onChange={(e) => {
|
||||
setLocalColor(e.target.value);
|
||||
setColorError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleColorSave}
|
||||
disabled={savingColor}
|
||||
>
|
||||
<Text size="B300">{savingColor ? 'Saving...' : 'Save'}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{colorError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{colorError}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
onRemove={userColor ? handleColorRemove : undefined}
|
||||
>
|
||||
{(onOpen) => (
|
||||
<Box
|
||||
as="button"
|
||||
onClick={onOpen}
|
||||
disabled={savingColor}
|
||||
style={{
|
||||
width: toRem(24),
|
||||
height: toRem(24),
|
||||
borderRadius: toRem(6),
|
||||
backgroundColor: userColor ?? localColor,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</HexColorPickerPopOut>
|
||||
{/* Display Name */}
|
||||
<Box style={{ width: '100%' }}>
|
||||
{isEditingName ? (
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -910,6 +638,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
size="400"
|
||||
disabled={savingName}
|
||||
style={{
|
||||
width: '100%',
|
||||
fontSize: 'var(--token.font-size.H400)',
|
||||
fontWeight: 'var(--token.font-weight.H400)',
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
@@ -926,19 +655,28 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
onMouseEnter={() => setHoveredArea('name')}
|
||||
onMouseLeave={() => setHoveredArea(null)}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
margin: `${toRem(-4)} ${toRem(-8)}`,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="H4"
|
||||
className={classNames(BreakWord, LineClamp3)}
|
||||
title={profile.displayName || getMxIdLocalPart(userId)}
|
||||
style={{ color: previewProfileColor, textShadow: previewTextShadow }}
|
||||
style={{
|
||||
color: previewProfileColor,
|
||||
textShadow: previewTextShadow,
|
||||
textAlign: 'center',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{profile.displayName || getMxIdLocalPart(userId) || userId}
|
||||
</Text>
|
||||
@@ -946,8 +684,9 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: toRem(-32),
|
||||
top: '50%',
|
||||
right: 0,
|
||||
transform: 'translateY(-50%)',
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderRadius: toRem(20),
|
||||
padding: toRem(6),
|
||||
@@ -980,8 +719,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
onMouseEnter={() => setHoveredArea('status')}
|
||||
onMouseLeave={() => setHoveredArea(null)}
|
||||
style={{
|
||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${toRem(6)} ${toRem(10)}`,
|
||||
borderRadius: toRem(16),
|
||||
maxWidth: toRem(250),
|
||||
@@ -1032,14 +771,13 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
className={BreakWord}
|
||||
style={{
|
||||
fontStyle: presence?.status ? 'normal' : 'italic',
|
||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
||||
}}
|
||||
>
|
||||
{presence?.status || 'Click to set a custom status...'}</Text>
|
||||
</Box>
|
||||
{presence?.status && hoveredArea === 'status' && (
|
||||
<>
|
||||
<Icon size="50" src={Icons.Pencil} style={{ color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined }} />
|
||||
<Icon size="50" src={Icons.Pencil} />
|
||||
<Box
|
||||
as="button"
|
||||
onClick={(e) => {
|
||||
@@ -1070,63 +808,42 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
borderRadius: toRem(16),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="B300"
|
||||
style={{
|
||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
||||
}}
|
||||
>
|
||||
ruv.wtf
|
||||
</Text>
|
||||
<Text size="B300">ruv.wtf</Text>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
borderRadius: toRem(16),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="B300"
|
||||
style={{
|
||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Text>
|
||||
<Text size="B300">Share</Text>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
borderRadius: toRem(16),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="B300"
|
||||
style={{
|
||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</Text>
|
||||
<Text size="B300">Admin</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Profile Style Settings */}
|
||||
{/* MSC4522 username colors */}
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="200"
|
||||
gap="300"
|
||||
style={{
|
||||
padding: config.space.S300,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
@@ -1134,111 +851,91 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Text size="H6">Profile Style</Text>
|
||||
|
||||
{/* Avatar Border Color */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300">Avatar Border Color</Text>
|
||||
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||
<RgbaColorPicker color={localBorderColor} onChange={(c) => { setLocalBorderColor(c); setEditingBorder(true); }} />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200" style={{ opacity: 0.7 }}>
|
||||
Preview: {rgbaToHex(localBorderColor)}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
borderRadius: '50%',
|
||||
border: `${toRem(4)} solid ${rgbaToHex(localBorderColor)}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
}}
|
||||
/>
|
||||
<Box gap="100">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleBorderColorSave}
|
||||
disabled={savingStyle}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
{profileStyle.avatarBorderColor && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleBorderColorRemove}
|
||||
disabled={savingStyle}
|
||||
>
|
||||
<Text size="B300">Remove</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="H6">Username colors</Text>
|
||||
<Text size="T200" style={{ opacity: 0.8 }}>
|
||||
Set how your name appears on dark and light themes (MSC4522). Other clients that support this
|
||||
spec will see your chosen colors.
|
||||
</Text>
|
||||
|
||||
<Box gap="400" wrap="Wrap">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="T300">On dark themes</Text>
|
||||
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
|
||||
<HexColorPicker color={localOnDark} onChange={(c) => { setLocalOnDark(c); setColorError(undefined); }} />
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(120) }}
|
||||
value={localOnDark}
|
||||
onChange={(e) => {
|
||||
setLocalOnDark(e.target.value);
|
||||
setColorError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
borderRadius: toRem(8),
|
||||
backgroundColor: localOnDark,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="T300">On light themes</Text>
|
||||
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
|
||||
<HexColorPicker color={localOnLight} onChange={(c) => { setLocalOnLight(c); setColorError(undefined); }} />
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(120) }}
|
||||
value={localOnLight}
|
||||
onChange={(e) => {
|
||||
setLocalOnLight(e.target.value);
|
||||
setColorError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
borderRadius: toRem(8),
|
||||
backgroundColor: localOnLight,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Profile Gradient */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300">Profile Card Gradient</Text>
|
||||
<Box gap="200" alignItems="Start" wrap="Wrap">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200">Start Color</Text>
|
||||
<RgbaColorPicker color={localGradientStart} onChange={(c) => { setLocalGradientStart(c); setEditingGradient(true); }} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200">End Color</Text>
|
||||
<RgbaColorPicker color={localGradientStop} onChange={(c) => { setLocalGradientStop(c); setEditingGradient(true); }} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200">Direction</Text>
|
||||
<AngleSelector
|
||||
value={localGradientDirection}
|
||||
onChange={(deg) => { setLocalGradientDirection(deg); setEditingGradient(true); }}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(100),
|
||||
height: toRem(60),
|
||||
borderRadius: toRem(8),
|
||||
background: `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
<Box gap="100">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleGradientSave}
|
||||
disabled={savingStyle}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
{profileStyle.gradient && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleGradientRemove}
|
||||
disabled={savingStyle}
|
||||
>
|
||||
<Text size="B300">Remove</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleColorSave}
|
||||
disabled={savingColor || colorLoading}
|
||||
>
|
||||
<Text size="B300">{savingColor ? 'Saving...' : 'Save colors'}</Text>
|
||||
</Button>
|
||||
{hasSavedColors && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleColorRemove}
|
||||
disabled={savingColor}
|
||||
>
|
||||
<Text size="B300">Remove colors</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{styleError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>{styleError}</Text>
|
||||
{colorError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>{colorError}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user