diff --git a/desktop/src/api/desktopUiPreferences.test.ts b/desktop/src/api/desktopUiPreferences.test.ts new file mode 100644 index 00000000..709cbaa8 --- /dev/null +++ b/desktop/src/api/desktopUiPreferences.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultBaseUrl, setAuthToken, setBaseUrl } from './client' +import { desktopUiPreferencesApi, getProfileAvatarUrl } from './desktopUiPreferences' + +const preferences = { + schemaVersion: 2, + profile: { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, + }, + sidebar: { + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', + }, +} + +describe('desktopUiPreferencesApi', () => { + afterEach(() => { + setAuthToken(null) + setBaseUrl(getDefaultBaseUrl()) + vi.restoreAllMocks() + }) + + it('wraps preference reads and profile updates with the configured API base URL', async () => { + setBaseUrl('http://127.0.0.1:49237') + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ exists: true, preferences }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, preferences }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, preferences }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + await expect(desktopUiPreferencesApi.getPreferences()).resolves.toEqual({ exists: true, preferences }) + await expect(desktopUiPreferencesApi.updateProfilePreferences({ displayName: 'Local Captain' })).resolves.toEqual({ + ok: true, + preferences, + }) + await expect(desktopUiPreferencesApi.deleteProfileAvatar()).resolves.toEqual({ ok: true, preferences }) + + expect(fetchMock).toHaveBeenNthCalledWith(1, 'http://127.0.0.1:49237/api/desktop-ui/preferences', expect.objectContaining({ + method: 'GET', + })) + 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' }), + })) + expect(fetchMock).toHaveBeenNthCalledWith(3, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar', expect.objectContaining({ + method: 'DELETE', + })) + }) + + it('uploads profile avatars with the file content type and auth token', async () => { + setBaseUrl('http://127.0.0.1:49237') + setAuthToken('h5_avatar_token') + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, preferences }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + const file = new File([new Uint8Array([137, 80, 78, 71])], 'avatar.png', { type: 'image/png' }) + await expect(desktopUiPreferencesApi.uploadProfileAvatar(file)).resolves.toEqual({ ok: true, preferences }) + + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar') + expect(init).toMatchObject({ + method: 'PUT', + headers: { + 'Content-Type': 'image/png', + Authorization: 'Bearer h5_avatar_token', + }, + body: file, + }) + }) + + it('surfaces avatar upload API errors and builds cache-busted avatar URLs', async () => { + setBaseUrl('http://127.0.0.1:49237') + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Too large' }), { + status: 413, + headers: { 'Content-Type': 'application/json' }, + })) + + const file = new File(['bad'], 'avatar.bin', { type: '' }) + await expect(desktopUiPreferencesApi.uploadProfileAvatar(file)).rejects.toThrow('Too large') + + const [, init] = fetchMock.mock.calls[0]! + expect(init).toMatchObject({ + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + }, + body: file, + }) + expect(getProfileAvatarUrl('2026-05-30T15:37:51.649Z')).toBe( + 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar?v=2026-05-30T15%3A37%3A51.649Z', + ) + expect(getProfileAvatarUrl(null)).toBe('http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar') + }) +}) diff --git a/desktop/src/api/desktopUiPreferences.ts b/desktop/src/api/desktopUiPreferences.ts index 4692c491..1b913aa8 100644 --- a/desktop/src/api/desktopUiPreferences.ts +++ b/desktop/src/api/desktopUiPreferences.ts @@ -1,4 +1,4 @@ -import { api } from './client' +import { ApiError, api, getApiUrl, getAuthToken } from './client' export type SidebarProjectPreferences = { projectOrder: string[] @@ -8,9 +8,16 @@ export type SidebarProjectPreferences = { projectSortBy: 'createdAt' | 'updatedAt' } +export type DesktopProfilePreferences = { + displayName: string + avatarFile: string | null + avatarUpdatedAt: string | null +} + export type DesktopUiPreferences = { schemaVersion: number sidebar: SidebarProjectPreferences + profile: DesktopProfilePreferences } export type DesktopUiPreferencesResponse = { @@ -29,4 +36,49 @@ export const desktopUiPreferencesApi = { sidebar, ) }, + + updateProfilePreferences(profile: Pick) { + return api.put<{ ok: true; preferences: DesktopUiPreferences }>( + '/api/desktop-ui/preferences/profile', + profile, + ) + }, + + async uploadProfileAvatar(file: File) { + return uploadProfileAvatar(file) + }, + + deleteProfileAvatar() { + return api.delete<{ ok: true; preferences: DesktopUiPreferences }>( + '/api/desktop-ui/preferences/profile/avatar', + ) + }, +} + +export function getProfileAvatarUrl(updatedAt: string | null | undefined) { + const suffix = updatedAt ? `?v=${encodeURIComponent(updatedAt)}` : '' + return getApiUrl(`/api/desktop-ui/preferences/profile/avatar${suffix}`) +} + +async function uploadProfileAvatar(file: File): Promise<{ ok: true; preferences: DesktopUiPreferences }> { + const headers: Record = { + 'Content-Type': file.type || 'application/octet-stream', + } + const token = getAuthToken() + if (token) { + headers.Authorization = `Bearer ${token}` + } + + const res = await fetch(getApiUrl('/api/desktop-ui/preferences/profile/avatar'), { + method: 'PUT', + headers, + body: file, + }) + + if (!res.ok) { + const body = await res.json().catch(() => res.text()) + throw new ApiError(res.status, body) + } + + return res.json() as Promise<{ ok: true; preferences: DesktopUiPreferences }> } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index d79079ac..14a89160 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -180,7 +180,36 @@ export const en = { // Settings > Usage 'settings.activity.title': 'Token usage', + 'settings.activity.profileTitle': 'Profile', + 'settings.activity.profilePrivacy': 'Local only', + '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.avatar': 'Avatar', + 'settings.activity.avatarHelper': 'PNG, JPEG, or WebP up to 2 MB.', + 'settings.activity.changeAvatar': 'Change avatar', + 'settings.activity.removeAvatar': 'Remove avatar', + 'settings.activity.saveProfile': 'Save', + 'settings.activity.cancelEdit': 'Cancel', + 'settings.activity.profileSaved': 'Saved locally', + 'settings.activity.profileSaveFailed': 'Could not save profile', 'settings.activity.subtitleLoading': 'Based on local Claude Code CLI session transcripts', + 'settings.activity.totalTokens': 'Total tokens', + 'settings.activity.peakTokens': 'Peak tokens', + 'settings.activity.longestTask': 'Longest task', + 'settings.activity.currentStreak': 'Current streak', + 'settings.activity.longestStreak': 'Longest streak', + 'settings.activity.noDuration': '0m', + 'settings.activity.tokenActivity': 'Token Activity', + 'settings.activity.mode.daily': 'Daily', + 'settings.activity.mode.weekly': 'Weekly', + 'settings.activity.mode.cumulative': 'Cumulative', + 'settings.activity.modeHelp.daily': "Daily: each square is that day's token usage.", + 'settings.activity.modeHelp.weekly': "Weekly: each column is one week's total token usage.", + 'settings.activity.modeHelp.cumulative': 'Cumulative: each column is total tokens up to that week.', + 'settings.activity.emptyTitle': 'No local usage yet', + 'settings.activity.emptyBody': 'Start a CLI or desktop session and completed model replies will appear here.', 'settings.activity.metric.yesterday': 'Yesterday', 'settings.activity.metric.today': 'Today', 'settings.activity.metric.last4': 'Last 4 days', @@ -188,6 +217,9 @@ export const en = { 'settings.activity.metric.streak': 'Streak', 'settings.activity.metric.bestStreak': 'best {days} days', 'settings.activity.heatmapLabel': 'Token usage by day', + 'settings.activity.weekRange': '{start} - {end}', + 'settings.activity.cumulativeThrough': 'Through {date}', + 'settings.activity.tokenValue': '{tokens} Tokens', 'settings.activity.count.sessionOne': '{count} session', 'settings.activity.count.sessionOther': '{count} sessions', 'settings.activity.count.dayOne': '{count} day', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index eaf90d4e..c2a4b673 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -182,7 +182,36 @@ export const zh: Record = { // Settings > Usage 'settings.activity.title': 'Token 用量', + 'settings.activity.profileTitle': '个人资料', + 'settings.activity.profilePrivacy': '仅本地', + 'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha', + 'settings.activity.editProfile': '编辑个人资料', + 'settings.activity.displayName': '显示名称', + 'settings.activity.displayNameHelper': '这里只修改主名称,第二行保持为项目链接。', + 'settings.activity.avatar': '头像', + 'settings.activity.avatarHelper': '支持 PNG、JPEG 或 WebP,最大 2 MB。', + 'settings.activity.changeAvatar': '更换头像', + 'settings.activity.removeAvatar': '移除头像', + 'settings.activity.saveProfile': '保存', + 'settings.activity.cancelEdit': '取消', + 'settings.activity.profileSaved': '已保存到本地', + 'settings.activity.profileSaveFailed': '保存个人资料失败', 'settings.activity.subtitleLoading': '基于本机 Claude Code CLI 会话记录统计', + 'settings.activity.totalTokens': '累计 Token 数', + 'settings.activity.peakTokens': '峰值 Token 数', + 'settings.activity.longestTask': '最长任务时长', + 'settings.activity.currentStreak': '当前连续天数', + 'settings.activity.longestStreak': '最长连续天数', + 'settings.activity.noDuration': '0 分钟', + 'settings.activity.tokenActivity': 'Token 活动', + 'settings.activity.mode.daily': '每日', + 'settings.activity.mode.weekly': '每周', + 'settings.activity.mode.cumulative': '累计', + 'settings.activity.modeHelp.daily': '每日:每个格子表示当天 Token 用量。', + 'settings.activity.modeHelp.weekly': '每周:每一列表示这一周的 Token 总量。', + 'settings.activity.modeHelp.cumulative': '累计:每一列表示截至该周的累计 Token 总量。', + 'settings.activity.emptyTitle': '暂无本地用量', + 'settings.activity.emptyBody': '启动 CLI 或桌面会话并完成模型回复后,这里会显示统计。', 'settings.activity.metric.yesterday': '昨天', 'settings.activity.metric.today': '今天', 'settings.activity.metric.last4': '近 4 天', @@ -190,6 +219,9 @@ export const zh: Record = { 'settings.activity.metric.streak': '连续活跃', 'settings.activity.metric.bestStreak': '最长 {days} 天', 'settings.activity.heatmapLabel': '按天统计的 Token 用量', + 'settings.activity.weekRange': '{start} - {end}', + 'settings.activity.cumulativeThrough': '截至 {date}', + 'settings.activity.tokenValue': '{tokens} Token', 'settings.activity.count.sessionOne': '{count} 次会话', 'settings.activity.count.sessionOther': '{count} 次会话', 'settings.activity.count.dayOne': '{count} 天', diff --git a/desktop/src/pages/ActivitySettings.test.tsx b/desktop/src/pages/ActivitySettings.test.tsx index dc8e5baf..40c74a14 100644 --- a/desktop/src/pages/ActivitySettings.test.tsx +++ b/desktop/src/pages/ActivitySettings.test.tsx @@ -9,12 +9,34 @@ const { getStatsMock } = vi.hoisted(() => ({ getStatsMock: vi.fn(), })) +const { + getPreferencesMock, + updateProfilePreferencesMock, + uploadProfileAvatarMock, + deleteProfileAvatarMock, +} = vi.hoisted(() => ({ + getPreferencesMock: vi.fn(), + updateProfilePreferencesMock: vi.fn(), + uploadProfileAvatarMock: vi.fn(), + deleteProfileAvatarMock: vi.fn(), +})) + vi.mock('../api/activityStats', () => ({ activityStatsApi: { getStats: getStatsMock, }, })) +vi.mock('../api/desktopUiPreferences', () => ({ + desktopUiPreferencesApi: { + getPreferences: getPreferencesMock, + updateProfilePreferences: updateProfilePreferencesMock, + uploadProfileAvatar: uploadProfileAvatarMock, + deleteProfileAvatar: deleteProfileAvatarMock, + }, + getProfileAvatarUrl: () => '/api/desktop-ui/preferences/profile/avatar?mock=1', +})) + const activityResponse = { range: 'all', generatedAt: '2026-05-09T12:00:00.000Z', @@ -61,6 +83,82 @@ describe('ActivitySettings', () => { vi.setSystemTime(new Date('2026-05-09T12:00:00')) getStatsMock.mockReset() getStatsMock.mockResolvedValue(activityResponse) + getPreferencesMock.mockReset() + updateProfilePreferencesMock.mockReset() + uploadProfileAvatarMock.mockReset() + deleteProfileAvatarMock.mockReset() + getPreferencesMock.mockResolvedValue({ + exists: false, + preferences: { + schemaVersion: 2, + profile: { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, + }, + sidebar: { + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', + }, + }, + }) + updateProfilePreferencesMock.mockImplementation((profile) => Promise.resolve({ + ok: true, + preferences: { + schemaVersion: 2, + profile: { + displayName: profile.displayName, + avatarFile: null, + avatarUpdatedAt: null, + }, + sidebar: { + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', + }, + }, + })) + uploadProfileAvatarMock.mockImplementation(() => Promise.resolve({ + ok: true, + preferences: { + schemaVersion: 2, + profile: { + displayName: 'cc-haha', + avatarFile: 'profile/avatar.png', + avatarUpdatedAt: '2026-05-09T12:00:00.000Z', + }, + sidebar: { + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', + }, + }, + })) + deleteProfileAvatarMock.mockImplementation(() => Promise.resolve({ + ok: true, + preferences: { + schemaVersion: 2, + profile: { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, + }, + sidebar: { + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', + }, + }, + })) useSettingsStore.setState({ locale: 'en' }) }) @@ -75,24 +173,29 @@ describe('ActivitySettings', () => { expect(getStatsMock).toHaveBeenCalledWith('all') - expect(screen.getByText('Token usage')).toBeInTheDocument() - expect(screen.getByText('2025.05 - 2026.05')).toBeInTheDocument() - expect(screen.getByText('Based on local Claude Code CLI session transcripts')).toBeInTheDocument() - expect(screen.getByText('Yesterday')).toBeInTheDocument() - expect(screen.getByText('Today')).toBeInTheDocument() - expect(screen.getByText('30 days')).toBeInTheDocument() - expect(screen.getByText('0 tokens')).toBeInTheDocument() - expect(screen.getByText('128K tokens')).toBeInTheDocument() - expect(screen.getByText('2.9M tokens')).toBeInTheDocument() + expect(screen.getByText('cc-haha')).toBeInTheDocument() + expect(screen.getByAltText('cc-haha avatar')).toHaveAttribute('src', '/app-icon.png') + expect(screen.getByAltText('cc-haha avatar')).toHaveClass('scale-[1.28]') + expect(screen.getByRole('link', { name: 'github.com/NanmiCoder/cc-haha' })).toHaveAttribute( + 'href', + 'https://github.com/NanmiCoder/cc-haha', + ) + expect(screen.getByText('Token Activity')).toBeInTheDocument() + expect(screen.getByText('Total tokens')).toBeInTheDocument() + expect(screen.getByText('Peak tokens')).toBeInTheDocument() + expect(screen.getByText('Longest task')).toBeInTheDocument() + expect(screen.getByText('Current streak')).toBeInTheDocument() + expect(screen.getByText('Longest streak')).toBeInTheDocument() + expect(screen.getByText('2.9M')).toBeInTheDocument() + expect(screen.getByText('2.7M')).toBeInTheDocument() + expect(screen.getByText('0m')).toBeInTheDocument() + expect(screen.getByText('9 days')).toBeInTheDocument() + expect(screen.getByText('18 days')).toBeInTheDocument() expect(screen.getAllByText('May').length).toBeGreaterThan(0) expect(screen.queryByText('5月')).not.toBeInTheDocument() - expect( - screen.getByText('Today').compareDocumentPosition(screen.getByText('Yesterday')) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy() const todayCell = screen.getByRole('gridcell', { - name: /May 9, 2026: 4 sessions, 128K tokens/i, + name: /May 9, 2026: 4 sessions · 128K Tokens/i, }) expect(todayCell).toBeInTheDocument() expect(screen.queryByRole('gridcell', { name: /May 10, 2026/i })).not.toBeInTheDocument() @@ -104,17 +207,138 @@ describe('ActivitySettings', () => { await flushActivityLoad() const todayCell = screen.getByRole('gridcell', { - name: /May 9, 2026: 4 sessions, 128K tokens/i, + name: /May 9, 2026: 4 sessions · 128K Tokens/i, }) fireEvent.mouseEnter(todayCell) const tooltip = screen.getByRole('tooltip') expect(tooltip).toHaveTextContent('May 9, 2026') - expect(tooltip).toHaveTextContent('4 sessions · 128K tokens') + expect(tooltip).toHaveTextContent('4 sessions · 128K Tokens') expect(tooltip).not.toHaveTextContent(/messages|tools/i) expect(tooltip.className).toContain('--color-activity-tooltip-surface') expect(tooltip.className).toContain('--color-activity-tooltip-border') + expect(todayCell.className).toContain('activity-heat-cell') + expect(todayCell.className).toContain('is-active') expect(todayCell.className).toContain('--color-activity-cell-border') expect(screen.queryByText('Selected day')).not.toBeInTheDocument() }) + + it('supports localized heatmap mode switches and persisted display name edits', async () => { + useSettingsStore.setState({ locale: 'zh' }) + render() + + await flushActivityLoad() + + expect(screen.getByText('Token 活动')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '每周' })) + expect(screen.getByRole('button', { name: '每周' })).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(screen.getByRole('button', { name: '累计' })) + expect(screen.getByRole('button', { name: '累计' })).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(screen.getByRole('button', { name: '编辑个人资料' })) + const input = screen.getByLabelText('显示名称') + fireEvent.change(input, { target: { value: '本地舰长' } }) + fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await flushActivityLoad() + + expect(updateProfilePreferencesMock).toHaveBeenCalledWith({ displayName: '本地舰长' }) + expect(screen.getByText('本地舰长')).toBeInTheDocument() + }) + + it('handles avatar upload, fallback, removal, save failure, and cancel reset', async () => { + getPreferencesMock.mockResolvedValueOnce({ + exists: true, + preferences: { + schemaVersion: 2, + profile: { + displayName: 'Local Captain', + avatarFile: 'profile/avatar.webp', + avatarUpdatedAt: '2026-05-09T12:00:00.000Z', + }, + sidebar: { + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', + }, + }, + }) + updateProfilePreferencesMock.mockRejectedValueOnce(new Error('display name rejected')) + const { container } = render() + + await flushActivityLoad() + + const avatar = screen.getByAltText('Local Captain avatar') + expect(avatar).toHaveAttribute('src', '/api/desktop-ui/preferences/profile/avatar?mock=1') + expect(avatar).not.toHaveClass('scale-[1.28]') + fireEvent.error(avatar) + expect(avatar).toHaveAttribute('src', '/app-icon.png') + expect(avatar).toHaveClass('scale-[1.28]') + + fireEvent.click(screen.getByRole('button', { name: 'Edit profile' })) + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Rejected Name' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await flushActivityLoad() + + expect(screen.getByText('display name rejected')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Unsaved Name' } }) + 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') + + const input = container.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] } }) + + await flushActivityLoad() + + expect(uploadProfileAvatarMock).toHaveBeenCalledWith(file) + expect(screen.getByText('Saved locally')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Remove avatar' })) + await flushActivityLoad() + + expect(deleteProfileAvatarMock).toHaveBeenCalled() + expect(screen.getByAltText('cc-haha avatar')).toHaveAttribute('src', '/app-icon.png') + }) + + it('shows localized duration details and the empty usage state', async () => { + useSettingsStore.setState({ locale: 'zh' }) + getStatsMock.mockResolvedValueOnce({ + ...activityResponse, + totalSessions: 0, + totalMessages: 0, + activeDays: 0, + dailyActivity: [], + dailyModelTokens: [], + longestSession: { + id: 'session-1', + startedAt: '2026-05-09T08:00:00.000Z', + endedAt: '2026-05-09T09:30:00.000Z', + duration: 90 * 60_000, + messageCount: 12, + toolCallCount: 4, + }, + peakActivityDay: null, + streaks: { + currentStreak: 0, + longestStreak: 0, + currentStreakStart: null, + longestStreakStart: null, + longestStreakEnd: null, + }, + }) + render() + + await flushActivityLoad() + + expect(screen.getByText('1 小时 30 分钟')).toBeInTheDocument() + expect(screen.getByText('12 消息')).toBeInTheDocument() + expect(screen.getByText('暂无本地用量')).toBeInTheDocument() + }) }) diff --git a/desktop/src/pages/ActivitySettings.tsx b/desktop/src/pages/ActivitySettings.tsx index bf71931b..b40a6d4c 100644 --- a/desktop/src/pages/ActivitySettings.tsx +++ b/desktop/src/pages/ActivitySettings.tsx @@ -1,5 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react' import { activityStatsApi, type ActivityStatsResponse, type DailyActivity } from '../api/activityStats' +import { + desktopUiPreferencesApi, + getProfileAvatarUrl, + type DesktopProfilePreferences, +} from '../api/desktopUiPreferences' import { type Locale, useTranslation } from '../i18n' import { useSettingsStore } from '../stores/settingsStore' @@ -10,6 +15,9 @@ type HeatmapDay = { toolCallCount: number tokens: number level: number + mode: HeatmapMode + rangeStart?: string + rangeEnd?: string } type SummaryMetric = { @@ -18,6 +26,8 @@ type SummaryMetric = { detail?: string } +type HeatmapMode = 'daily' | 'weekly' | 'cumulative' + const WEEK_COUNT = 52 const WEEKDAY_LABEL_KEYS = [ 'settings.activity.weekday.mon', @@ -40,6 +50,12 @@ const DATE_LOCALES: Record = { en: 'en-US', zh: 'zh-CN', } +const DEFAULT_PROFILE: DesktopProfilePreferences = { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, +} +const PROFILE_LINK_URL = 'https://github.com/NanmiCoder/cc-haha' function localDateKey(date: Date) { const year = date.getFullYear() @@ -73,15 +89,6 @@ function formatDateLabel(dateKey: string, locale: Locale) { }) } -function formatMonthKey(dateKey: string) { - const date = parseLocalDate(dateKey) - return `${date.getFullYear()}.${`${date.getMonth() + 1}`.padStart(2, '0')}` -} - -function formatDateRange(start: string, end: string) { - return `${formatMonthKey(start)} - ${formatMonthKey(end)}` -} - function formatTokens(tokens: number) { if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(tokens >= 10_000_000_000 ? 0 : 1)}B` if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M` @@ -89,10 +96,35 @@ function formatTokens(tokens: number) { return `${tokens}` } +function formatDayCount(value: number, t: ReturnType) { + return t(value === 1 ? 'settings.activity.count.dayOne' : 'settings.activity.count.dayOther', { count: value }) +} + +function formatTaskDuration(duration: number | undefined, locale: Locale, t: ReturnType) { + if (!duration || duration <= 0) return t('settings.activity.noDuration') + const totalMinutes = Math.max(1, Math.round(duration / 60_000)) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + + if (locale === 'zh') { + if (hours > 0 && minutes > 0) return `${hours} 小时 ${minutes} 分钟` + if (hours > 0) return `${hours} 小时` + return `${minutes} 分钟` + } + + if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m` + if (hours > 0) return `${hours}h` + return `${minutes}m` +} + function formatSessionCount(value: number, t: ReturnType) { return t(value === 1 ? 'settings.activity.count.sessionOne' : 'settings.activity.count.sessionOther', { count: value }) } +function formatMessageCount(value: number, t: ReturnType) { + return `${value} ${t('settings.activity.messages')}` +} + 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))) @@ -130,7 +162,22 @@ function getHeatLevel(day: DailyActivity | undefined, tokens: number, maxScore: return 1 } -function buildHeatmapDays(stats: ActivityStatsResponse | null) { +function getBarHeight(value: number, maxValue: number) { + if (value <= 0 || maxValue <= 0) return 0 + return Math.max(1, Math.min(7, Math.ceil((value / maxValue) * 7))) +} + +function getBarLevel(value: number, maxValue: number) { + if (value <= 0) return 0 + if (maxValue <= 0) return 1 + const ratio = value / maxValue + if (ratio >= 0.78) return 4 + if (ratio >= 0.5) return 3 + if (ratio >= 0.24) return 2 + return 1 +} + +function buildHeatmapDays(stats: ActivityStatsResponse | null, mode: HeatmapMode) { const today = new Date() today.setHours(0, 0, 0, 0) @@ -138,21 +185,27 @@ function buildHeatmapDays(stats: ActivityStatsResponse | null) { const start = addDays(finalWeekStart, -(WEEK_COUNT - 1) * 7) const activityMap = new Map((stats?.dailyActivity ?? []).map((day) => [day.date, day])) const tokenMap = getDailyTokenMap(stats) + const dates: string[] = [] + for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) { + dates.push(localDateKey(cursor)) + } const scores: number[] = [] - for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) { - const dateKey = localDateKey(cursor) + let cumulativeTokens = 0 + for (const dateKey of dates) { const day = activityMap.get(dateKey) const tokens = tokenMap.get(dateKey) ?? 0 + cumulativeTokens += tokens scores.push((day?.sessionCount ?? 0) * 3 + Math.ceil(tokens / 50_000)) } const maxScore = Math.max(...scores, 0) const days: HeatmapDay[] = [] - for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) { - const dateKey = localDateKey(cursor) + cumulativeTokens = 0 + for (const dateKey of dates) { const day = activityMap.get(dateKey) const tokens = tokenMap.get(dateKey) ?? 0 + cumulativeTokens += tokens days.push({ date: dateKey, sessionCount: day?.sessionCount ?? 0, @@ -160,10 +213,66 @@ function buildHeatmapDays(stats: ActivityStatsResponse | null) { toolCallCount: day?.toolCallCount ?? 0, tokens, level: getHeatLevel(day, tokens, maxScore), + mode: 'daily', }) } - return days + if (mode === 'daily') return days + + const weeks = Array.from({ length: WEEK_COUNT }, (_, index) => { + const rangeStart = dates[index * 7] ?? '' + const rangeEnd = dates[Math.min(index * 7 + 6, dates.length - 1)] ?? rangeStart + return { + rangeStart, + rangeEnd, + sessionCount: 0, + messageCount: 0, + toolCallCount: 0, + tokens: 0, + cumulativeTokens: 0, + } + }) + + dates.forEach((dateKey, index) => { + const week = weeks[Math.floor(index / 7)] + const day = activityMap.get(dateKey) + if (!week) return + week.sessionCount += day?.sessionCount ?? 0 + week.messageCount += day?.messageCount ?? 0 + week.toolCallCount += day?.toolCallCount ?? 0 + week.tokens += tokenMap.get(dateKey) ?? 0 + }) + + let runningTotal = 0 + for (const week of weeks) { + runningTotal += week.tokens + week.cumulativeTokens = runningTotal + } + + const maxValue = Math.max( + ...weeks.map((week) => (mode === 'weekly' ? week.tokens : week.cumulativeTokens)), + 0, + ) + + return dates.map((dateKey, index) => { + const week = weeks[Math.floor(index / 7)] + const row = index % 7 + const tokens = mode === 'weekly' ? week?.tokens ?? 0 : week?.cumulativeTokens ?? 0 + const height = getBarHeight(tokens, maxValue) + const isFilled = height > 0 && row >= 7 - height + + return { + date: dateKey, + sessionCount: week?.sessionCount ?? 0, + messageCount: week?.messageCount ?? 0, + toolCallCount: week?.toolCallCount ?? 0, + tokens, + level: isFilled ? getBarLevel(tokens, maxValue) : 0, + mode, + rangeStart: week?.rangeStart, + rangeEnd: week?.rangeEnd, + } + }) } function buildMonthLabels(days: HeatmapDay[], locale: Locale) { @@ -192,13 +301,47 @@ function buildMonthLabels(days: HeatmapDay[], locale: Locale) { return labels } +function getHeatmapCellTitle(day: HeatmapDay, locale: Locale, t: ReturnType) { + if (day.mode === 'weekly') { + return t('settings.activity.weekRange', { + start: formatDateLabel(day.rangeStart ?? day.date, locale), + end: formatDateLabel(day.rangeEnd ?? day.date, locale), + }) + } + + if (day.mode === 'cumulative') { + return t('settings.activity.cumulativeThrough', { + date: formatDateLabel(day.rangeEnd ?? day.date, locale), + }) + } + + return formatDateLabel(day.date, locale) +} + +function getHeatmapCellDetail(day: HeatmapDay, t: ReturnType) { + if (day.mode === 'cumulative') { + return t('settings.activity.tokenValue', { tokens: formatTokens(day.tokens) }) + } + + return `${formatSessionCount(day.sessionCount, t)} · ${formatTokens(day.tokens)} ${t('settings.activity.tokens')}` +} + export function ActivitySettings() { const t = useTranslation() const locale = useSettingsStore((state) => state.locale) const heatmapMeasureRef = useRef(null) + const avatarInputRef = useRef(null) const [stats, setStats] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) + const [profile, setProfile] = useState(DEFAULT_PROFILE) + const [profileError, setProfileError] = useState(null) + const [profileStatus, setProfileStatus] = useState(null) + const [isProfileLoading, setIsProfileLoading] = useState(true) + const [isEditingProfile, setIsEditingProfile] = useState(false) + const [isSavingProfile, setIsSavingProfile] = useState(false) + const [draftDisplayName, setDraftDisplayName] = useState(DEFAULT_PROFILE.displayName) + const [heatmapMode, setHeatmapMode] = useState('daily') const [hoveredDate, setHoveredDate] = useState(null) const [focusedDate, setFocusedDate] = useState(null) const [heatCellSize, setHeatCellSize] = useState(10) @@ -226,6 +369,31 @@ export function ActivitySettings() { } }, []) + useEffect(() => { + let cancelled = false + setIsProfileLoading(true) + setProfileError(null) + + desktopUiPreferencesApi.getPreferences() + .then((result) => { + if (cancelled) return + const nextProfile = result.preferences.profile ?? DEFAULT_PROFILE + setProfile(nextProfile) + setDraftDisplayName(nextProfile.displayName) + }) + .catch((err) => { + if (cancelled) return + setProfileError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setIsProfileLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + useEffect(() => { if (isLoading || error) return const element = heatmapMeasureRef.current @@ -248,7 +416,7 @@ export function ActivitySettings() { return () => window.removeEventListener('resize', updateCellSize) }, [error, isLoading]) - const days = useMemo(() => buildHeatmapDays(stats), [stats]) + const days = useMemo(() => buildHeatmapDays(stats, heatmapMode), [heatmapMode, stats]) const monthLabels = useMemo(() => buildMonthLabels(days, locale), [days, locale]) const today = days.length > 0 ? days[days.length - 1] : null const activeTooltipDate = hoveredDate ?? focusedDate @@ -267,66 +435,319 @@ export function ActivitySettings() { ), ), top: Math.max(28, 30 + (tooltipIndex % 7) * (heatCellSize + HEAT_CELL_GAP) - 50), - } + } : undefined - const dateRange = days.length > 0 && days[0] && today ? formatDateRange(days[0].date, today.date) : '' - const yesterdayDate = today ? localDateKey(addDays(parseLocalDate(today.date), -1)) : null - const yesterday = days.find((day) => day.date === yesterdayDate) ?? null const last30Usage = sumDailyUsage(days.slice(-30)) - + const totalTokens = useMemo(() => { + return (stats?.dailyModelTokens ?? []).reduce((sum, day) => ( + sum + Object.values(day.tokensByModel).reduce((daySum, tokens) => daySum + tokens, 0) + ), 0) + }, [stats]) + const peakTokens = useMemo(() => { + return (stats?.dailyModelTokens ?? []).reduce((peak, day) => { + const dayTotal = Object.values(day.tokensByModel).reduce((sum, tokens) => sum + tokens, 0) + return Math.max(peak, dayTotal) + }, 0) + }, [stats]) const metrics: SummaryMetric[] = [ { - label: t('settings.activity.metric.today'), - value: today ? `${formatTokens(today.tokens)} tokens` : '0 tokens', - detail: today ? formatSessionCount(today.sessionCount, t) : formatSessionCount(0, t), + label: t('settings.activity.totalTokens'), + value: formatTokens(totalTokens), + detail: formatDayCount(stats?.activeDays ?? 0, t), }, { - label: t('settings.activity.metric.yesterday'), - value: `${formatTokens(yesterday?.tokens ?? 0)} tokens`, - detail: formatSessionCount(yesterday?.sessionCount ?? 0, t), + label: t('settings.activity.peakTokens'), + value: formatTokens(peakTokens), + detail: stats?.peakActivityDay ? formatDateLabel(stats.peakActivityDay, locale) : undefined, }, { - label: t('settings.activity.metric.last30'), - value: `${formatTokens(last30Usage.tokens)} tokens`, + label: t('settings.activity.longestTask'), + value: formatTaskDuration(stats?.longestSession?.duration, locale, t), + detail: stats?.longestSession ? formatMessageCount(stats.longestSession.messageCount, t) : undefined, + }, + { + label: t('settings.activity.currentStreak'), + value: formatDayCount(stats?.streaks.currentStreak ?? 0, t), + detail: today ? `${formatTokens(today.tokens)} ${t('settings.activity.tokens')}` : undefined, + }, + { + label: t('settings.activity.longestStreak'), + value: formatDayCount(stats?.streaks.longestStreak ?? 0, t), detail: formatSessionCount(last30Usage.sessions, t), }, ] + const avatarSrc = profile.avatarFile ? getProfileAvatarUrl(profile.avatarUpdatedAt) : '/app-icon.png' + const avatarClassName = profile.avatarFile + ? 'h-full w-full object-cover' + : 'h-full w-full scale-[1.28] object-contain transition-transform' + 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') }, + { mode: 'weekly', label: t('settings.activity.mode.weekly'), help: t('settings.activity.modeHelp.weekly') }, + { mode: 'cumulative', label: t('settings.activity.mode.cumulative'), help: t('settings.activity.modeHelp.cumulative') }, + ] + + const saveProfile = async () => { + setIsSavingProfile(true) + setProfileError(null) + setProfileStatus(null) + try { + const result = await desktopUiPreferencesApi.updateProfilePreferences({ displayName: draftDisplayName }) + setProfile(result.preferences.profile) + setDraftDisplayName(result.preferences.profile.displayName) + setIsEditingProfile(false) + setProfileStatus(t('settings.activity.profileSaved')) + } catch (err) { + setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed')) + } finally { + setIsSavingProfile(false) + } + } + + const handleAvatarChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + setIsSavingProfile(true) + setProfileError(null) + setProfileStatus(null) + try { + const result = await desktopUiPreferencesApi.uploadProfileAvatar(file) + setProfile(result.preferences.profile) + setDraftDisplayName(result.preferences.profile.displayName) + setProfileStatus(t('settings.activity.profileSaved')) + } catch (err) { + setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed')) + } finally { + setIsSavingProfile(false) + } + } + + const removeAvatar = async () => { + setIsSavingProfile(true) + setProfileError(null) + setProfileStatus(null) + try { + const result = await desktopUiPreferencesApi.deleteProfileAvatar() + setProfile(result.preferences.profile) + setProfileStatus(t('settings.activity.profileSaved')) + } catch (err) { + setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed')) + } finally { + setIsSavingProfile(false) + } + } return ( -
-
-
-

{t('settings.activity.title')}

-
- {dateRange &&
{dateRange}
} -
{t('settings.activity.subtitleLoading')}
+
+
+ + +
+ {`${profile.displayName} { + event.currentTarget.src = '/app-icon.png' + event.currentTarget.className = 'h-full w-full scale-[1.28] object-contain transition-transform' + }} + /> +
+

{profile.displayName}

+ + {t('settings.activity.defaultHandle')} + + {profileStatus &&
{profileStatus}
} + {profileError && !isEditingProfile &&
{profileError}
} +
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ ) : ( +
+ {metrics.map((metric, index) => ( +
+
{metric.value}
+
{metric.label}
+ {metric.detail &&
{metric.detail}
} +
+ ))} +
+ )} +
+ + {isEditingProfile && ( +
+
+
+
+

{t('settings.activity.editProfile')}

+

{t('settings.activity.displayNameHelper')}

+
+ +
+ +
+
+ + setDraftDisplayName(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)]" + /> +
+ +
+
{t('settings.activity.avatar')}
+

{t('settings.activity.avatarHelper')}

+
+ + + {profile.avatarFile && ( + + )} +
+
+
+ + {profileError &&
{profileError}
} + +
+ + +
+
+
+ )} + +
+
+
+

{t('settings.activity.tokenActivity')}

+
+
+ {modeOptions.map((option) => ( + + ))}
-
- {metrics.map((metric) => ( -
-
{metric.label}
-
{metric.value}
- {metric.detail &&
{metric.detail}
} -
- ))} -
-
- -
{isLoading ? ( -
- progress_activity - {t('common.loading')} +
+
+
+ {Array.from({ length: 52 }).map((_, col) => ( +
+ {Array.from({ length: 7 }).map((__, row) => ( +
+ ))} +
+ ))} +
) : error ? (
{error}
+ ) : !hasUsage ? ( +
+
+
+ +
+
{t('settings.activity.emptyTitle')}
+

{t('settings.activity.emptyBody')}

+
+
) : ( <>
@@ -371,16 +792,18 @@ export function ActivitySettings() { {days.map((day) => { const isSelected = activeTooltipDate === day.date const tooltipId = `activity-day-tooltip-${day.date}` + const cellTitle = getHeatmapCellTitle(day, locale, t) + const cellDetail = getHeatmapCellDetail(day, t) return (
)} diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 1188dd88..6679aeb2 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -401,13 +401,14 @@ --color-model-option-selected-bg: var(--color-primary-fixed); --color-model-option-selected-border: rgba(143, 72, 47, 0.2); --color-activity-heat-0: var(--color-surface-container); - --color-activity-heat-1: #FFE6DE; - --color-activity-heat-2: #FFBCA8; - --color-activity-heat-3: #E57A58; - --color-activity-heat-4: #8F482F; + --color-activity-heat-1: #FBE9E2; + --color-activity-heat-2: var(--color-primary-fixed); + --color-activity-heat-3: var(--color-primary-fixed-dim); + --color-activity-heat-4: var(--color-primary); --color-activity-cell-border: rgba(135, 115, 109, 0.3); - --color-activity-cell-border-hover: rgba(84, 67, 62, 0.72); + --color-activity-cell-border-hover: rgba(143, 72, 47, 0.58); --color-activity-cell-border-active: var(--color-text-primary); + --shadow-activity-cell-hover: 0 0 0 2px rgba(143, 72, 47, 0.12), 0 8px 18px rgba(65, 54, 48, 0.16); --color-activity-tooltip-surface: #2F312E; --color-activity-tooltip-border: rgba(255, 255, 255, 0.12); --color-activity-tooltip-text: #F2F1ED; @@ -608,17 +609,18 @@ --color-model-option-selected-bg: #FFF0EA; --color-model-option-selected-border: rgba(143, 72, 47, 0.2); --color-activity-heat-0: #EEF2F6; - --color-activity-heat-1: #FFE9E1; - --color-activity-heat-2: #FFC4B1; - --color-activity-heat-3: #E57A58; - --color-activity-heat-4: #8F482F; + --color-activity-heat-1: #FFF0EA; + --color-activity-heat-2: var(--color-primary-fixed); + --color-activity-heat-3: var(--color-primary-fixed-dim); + --color-activity-heat-4: var(--color-primary); --color-activity-cell-border: rgba(139, 152, 167, 0.32); - --color-activity-cell-border-hover: rgba(75, 85, 99, 0.72); + --color-activity-cell-border-hover: rgba(143, 72, 47, 0.55); --color-activity-cell-border-active: var(--color-text-primary); + --shadow-activity-cell-hover: 0 0 0 2px rgba(143, 72, 47, 0.11), 0 8px 18px rgba(15, 23, 42, 0.14); --color-activity-tooltip-surface: #111827; --color-activity-tooltip-border: rgba(255, 255, 255, 0.14); --color-activity-tooltip-text: #F9FAFB; - --color-activity-tooltip-muted: #FFC4B1; + --color-activity-tooltip-muted: #FFB59D; --color-inspector-surface: #FFFFFF; --color-inspector-panel: #F7F9FB; --color-inspector-chip: #EEF3F6; @@ -792,17 +794,18 @@ --color-model-option-selected-bg: rgba(255, 181, 159, 0.13); --color-model-option-selected-border: rgba(255, 181, 159, 0.34); --color-activity-heat-0: #242322; - --color-activity-heat-1: #4A2B24; - --color-activity-heat-2: #85432F; - --color-activity-heat-3: #C85F3C; - --color-activity-heat-4: #FF8A5C; + --color-activity-heat-1: #3A2823; + --color-activity-heat-2: #674032; + --color-activity-heat-3: #A76549; + --color-activity-heat-4: var(--color-primary); --color-activity-cell-border: rgba(229, 226, 225, 0.08); - --color-activity-cell-border-hover: rgba(255, 181, 159, 0.48); + --color-activity-cell-border-hover: rgba(255, 181, 159, 0.58); --color-activity-cell-border-active: #FBE7E1; + --shadow-activity-cell-hover: 0 0 0 2px rgba(255, 181, 159, 0.16), 0 8px 18px rgba(0, 0, 0, 0.34); --color-activity-tooltip-surface: #F2F1ED; - --color-activity-tooltip-border: rgba(255, 181, 159, 0.32); + --color-activity-tooltip-border: rgba(255, 181, 159, 0.36); --color-activity-tooltip-text: #1B1C1A; - --color-activity-tooltip-muted: #8F3217; + --color-activity-tooltip-muted: #8F482F; --color-inspector-surface: #131211; --color-inspector-panel: #1C1B1B; --color-inspector-chip: #252120; @@ -1273,6 +1276,38 @@ button, input, textarea, select, a, [role="button"] { background: var(--color-outline-a92); } +.activity-heat-cell { + position: relative; + transform: translateZ(0); + transform-origin: center; + transition: + border-color 150ms ease, + box-shadow 180ms ease, + filter 180ms ease, + transform 180ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.activity-heat-cell:hover, +.activity-heat-cell:focus-visible, +.activity-heat-cell.is-active { + z-index: 1; + transform: translateZ(0) scale(1.16); + box-shadow: var(--shadow-activity-cell-hover); + filter: saturate(1.08); +} + +@media (prefers-reduced-motion: reduce) { + .activity-heat-cell { + transition-duration: 0.01ms !important; + } + + .activity-heat-cell:hover, + .activity-heat-cell:focus-visible, + .activity-heat-cell.is-active { + transform: translateZ(0); + } +} + /* Animations */ @keyframes shimmer { 0%, 100% { opacity: 0.4; } @@ -1285,6 +1320,10 @@ button, input, textarea, select, a, [role="button"] { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } +@keyframes activity-reveal { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} @keyframes composer-drop-fade { from { opacity: 0; transform: scale(0.985); } to { opacity: 1; transform: scale(1); } diff --git a/desktop/src/theme/globals.test.ts b/desktop/src/theme/globals.test.ts index 22dad05b..fe011d40 100644 --- a/desktop/src/theme/globals.test.ts +++ b/desktop/src/theme/globals.test.ts @@ -43,6 +43,7 @@ describe('desktop theme tokens', () => { '--color-activity-cell-border', '--color-activity-cell-border-hover', '--color-activity-cell-border-active', + '--shadow-activity-cell-hover', '--color-activity-tooltip-surface', '--color-activity-tooltip-border', '--color-activity-tooltip-text', @@ -79,6 +80,15 @@ describe('desktop theme tokens', () => { } }) + it('keeps activity heatmap colors on the app theme accent instead of the old blue ramp', () => { + expect(css).not.toContain('#DCEEFF') + expect(css).not.toContain('#B6D9FF') + expect(css).not.toContain('#2387E8') + expect(css).toContain('--color-activity-heat-4: var(--color-primary);') + expect(css).toContain('.activity-heat-cell:hover') + expect(css).toContain('box-shadow: var(--shadow-activity-cell-hover);') + }) + it('avoids color-mix in the startup-critical UI zoom shell chrome for Safari 15 WebView support', () => { const zoomShellCss = getCssBetween('.settings-zoom-kbd {', '/* ─── Tailwind Theme Override') diff --git a/src/server/__tests__/desktop-ui-preferences.test.ts b/src/server/__tests__/desktop-ui-preferences.test.ts index cb0af3c1..f8c1a74f 100644 --- a/src/server/__tests__/desktop-ui-preferences.test.ts +++ b/src/server/__tests__/desktop-ui-preferences.test.ts @@ -26,13 +26,14 @@ async function teardown() { function makeRequest( method: string, urlStr: string, - body?: Record, + body?: Record | Uint8Array, + contentType = 'application/json', ): { req: Request; url: URL; segments: string[] } { const url = new URL(urlStr, 'http://localhost:3456') const init: RequestInit = { method } if (body !== undefined) { - init.headers = { 'Content-Type': 'application/json' } - init.body = JSON.stringify(body) + init.headers = { 'Content-Type': contentType } + init.body = body instanceof Uint8Array ? body : JSON.stringify(body) } const req = new Request(url.toString(), init) const segments = url.pathname.split('/').filter(Boolean) @@ -55,7 +56,12 @@ describe('DesktopUiPreferencesService', () => { expect(result.exists).toBe(false) expect(result.preferences).toEqual({ - schemaVersion: 1, + schemaVersion: 2, + profile: { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, + }, sidebar: { projectOrder: [], pinnedProjects: [], @@ -91,8 +97,13 @@ describe('DesktopUiPreferencesService', () => { expect(before.exists).toBe(true) expect(before.preferences).toEqual({ - schemaVersion: 1, + schemaVersion: 2, futureField: { keep: true }, + profile: { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, + }, sidebar: { projectOrder: ['/workspace/alpha', '/workspace/beta'], pinnedProjects: ['/workspace/beta'], @@ -102,8 +113,13 @@ describe('DesktopUiPreferencesService', () => { }, }) expect(after).toEqual({ - schemaVersion: 1, + schemaVersion: 2, futureField: { keep: true }, + profile: { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, + }, sidebar: { projectOrder: ['/workspace/gamma'], pinnedProjects: [], @@ -125,8 +141,66 @@ describe('DesktopUiPreferencesService', () => { expect(result.exists).toBe(false) expect(result.preferences.sidebar.hiddenProjects).toEqual([]) + expect(result.preferences.profile.displayName).toBe('cc-haha') expect(files.some((name) => name.startsWith('desktop-ui.json.invalid-'))).toBe(true) }) + + test('normalizes and persists profile preferences without touching sidebar preferences', async () => { + const service = new DesktopUiPreferencesService() + const after = await service.updateProfilePreferences({ + displayName: ' Claude Captain ', + avatarFile: '../escape.png', + avatarUpdatedAt: 42, + }) + + expect(after).toEqual({ + schemaVersion: 2, + profile: { + displayName: 'Claude Captain', + avatarFile: null, + avatarUpdatedAt: null, + }, + sidebar: { + projectOrder: [], + pinnedProjects: [], + hiddenProjects: [], + projectOrganization: 'recentProject', + projectSortBy: 'updatedAt', + }, + }) + expect(await readDesktopUiFile()).toEqual(after) + }) + + test('stores uploaded profile avatars under cc-haha profile storage', async () => { + const service = new DesktopUiPreferencesService() + const after = await service.updateProfileAvatar(new Uint8Array([137, 80, 78, 71]), 'image/png') + + expect(after.profile.avatarFile).toBe('profile/avatar.png') + expect(typeof after.profile.avatarUpdatedAt).toBe('string') + + const avatar = await fs.readFile(path.join(tmpDir, 'cc-haha', 'profile', 'avatar.png')) + expect([...avatar]).toEqual([137, 80, 78, 71]) + }) + + test('clears only managed profile avatar files', async () => { + const service = new DesktopUiPreferencesService() + await service.updateProfileAvatar(new Uint8Array([137, 80, 78, 71]), 'image/png') + await fs.writeFile(path.join(tmpDir, 'cc-haha', 'profile', 'local-note.txt'), 'keep me', 'utf-8') + + const after = await service.clearProfileAvatar() + + expect(after.profile.avatarFile).toBeNull() + expect(after.profile.avatarUpdatedAt).toBeNull() + await expect(fs.readFile(path.join(tmpDir, 'cc-haha', 'profile', 'avatar.png'))).rejects.toThrow() + await expect(fs.readFile(path.join(tmpDir, 'cc-haha', 'profile', 'local-note.txt'), 'utf-8')).resolves.toBe('keep me') + }) + + test('rejects unsupported or oversized profile avatars', async () => { + const service = new DesktopUiPreferencesService() + + await expect(service.updateProfileAvatar(new Uint8Array([1, 2, 3]), 'image/gif')).rejects.toThrow('Unsupported avatar type') + await expect(service.updateProfileAvatar(new Uint8Array(2_000_001), 'image/png')).rejects.toThrow('Avatar image is too large') + }) }) describe('desktop UI preferences API', () => { @@ -149,7 +223,12 @@ describe('desktop UI preferences API', () => { expect(putBody).toEqual({ ok: true, preferences: { - schemaVersion: 1, + schemaVersion: 2, + profile: { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, + }, sidebar: { projectOrder: ['/workspace/beta', '/workspace/alpha'], pinnedProjects: ['/workspace/beta'], @@ -168,7 +247,12 @@ describe('desktop UI preferences API', () => { expect(getBody).toEqual({ exists: true, preferences: { - schemaVersion: 1, + schemaVersion: 2, + profile: { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, + }, sidebar: { projectOrder: ['/workspace/beta', '/workspace/alpha'], pinnedProjects: ['/workspace/beta'], @@ -179,4 +263,138 @@ 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 ', + }) + const profileRes = await handleDesktopUiApi(profileReq.req, profileReq.url, profileReq.segments) + const profileBody = await profileRes.json() as Record + + expect(profileRes.status).toBe(200) + expect(profileBody).toMatchObject({ + ok: true, + preferences: { + profile: { + displayName: 'Local Operator', + avatarFile: null, + }, + }, + }) + + const avatarReq = makeRequest( + 'PUT', + '/api/desktop-ui/preferences/profile/avatar', + new Uint8Array([255, 216, 255]), + 'image/jpeg', + ) + const avatarRes = await handleDesktopUiApi(avatarReq.req, avatarReq.url, avatarReq.segments) + const avatarBody = await avatarRes.json() as Record + + expect(avatarRes.status).toBe(200) + expect(avatarBody).toMatchObject({ + ok: true, + preferences: { + profile: { + displayName: 'Local Operator', + avatarFile: 'profile/avatar.jpg', + }, + }, + }) + + const getAvatarReq = makeRequest('GET', '/api/desktop-ui/preferences/profile/avatar') + const getAvatarRes = await handleDesktopUiApi(getAvatarReq.req, getAvatarReq.url, getAvatarReq.segments) + + expect(getAvatarRes.status).toBe(200) + expect(getAvatarRes.headers.get('Content-Type')).toBe('image/jpeg') + expect([...new Uint8Array(await getAvatarRes.arrayBuffer())]).toEqual([255, 216, 255]) + }) + + test('returns API errors for missing avatars, invalid JSON, and unknown routes', async () => { + const missingAvatarReq = makeRequest('GET', '/api/desktop-ui/preferences/profile/avatar') + const missingAvatarRes = await handleDesktopUiApi( + missingAvatarReq.req, + missingAvatarReq.url, + missingAvatarReq.segments, + ) + expect(missingAvatarRes.status).toBe(404) + await expect(missingAvatarRes.json()).resolves.toMatchObject({ + error: 'NOT_FOUND', + message: 'Profile avatar is not configured', + }) + + const invalidUrl = new URL('/api/desktop-ui/preferences/profile', 'http://localhost:3456') + const invalidReq = new Request(invalidUrl.toString(), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: '{bad json', + }) + const invalidRes = await handleDesktopUiApi( + invalidReq, + invalidUrl, + invalidUrl.pathname.split('/').filter(Boolean), + ) + expect(invalidRes.status).toBe(400) + await expect(invalidRes.json()).resolves.toMatchObject({ + error: 'BAD_REQUEST', + message: 'Invalid JSON body', + }) + + const unknownTopReq = makeRequest('GET', '/api/desktop-ui/unknown') + const unknownTopRes = await handleDesktopUiApi(unknownTopReq.req, unknownTopReq.url, unknownTopReq.segments) + expect(unknownTopRes.status).toBe(404) + + const unknownProfileReq = makeRequest('GET', '/api/desktop-ui/preferences/profile/banner') + const unknownProfileRes = await handleDesktopUiApi( + unknownProfileReq.req, + unknownProfileReq.url, + unknownProfileReq.segments, + ) + expect(unknownProfileRes.status).toBe(404) + + const unknownPreferenceReq = makeRequest('GET', '/api/desktop-ui/preferences/theme') + const unknownPreferenceRes = await handleDesktopUiApi( + unknownPreferenceReq.req, + unknownPreferenceReq.url, + unknownPreferenceReq.segments, + ) + expect(unknownPreferenceRes.status).toBe(404) + }) + + test('clears profile avatars and rejects unsupported avatar methods through the API', async () => { + const putReq = makeRequest( + 'PUT', + '/api/desktop-ui/preferences/profile/avatar', + new Uint8Array([82, 73, 70, 70]), + 'image/webp', + ) + const putRes = await handleDesktopUiApi(putReq.req, putReq.url, putReq.segments) + expect(putRes.status).toBe(200) + + const deleteReq = makeRequest('DELETE', '/api/desktop-ui/preferences/profile/avatar') + const deleteRes = await handleDesktopUiApi(deleteReq.req, deleteReq.url, deleteReq.segments) + const deleteBody = await deleteRes.json() as Record + expect(deleteRes.status).toBe(200) + expect(deleteBody).toMatchObject({ + ok: true, + preferences: { + profile: { + avatarFile: null, + avatarUpdatedAt: null, + }, + }, + }) + + const getAvatarReq = makeRequest('GET', '/api/desktop-ui/preferences/profile/avatar') + const getAvatarRes = await handleDesktopUiApi(getAvatarReq.req, getAvatarReq.url, getAvatarReq.segments) + expect(getAvatarRes.status).toBe(404) + + const patchReq = makeRequest('PATCH', '/api/desktop-ui/preferences/profile/avatar') + const patchRes = await handleDesktopUiApi(patchReq.req, patchReq.url, patchReq.segments) + expect(patchRes.status).toBe(405) + await expect(patchRes.json()).resolves.toMatchObject({ + error: 'METHOD_NOT_ALLOWED', + message: 'Method PATCH not allowed', + }) + }) }) diff --git a/src/server/api/desktop-ui.ts b/src/server/api/desktop-ui.ts index 0d90573f..953cacdf 100644 --- a/src/server/api/desktop-ui.ts +++ b/src/server/api/desktop-ui.ts @@ -3,6 +3,10 @@ * * GET /api/desktop-ui/preferences — read cc-haha UI preferences * PUT /api/desktop-ui/preferences/sidebar — persist sidebar project preferences + * PUT /api/desktop-ui/preferences/profile — persist local profile preferences + * GET /api/desktop-ui/preferences/profile/avatar — read local profile avatar + * PUT /api/desktop-ui/preferences/profile/avatar — persist local profile avatar + * DELETE /api/desktop-ui/preferences/profile/avatar — reset local profile avatar */ import { ApiError, errorResponse } from '../middleware/errorHandler.js' @@ -39,6 +43,56 @@ export async function handleDesktopUiApi( }) } + if (detail === 'profile') { + const action = segments[4] + + if (action === undefined) { + if (req.method !== 'PUT') throw methodNotAllowed(req.method) + const body = await parseJsonBody(req) + return Response.json({ + ok: true, + preferences: await desktopUiPreferencesService.updateProfilePreferences(body), + }) + } + + if (action === 'avatar') { + if (req.method === 'GET') { + const avatar = await desktopUiPreferencesService.readProfileAvatar() + if (!avatar) { + throw ApiError.notFound('Profile avatar is not configured') + } + return new Response(avatar.bytes, { + headers: { + 'Content-Type': avatar.contentType, + 'Cache-Control': 'no-store', + }, + }) + } + + if (req.method === 'PUT') { + const bytes = new Uint8Array(await req.arrayBuffer()) + return Response.json({ + ok: true, + preferences: await desktopUiPreferencesService.updateProfileAvatar( + bytes, + req.headers.get('Content-Type'), + ), + }) + } + + if (req.method === 'DELETE') { + return Response.json({ + ok: true, + preferences: await desktopUiPreferencesService.clearProfileAvatar(), + }) + } + + throw methodNotAllowed(req.method) + } + + throw ApiError.notFound(`Unknown desktop UI profile endpoint: ${action}`) + } + throw ApiError.notFound(`Unknown desktop UI preferences endpoint: ${detail}`) } catch (error) { return errorResponse(error) diff --git a/src/server/services/desktopUiPreferencesService.ts b/src/server/services/desktopUiPreferencesService.ts index 6091e686..07b1bdb5 100644 --- a/src/server/services/desktopUiPreferencesService.ts +++ b/src/server/services/desktopUiPreferencesService.ts @@ -6,8 +6,16 @@ import { ApiError } from '../middleware/errorHandler.js' import { readRecoverableJsonFile } from './recoverableJsonFile.js' import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js' -const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 1 +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_AVATAR_BYTES = 2_000_000 + +const AVATAR_CONTENT_TYPES = { + 'image/png': { extension: 'png', mediaType: 'image/png' }, + 'image/jpeg': { extension: 'jpg', mediaType: 'image/jpeg' }, + 'image/webp': { extension: 'webp', mediaType: 'image/webp' }, +} as const export type SidebarProjectPreferences = { projectOrder: string[] @@ -17,9 +25,16 @@ export type SidebarProjectPreferences = { projectSortBy: 'createdAt' | 'updatedAt' } +export type DesktopProfilePreferences = { + displayName: string + avatarFile: string | null + avatarUpdatedAt: string | null +} + export type DesktopUiPreferences = { schemaVersion: number sidebar: SidebarProjectPreferences + profile: DesktopProfilePreferences [key: string]: unknown } @@ -36,10 +51,17 @@ const DEFAULT_SIDEBAR_PROJECT_PREFERENCES: SidebarProjectPreferences = { projectSortBy: 'updatedAt', } +const DEFAULT_PROFILE_PREFERENCES: DesktopProfilePreferences = { + displayName: 'cc-haha', + avatarFile: null, + avatarUpdatedAt: null, +} + function defaultPreferences(): DesktopUiPreferences { return { schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION, sidebar: { ...DEFAULT_SIDEBAR_PROJECT_PREFERENCES }, + profile: { ...DEFAULT_PROFILE_PREFERENCES }, } } @@ -73,6 +95,32 @@ export function normalizeSidebarProjectPreferences(value: unknown): SidebarProje } } +function normalizeProfileDisplayName(value: unknown): string { + if (typeof value !== 'string') return DEFAULT_PROFILE_PREFERENCES.displayName + const trimmed = value.trim().replace(/\s+/g, ' ') + if (trimmed.length === 0) return DEFAULT_PROFILE_PREFERENCES.displayName + return trimmed.slice(0, MAX_PROFILE_DISPLAY_NAME_LENGTH) +} + +function normalizeAvatarFile(value: unknown): string | null { + if (typeof value !== 'string') return null + if (!/^profile\/avatar\.(png|jpg|webp)$/.test(value)) return null + return value +} + +function normalizeProfilePreferences(value: unknown): DesktopProfilePreferences { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { ...DEFAULT_PROFILE_PREFERENCES } + } + + const record = value as Record + return { + displayName: normalizeProfileDisplayName(record.displayName), + avatarFile: normalizeAvatarFile(record.avatarFile), + avatarUpdatedAt: typeof record.avatarUpdatedAt === 'string' ? record.avatarUpdatedAt : null, + } +} + function normalizeProjectOrganization(value: unknown): SidebarProjectPreferences['projectOrganization'] { return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject' } @@ -91,6 +139,7 @@ function normalizeDesktopUiPreferences(value: unknown): DesktopUiPreferences | n ...record, schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION, sidebar: normalizeSidebarProjectPreferences(record.sidebar), + profile: normalizeProfilePreferences(record.profile), } } @@ -111,6 +160,18 @@ export class DesktopUiPreferencesService { return path.join(this.getConfigDir(), 'cc-haha', 'desktop-ui.json') } + private getProfileDir(): string { + return path.join(this.getConfigDir(), 'cc-haha', 'profile') + } + + private getProfileAvatarPath(avatarFile: string): string { + const normalized = normalizeAvatarFile(avatarFile) + if (!normalized) { + throw ApiError.badRequest('Invalid avatar file path') + } + return path.join(this.getConfigDir(), 'cc-haha', normalized) + } + private async fileExists(filePath: string): Promise { try { await fs.access(filePath) @@ -183,10 +244,135 @@ export class DesktopUiPreferencesService { ...preferences, schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION, sidebar: normalizeSidebarProjectPreferences(sidebar), + profile: normalizeProfilePreferences(preferences.profile), } await this.writePreferences(nextPreferences) return nextPreferences }) } + + async updateProfilePreferences(profile: unknown): Promise { + const filePath = this.getPreferencesPath() + return this.withWriteLock(filePath, async () => { + const { preferences } = await this.readPreferences() + const currentProfile = normalizeProfilePreferences(preferences.profile) + const patch = profile && typeof profile === 'object' && !Array.isArray(profile) + ? profile as Record + : {} + const nextProfile = normalizeProfilePreferences({ + ...currentProfile, + displayName: Object.prototype.hasOwnProperty.call(patch, 'displayName') + ? patch.displayName + : currentProfile.displayName, + }) + const nextPreferences: DesktopUiPreferences = { + ...preferences, + schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION, + sidebar: normalizeSidebarProjectPreferences(preferences.sidebar), + profile: { + ...nextProfile, + avatarFile: currentProfile.avatarFile, + avatarUpdatedAt: currentProfile.avatarUpdatedAt, + }, + } + + await this.writePreferences(nextPreferences) + return nextPreferences + }) + } + + async updateProfileAvatar(bytes: Uint8Array, contentType: string | null): Promise { + const type = contentType?.split(';')[0]?.trim().toLowerCase() + const avatarType = type ? AVATAR_CONTENT_TYPES[type as keyof typeof AVATAR_CONTENT_TYPES] : undefined + if (!avatarType) { + throw ApiError.badRequest('Unsupported avatar type') + } + if (bytes.byteLength > MAX_PROFILE_AVATAR_BYTES) { + throw ApiError.badRequest('Avatar image is too large') + } + + const filePath = this.getPreferencesPath() + return this.withWriteLock(filePath, async () => { + const { preferences } = await this.readPreferences() + const profileDir = this.getProfileDir() + const avatarFile = `profile/avatar.${avatarType.extension}` + const avatarPath = this.getProfileAvatarPath(avatarFile) + const tmpFile = `${avatarPath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString('hex')}` + + await fs.mkdir(profileDir, { recursive: true }) + try { + await fs.writeFile(tmpFile, bytes) + await fs.rename(tmpFile, avatarPath) + } catch (error) { + await fs.unlink(tmpFile).catch(() => {}) + throw ApiError.internal(`Failed to write profile avatar: ${error}`) + } + + await Promise.all( + Object.values(AVATAR_CONTENT_TYPES) + .filter((candidate) => candidate.extension !== avatarType.extension) + .map((candidate) => fs.unlink(path.join(profileDir, `avatar.${candidate.extension}`)).catch(() => {})), + ) + + const nextPreferences: DesktopUiPreferences = { + ...preferences, + schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION, + sidebar: normalizeSidebarProjectPreferences(preferences.sidebar), + profile: { + ...normalizeProfilePreferences(preferences.profile), + avatarFile, + avatarUpdatedAt: new Date().toISOString(), + }, + } + + await this.writePreferences(nextPreferences) + return nextPreferences + }) + } + + async clearProfileAvatar(): Promise { + const filePath = this.getPreferencesPath() + return this.withWriteLock(filePath, async () => { + const { preferences } = await this.readPreferences() + const profileDir = this.getProfileDir() + await Promise.all( + Object.values(AVATAR_CONTENT_TYPES) + .map((candidate) => fs.unlink(path.join(profileDir, `avatar.${candidate.extension}`)).catch(() => {})), + ) + const nextPreferences: DesktopUiPreferences = { + ...preferences, + schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION, + sidebar: normalizeSidebarProjectPreferences(preferences.sidebar), + profile: { + ...normalizeProfilePreferences(preferences.profile), + avatarFile: null, + avatarUpdatedAt: null, + }, + } + + await this.writePreferences(nextPreferences) + return nextPreferences + }) + } + + async readProfileAvatar(): Promise<{ bytes: Uint8Array; contentType: string } | null> { + const { preferences } = await this.readPreferences() + const avatarFile = normalizeAvatarFile(preferences.profile.avatarFile) + if (!avatarFile) return null + + const extension = path.extname(avatarFile).slice(1) + const contentType = Object.values(AVATAR_CONTENT_TYPES).find((candidate) => candidate.extension === extension)?.mediaType + if (!contentType) return null + + try { + return { + bytes: await fs.readFile(this.getProfileAvatarPath(avatarFile)), + contentType, + } + } catch (error) { + if (errnoCode(error) === 'ENOENT') return null + throw ApiError.internal(`Failed to read profile avatar: ${error}`) + } + } }