diff --git a/components/calendar/__tests__/calendar-sidebar-shares.test.tsx b/components/calendar/__tests__/calendar-sidebar-shares.test.tsx new file mode 100644 index 000000000..ea5d0b789 --- /dev/null +++ b/components/calendar/__tests__/calendar-sidebar-shares.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar, Principal } from '@/lib/jmap/types'; + +// Show interpolated values, so the tests can read whom a calendar is shared with. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key} ${Object.values(values).join(' ')}` : key, + useLocale: () => 'en', +})); + +function cal(overrides: Partial): Calendar { + return { + id: 'c', name: 'Cal', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true, + isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null, + myRights: { mayWriteOwn: true, mayShare: true, mayDelete: true }, + ...overrides, + } as unknown as Calendar; +} + +const rights = { mayReadItems: true }; + +function clientWith(principals: Principal[]): IJMAPClient { + return { + getAccountId: () => 'acct', + getPrincipals: vi.fn().mockResolvedValue(principals), + } as unknown as IJMAPClient; +} + +describe('CalendarSidebarPanel shared calendars', () => { + it('names whom a calendar is shared with', async () => { + const client = clientWith([ + { id: 'g', type: 'individual', name: 'bot@example.com', email: 'bot@example.com', description: 'Booking robot' }, + { id: 'h', type: 'individual', name: 'alice', email: 'alice@example.com', description: null }, + ]); + render( + {}} + client={client} + />, + ); + + await waitFor(() => { + expect(screen.getByTestId('calendar-shared-indicator')).toHaveAttribute( + 'title', + 'shared_with bot@example.com (Booking robot), alice@example.com', + ); + }); + }); + + it('falls back to a count while a recipient is unknown', async () => { + const client = clientWith([]); + render( + {}} + client={client} + />, + ); + + await waitFor(() => expect(client.getPrincipals).toHaveBeenCalled()); + expect(screen.getByTestId('calendar-shared-indicator')).toHaveAttribute('title', 'shared_with_count 1'); + }); + + it('fetches no principals when nothing is shared', () => { + const client = clientWith([]); + render( + {}} + client={client} + />, + ); + expect(client.getPrincipals).not.toHaveBeenCalled(); + expect(screen.queryByTestId('calendar-shared-indicator')).toBeNull(); + }); +}); diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index e6b798f45..d4f22f94b 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -18,6 +18,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { displayNow } from "@/lib/timezone"; import { readableTextOn } from "@/lib/color-transform"; +import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels"; /** * Split a per-account calendar list into "owned" (the user's own) and @@ -139,6 +140,20 @@ export function CalendarSidebarPanel({ const eventCalendars = useMemo(() => calendars.filter(c => !c.isTasksOnly), [calendars]); const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared), [eventCalendars]); + + // Names for the "shared with" hint. Principal ids belong to one server, so + // only calendars of the account `client` talks to are resolved. + const resolvesShares = (cal: Calendar) => !cal.localAccountId || cal.localAccountId === activeLocalAccountId; + const hasSharedCalendar = eventCalendars.some( + (c) => !c.isShared && Object.keys(c.shareWith ?? {}).length > 0 && resolvesShares(c), + ); + const shareLabels = useShareRecipientLabels(client, hasSharedCalendar); + const sharedWithLabel = (cal: Calendar) => { + const names = resolvesShares(cal) ? shareRecipientNames(cal.shareWith, shareLabels) : null; + return names + ? tMgmt('shared_with', { names: names.join(', ') }) + : tMgmt('shared_with_count', { count: Object.keys(cal.shareWith ?? {}).length }); + }; const sharedAccountGroups = useMemo(() => { const shared = eventCalendars.filter(c => c.isShared); const groups = new Map(); @@ -286,12 +301,15 @@ export function CalendarSidebarPanel({ {cal.id === BIRTHDAY_CALENDAR_ID && ( )} - {!cal.isShared && Object.keys(cal.shareWith || {}).length > 0 && ( - - )} + {!cal.isShared && Object.keys(cal.shareWith || {}).length > 0 && (() => { + const label = sharedWithLabel(cal); + return ( + + + ); + })()} {hasMenu && (