fix(desktop): refine activity profile editing

The token usage profile header had too much top spacing, the edit action competed with the content from the top-right corner, and the profile subtitle was fixed to the default project link. This keeps the profile controls close to the identity block, moves the modal scrim to the document body so it covers the whole desktop shell, and persists a user-editable second line with URL auto-linking.

Constraint: Preserve existing profile preferences and backfill older records that do not have a subtitle field.
Rejected: Keep the top-right edit button | it kept the action visually detached from the profile content.
Rejected: Store the second line only in desktop local state | the preference already has a server-backed profile shape.
Confidence: high
Scope-risk: narrow
Directive: Keep profile preference schema changes backward-compatible with older desktop-ui preference files.
Tested: cd desktop && bun run test --run src/pages/ActivitySettings.test.tsx src/api/desktopUiPreferences.test.ts
Tested: bun test src/server/__tests__/desktop-ui-preferences.test.ts
Tested: bun run check:persistence-upgrade
Tested: bun run check:server
Tested: bun run check:desktop
Tested: git diff --check
Not-tested: Full Tauri native shell smoke; renderer flow was smoke-tested in browser only.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-31 15:42:44 +08:00
parent ef818f8d8c
commit 587922ba75
8 changed files with 131 additions and 44 deletions

View File

@ -6,6 +6,7 @@ const preferences = {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
@ -43,7 +44,10 @@ describe('desktopUiPreferencesApi', () => {
}))
await expect(desktopUiPreferencesApi.getPreferences()).resolves.toEqual({ exists: true, preferences })
await expect(desktopUiPreferencesApi.updateProfilePreferences({ displayName: 'Local Captain' })).resolves.toEqual({
await expect(desktopUiPreferencesApi.updateProfilePreferences({
displayName: 'Local Captain',
subtitle: 'local.example',
})).resolves.toEqual({
ok: true,
preferences,
})
@ -54,7 +58,7 @@ describe('desktopUiPreferencesApi', () => {
}))
expect(fetchMock).toHaveBeenNthCalledWith(2, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile', expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ displayName: 'Local Captain' }),
body: JSON.stringify({ displayName: 'Local Captain', subtitle: 'local.example' }),
}))
expect(fetchMock).toHaveBeenNthCalledWith(3, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar', expect.objectContaining({
method: 'DELETE',

View File

@ -10,6 +10,7 @@ export type SidebarProjectPreferences = {
export type DesktopProfilePreferences = {
displayName: string
subtitle: string
avatarFile: string | null
avatarUpdatedAt: string | null
}
@ -37,7 +38,7 @@ export const desktopUiPreferencesApi = {
)
},
updateProfilePreferences(profile: Pick<DesktopProfilePreferences, 'displayName'>) {
updateProfilePreferences(profile: Pick<DesktopProfilePreferences, 'displayName' | 'subtitle'>) {
return api.put<{ ok: true; preferences: DesktopUiPreferences }>(
'/api/desktop-ui/preferences/profile',
profile,

View File

@ -185,7 +185,8 @@ export const en = {
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
'settings.activity.editProfile': 'Edit profile',
'settings.activity.displayName': 'Display name',
'settings.activity.displayNameHelper': 'Only the display name changes here; the second line stays as the project link.',
'settings.activity.subtitle': 'Second line',
'settings.activity.displayNameHelper': 'Edit the display name and second line; URLs in the second line open as links.',
'settings.activity.avatar': 'Avatar',
'settings.activity.avatarHelper': 'PNG, JPEG, or WebP up to 2 MB.',
'settings.activity.changeAvatar': 'Change avatar',

View File

@ -187,7 +187,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
'settings.activity.editProfile': '编辑个人资料',
'settings.activity.displayName': '显示名称',
'settings.activity.displayNameHelper': '这里只修改主名称,第二行保持为项目链接。',
'settings.activity.subtitle': '第二行',
'settings.activity.displayNameHelper': '修改主名称和第二行;第二行如果是网址会自动作为链接打开。',
'settings.activity.avatar': '头像',
'settings.activity.avatarHelper': '支持 PNG、JPEG 或 WebP最大 2 MB。',
'settings.activity.changeAvatar': '更换头像',

View File

@ -93,6 +93,7 @@ describe('ActivitySettings', () => {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
@ -111,6 +112,7 @@ describe('ActivitySettings', () => {
schemaVersion: 2,
profile: {
displayName: profile.displayName,
subtitle: profile.subtitle,
avatarFile: null,
avatarUpdatedAt: null,
},
@ -129,6 +131,7 @@ describe('ActivitySettings', () => {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: 'profile/avatar.png',
avatarUpdatedAt: '2026-05-09T12:00:00.000Z',
},
@ -147,6 +150,7 @@ describe('ActivitySettings', () => {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
@ -238,12 +242,17 @@ describe('ActivitySettings', () => {
fireEvent.click(screen.getByRole('button', { name: '编辑个人资料' }))
const input = screen.getByLabelText('显示名称')
fireEvent.change(input, { target: { value: '本地舰长' } })
fireEvent.change(screen.getByLabelText('第二行'), { target: { value: 'relakkes.dev' } })
fireEvent.click(screen.getByRole('button', { name: '保存' }))
await flushActivityLoad()
expect(updateProfilePreferencesMock).toHaveBeenCalledWith({ displayName: '本地舰长' })
expect(updateProfilePreferencesMock).toHaveBeenCalledWith({
displayName: '本地舰长',
subtitle: 'relakkes.dev',
})
expect(screen.getByText('本地舰长')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'relakkes.dev' })).toHaveAttribute('href', 'https://relakkes.dev')
})
it('handles avatar upload, fallback, removal, save failure, and cancel reset', async () => {
@ -253,6 +262,7 @@ describe('ActivitySettings', () => {
schemaVersion: 2,
profile: {
displayName: 'Local Captain',
subtitle: 'Local workspace',
avatarFile: 'profile/avatar.webp',
avatarUpdatedAt: '2026-05-09T12:00:00.000Z',
},
@ -266,7 +276,7 @@ describe('ActivitySettings', () => {
},
})
updateProfilePreferencesMock.mockRejectedValueOnce(new Error('display name rejected'))
const { container } = render(<ActivitySettings />)
render(<ActivitySettings />)
await flushActivityLoad()
@ -286,12 +296,14 @@ describe('ActivitySettings', () => {
expect(screen.getByText('display name rejected')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Unsaved Name' } })
fireEvent.change(screen.getByLabelText('Second line'), { target: { value: 'Unsaved subtitle' } })
const cancelButtons = screen.getAllByRole('button', { name: 'Cancel' })
fireEvent.click(cancelButtons[cancelButtons.length - 1]!)
fireEvent.click(screen.getByRole('button', { name: 'Edit profile' }))
expect(screen.getByLabelText('Display name')).toHaveValue('Local Captain')
expect(screen.getByLabelText('Second line')).toHaveValue('Local workspace')
const input = container.querySelector('input[type="file"]') as HTMLInputElement
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' })
fireEvent.change(input, { target: { files: [file] } })

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'
import { createPortal } from 'react-dom'
import { activityStatsApi, type ActivityStatsResponse, type DailyActivity } from '../api/activityStats'
import {
desktopUiPreferencesApi,
@ -52,10 +53,10 @@ const DATE_LOCALES: Record<Locale, string> = {
}
const DEFAULT_PROFILE: DesktopProfilePreferences = {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
}
const PROFILE_LINK_URL = 'https://github.com/NanmiCoder/cc-haha'
function localDateKey(date: Date) {
const year = date.getFullYear()
@ -125,6 +126,16 @@ function formatMessageCount(value: number, t: ReturnType<typeof useTranslation>)
return `${value} ${t('settings.activity.messages')}`
}
function withProfileDefaults(profile: Partial<DesktopProfilePreferences> | null | undefined): DesktopProfilePreferences {
return { ...DEFAULT_PROFILE, ...profile }
}
function getProfileSubtitleHref(subtitle: string) {
if (/^https?:\/\//i.test(subtitle)) return subtitle
if (/^[\w.-]+\.[a-z]{2,}(?:\/.*)?$/i.test(subtitle)) return `https://${subtitle}`
return null
}
function calculateHeatCellSize(width: number) {
const available = width - HEAT_LABEL_WIDTH - (WEEK_COUNT - 1) * HEAT_CELL_GAP
return Math.max(HEAT_CELL_MIN, Math.min(HEAT_CELL_MAX, Math.floor(available / WEEK_COUNT)))
@ -341,6 +352,7 @@ export function ActivitySettings() {
const [isEditingProfile, setIsEditingProfile] = useState(false)
const [isSavingProfile, setIsSavingProfile] = useState(false)
const [draftDisplayName, setDraftDisplayName] = useState(DEFAULT_PROFILE.displayName)
const [draftSubtitle, setDraftSubtitle] = useState(DEFAULT_PROFILE.subtitle)
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('daily')
const [hoveredDate, setHoveredDate] = useState<string | null>(null)
const [focusedDate, setFocusedDate] = useState<string | null>(null)
@ -377,9 +389,10 @@ export function ActivitySettings() {
desktopUiPreferencesApi.getPreferences()
.then((result) => {
if (cancelled) return
const nextProfile = result.preferences.profile ?? DEFAULT_PROFILE
const nextProfile = withProfileDefaults(result.preferences.profile)
setProfile(nextProfile)
setDraftDisplayName(nextProfile.displayName)
setDraftSubtitle(nextProfile.subtitle)
})
.catch((err) => {
if (cancelled) return
@ -480,6 +493,7 @@ export function ActivitySettings() {
const avatarClassName = profile.avatarFile
? 'h-full w-full object-cover'
: 'h-full w-full scale-[1.28] object-contain transition-transform'
const profileSubtitleHref = getProfileSubtitleHref(profile.subtitle)
const hasUsage = Boolean(stats && (stats.totalSessions > 0 || totalTokens > 0))
const modeOptions: Array<{ mode: HeatmapMode; label: string; help: string }> = [
{ mode: 'daily', label: t('settings.activity.mode.daily'), help: t('settings.activity.modeHelp.daily') },
@ -492,9 +506,14 @@ export function ActivitySettings() {
setProfileError(null)
setProfileStatus(null)
try {
const result = await desktopUiPreferencesApi.updateProfilePreferences({ displayName: draftDisplayName })
setProfile(result.preferences.profile)
setDraftDisplayName(result.preferences.profile.displayName)
const result = await desktopUiPreferencesApi.updateProfilePreferences({
displayName: draftDisplayName,
subtitle: draftSubtitle,
})
const nextProfile = withProfileDefaults(result.preferences.profile)
setProfile(nextProfile)
setDraftDisplayName(nextProfile.displayName)
setDraftSubtitle(nextProfile.subtitle)
setIsEditingProfile(false)
setProfileStatus(t('settings.activity.profileSaved'))
} catch (err) {
@ -513,8 +532,10 @@ export function ActivitySettings() {
setProfileStatus(null)
try {
const result = await desktopUiPreferencesApi.uploadProfileAvatar(file)
setProfile(result.preferences.profile)
setDraftDisplayName(result.preferences.profile.displayName)
const nextProfile = withProfileDefaults(result.preferences.profile)
setProfile(nextProfile)
setDraftDisplayName(nextProfile.displayName)
setDraftSubtitle(nextProfile.subtitle)
setProfileStatus(t('settings.activity.profileSaved'))
} catch (err) {
setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed'))
@ -529,7 +550,7 @@ export function ActivitySettings() {
setProfileStatus(null)
try {
const result = await desktopUiPreferencesApi.deleteProfileAvatar()
setProfile(result.preferences.profile)
setProfile(withProfileDefaults(result.preferences.profile))
setProfileStatus(t('settings.activity.profileSaved'))
} catch (err) {
setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed'))
@ -540,21 +561,8 @@ export function ActivitySettings() {
return (
<div className="mx-auto w-full max-w-[1160px] min-w-0 pb-12">
<section className="relative flex min-h-[310px] flex-col items-center justify-end pt-12 text-center">
<button
type="button"
className="absolute right-0 top-0 inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium text-[var(--color-text-secondary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:translate-y-[1px] disabled:opacity-50"
onClick={() => {
setIsEditingProfile(true)
setDraftDisplayName(profile.displayName)
}}
disabled={isProfileLoading}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">edit</span>
{t('settings.activity.editProfile')}
</button>
<div className="relative h-24 w-24 overflow-hidden rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_12px_34px_-24px_rgba(15,23,42,0.65)]">
<section className="relative flex min-h-[245px] flex-col items-center justify-start pt-6 text-center">
<div className="relative h-[88px] w-[88px] overflow-hidden rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_12px_34px_-24px_rgba(15,23,42,0.65)]">
<img
src={avatarSrc}
alt={`${profile.displayName} avatar`}
@ -565,20 +573,40 @@ export function ActivitySettings() {
}}
/>
</div>
<h1 className="mt-8 max-w-full truncate text-4xl font-semibold tracking-tight text-[var(--color-text-primary)] sm:text-[44px]">{profile.displayName}</h1>
<a
href={PROFILE_LINK_URL}
target="_blank"
rel="noreferrer"
className="mt-4 inline-flex max-w-full items-center justify-center gap-2 truncate text-lg text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
>
<span>{t('settings.activity.defaultHandle')}</span>
</a>
<div className="mt-6 flex max-w-full items-center justify-center gap-2">
<h1 className="max-w-[min(720px,calc(100%-2.25rem))] truncate text-4xl font-semibold tracking-tight text-[var(--color-text-primary)] sm:text-[44px]">{profile.displayName}</h1>
<button
type="button"
aria-label={t('settings.activity.editProfile')}
title={t('settings.activity.editProfile')}
className="mt-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-[background-color,color,transform] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)] active:translate-y-[1px] disabled:opacity-50"
onClick={() => {
setIsEditingProfile(true)
setDraftDisplayName(profile.displayName)
setDraftSubtitle(profile.subtitle)
}}
disabled={isProfileLoading}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">edit</span>
</button>
</div>
{profileSubtitleHref ? (
<a
href={profileSubtitleHref}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex max-w-full items-center justify-center gap-2 truncate text-lg text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
>
<span>{profile.subtitle}</span>
</a>
) : (
<div className="mt-3 max-w-full truncate text-lg text-[var(--color-text-tertiary)]">{profile.subtitle}</div>
)}
{profileStatus && <div className="mt-3 text-xs text-[var(--color-success)]">{profileStatus}</div>}
{profileError && !isEditingProfile && <div className="mt-3 text-xs text-[var(--color-error)]">{profileError}</div>}
</section>
<section className="mx-auto mt-10 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<section className="mx-auto mt-8 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
{isLoading ? (
<div className="grid gap-0 sm:grid-cols-2 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
@ -602,8 +630,8 @@ export function ActivitySettings() {
)}
</section>
{isEditingProfile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/25 px-4 py-8" role="dialog" aria-modal="true" aria-labelledby="activity-profile-dialog-title">
{isEditingProfile && createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-[var(--color-overlay-scrim)] px-4 py-8" role="dialog" aria-modal="true" aria-labelledby="activity-profile-dialog-title">
<div className="w-full max-w-[420px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-5 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
@ -616,6 +644,7 @@ export function ActivitySettings() {
onClick={() => {
setIsEditingProfile(false)
setDraftDisplayName(profile.displayName)
setDraftSubtitle(profile.subtitle)
setProfileError(null)
}}
aria-label={t('settings.activity.cancelEdit')}
@ -637,6 +666,18 @@ export function ActivitySettings() {
/>
</div>
<div className="grid gap-2">
<label htmlFor="activity-profile-subtitle" className="text-xs font-medium text-[var(--color-text-secondary)]">
{t('settings.activity.subtitle')}
</label>
<input
id="activity-profile-subtitle"
value={draftSubtitle}
onChange={(event) => setDraftSubtitle(event.target.value)}
className="h-10 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
/>
</div>
<div className="grid gap-2">
<div className="text-xs font-medium text-[var(--color-text-secondary)]">{t('settings.activity.avatar')}</div>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.activity.avatarHelper')}</p>
@ -678,6 +719,7 @@ export function ActivitySettings() {
onClick={() => {
setIsEditingProfile(false)
setDraftDisplayName(profile.displayName)
setDraftSubtitle(profile.subtitle)
setProfileError(null)
}}
>
@ -693,7 +735,8 @@ export function ActivitySettings() {
</button>
</div>
</div>
</div>
</div>,
document.body,
)}
<div className="mt-16">

View File

@ -59,6 +59,7 @@ describe('DesktopUiPreferencesService', () => {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
@ -101,6 +102,7 @@ describe('DesktopUiPreferencesService', () => {
futureField: { keep: true },
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
@ -117,6 +119,7 @@ describe('DesktopUiPreferencesService', () => {
futureField: { keep: true },
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
@ -149,6 +152,7 @@ describe('DesktopUiPreferencesService', () => {
const service = new DesktopUiPreferencesService()
const after = await service.updateProfilePreferences({
displayName: ' Claude Captain ',
subtitle: ' local.example/profile ',
avatarFile: '../escape.png',
avatarUpdatedAt: 42,
})
@ -157,6 +161,7 @@ describe('DesktopUiPreferencesService', () => {
schemaVersion: 2,
profile: {
displayName: 'Claude Captain',
subtitle: 'local.example/profile',
avatarFile: null,
avatarUpdatedAt: null,
},
@ -226,6 +231,7 @@ describe('desktop UI preferences API', () => {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
@ -250,6 +256,7 @@ describe('desktop UI preferences API', () => {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
subtitle: 'github.com/NanmiCoder/cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
@ -267,6 +274,7 @@ describe('desktop UI preferences API', () => {
test('persists profile preferences and avatar uploads through the API', async () => {
const profileReq = makeRequest('PUT', '/api/desktop-ui/preferences/profile', {
displayName: ' Local Operator ',
subtitle: ' operator.example ',
})
const profileRes = await handleDesktopUiApi(profileReq.req, profileReq.url, profileReq.segments)
const profileBody = await profileRes.json() as Record<string, unknown>
@ -277,6 +285,7 @@ describe('desktop UI preferences API', () => {
preferences: {
profile: {
displayName: 'Local Operator',
subtitle: 'operator.example',
avatarFile: null,
},
},
@ -297,6 +306,7 @@ describe('desktop UI preferences API', () => {
preferences: {
profile: {
displayName: 'Local Operator',
subtitle: 'operator.example',
avatarFile: 'profile/avatar.jpg',
},
},

View File

@ -9,7 +9,9 @@ import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.j
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 2
const MAX_PROJECT_PREFERENCE_ENTRIES = 2_000
const MAX_PROFILE_DISPLAY_NAME_LENGTH = 80
const MAX_PROFILE_SUBTITLE_LENGTH = 160
const MAX_PROFILE_AVATAR_BYTES = 2_000_000
const DEFAULT_PROFILE_SUBTITLE = 'github.com/NanmiCoder/cc-haha'
const AVATAR_CONTENT_TYPES = {
'image/png': { extension: 'png', mediaType: 'image/png' },
@ -27,6 +29,7 @@ export type SidebarProjectPreferences = {
export type DesktopProfilePreferences = {
displayName: string
subtitle: string
avatarFile: string | null
avatarUpdatedAt: string | null
}
@ -53,6 +56,7 @@ const DEFAULT_SIDEBAR_PROJECT_PREFERENCES: SidebarProjectPreferences = {
const DEFAULT_PROFILE_PREFERENCES: DesktopProfilePreferences = {
displayName: 'cc-haha',
subtitle: DEFAULT_PROFILE_SUBTITLE,
avatarFile: null,
avatarUpdatedAt: null,
}
@ -102,6 +106,13 @@ function normalizeProfileDisplayName(value: unknown): string {
return trimmed.slice(0, MAX_PROFILE_DISPLAY_NAME_LENGTH)
}
function normalizeProfileSubtitle(value: unknown): string {
if (typeof value !== 'string') return DEFAULT_PROFILE_PREFERENCES.subtitle
const trimmed = value.trim().replace(/\s+/g, ' ')
if (trimmed.length === 0) return DEFAULT_PROFILE_PREFERENCES.subtitle
return trimmed.slice(0, MAX_PROFILE_SUBTITLE_LENGTH)
}
function normalizeAvatarFile(value: unknown): string | null {
if (typeof value !== 'string') return null
if (!/^profile\/avatar\.(png|jpg|webp)$/.test(value)) return null
@ -116,6 +127,7 @@ function normalizeProfilePreferences(value: unknown): DesktopProfilePreferences
const record = value as Record<string, unknown>
return {
displayName: normalizeProfileDisplayName(record.displayName),
subtitle: normalizeProfileSubtitle(record.subtitle),
avatarFile: normalizeAvatarFile(record.avatarFile),
avatarUpdatedAt: typeof record.avatarUpdatedAt === 'string' ? record.avatarUpdatedAt : null,
}
@ -265,6 +277,9 @@ export class DesktopUiPreferencesService {
displayName: Object.prototype.hasOwnProperty.call(patch, 'displayName')
? patch.displayName
: currentProfile.displayName,
subtitle: Object.prototype.hasOwnProperty.call(patch, 'subtitle')
? patch.subtitle
: currentProfile.subtitle,
})
const nextPreferences: DesktopUiPreferences = {
...preferences,