Feature: per-viewer colors for shared calendars (#345)

This commit is contained in:
Linus Rath
2026-05-31 15:52:27 +02:00
parent 55be19ede7
commit ae66f8d89d
25 changed files with 272 additions and 32 deletions
+64 -10
View File
@@ -60,6 +60,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal"; import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { getUserParticipantId } from "@/lib/calendar-participants"; import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session"; import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
@@ -97,6 +98,9 @@ export default function CalendarPage() {
refreshAllSubscriptions, icalSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
const taskStore = useTaskStore(); const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks); const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
@@ -1030,10 +1034,47 @@ export default function CalendarPage() {
try { return t('birthday_calendar'); } catch { return 'Birthdays'; } try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
})(); })();
// Apply each shared calendar's local color override (per-viewer recolor,
// #345). The override replaces the calendar's color and wins over per-event
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
// calendars are passed through untouched.
const displayCalendars = useMemo(() => {
return calendars.map((cal) => {
if (!cal.isShared) return cal;
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
if (!override) return cal;
return { ...cal, color: override, colorIsLocalOverride: true };
});
}, [calendars, sharedCalendarColors]);
// Auto-assign a random, not-yet-used palette color to any freshly shared
// calendar so multiple shared calendars don't collide on one color. Runs
// once per calendar (guarded by the presence of an existing key), and the
// user can still overwrite it from the sidebar.
useEffect(() => {
const shared = calendars.filter((c) => c.isShared);
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
if (missing.length === 0) return;
// Seed "used" with personal calendar colors plus already-assigned shared
// overrides so the picks stay distinct from what's already on screen.
const used = new Set<string>();
for (const c of calendars) {
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
}
for (const color of Object.values(sharedCalendarColors)) {
if (color) used.add(color.toLowerCase());
}
for (const cal of missing) {
const color = pickUnusedCalendarColor(used);
used.add(color.toLowerCase());
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
}
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
const allCalendars = useMemo(() => { const allCalendars = useMemo(() => {
if (!showBirthdayCalendar) return calendars; if (!showBirthdayCalendar) return displayCalendars;
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)]; return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]); }, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
const visibleEvents = useMemo(() => { const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => { const filtered = events.filter((e) => {
@@ -1219,7 +1260,7 @@ export default function CalendarPage() {
/> />
<TaskListView <TaskListView
tasks={taskStore.tasks} tasks={taskStore.tasks}
calendars={calendars} calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter} filter={taskStore.filter}
showCompleted={taskStore.showCompleted} showCompleted={taskStore.showCompleted}
@@ -1317,8 +1358,21 @@ export default function CalendarPage() {
updateSetting('birthdayCalendarColor', color); updateSetting('birthdayCalendarColor', color);
return; return;
} }
// Shared calendars: recolor locally only (the viewer usually
// can't write the owner's calendar, and it'd recolor it for
// everyone). Personal calendars write through to the server.
const cal = allCalendars.find((c) => c.id === calendarId);
if (cal?.isShared) {
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
return;
}
updateCalendar(client, calendarId, { color }); updateCalendar(client, calendarId, { color });
} : undefined} } : undefined}
onResetColor={(cal) => {
// Drop the local override; the auto-assign effect picks a
// fresh unused color (so it never reverts to a collision).
removeSharedCalendarColor(sharedCalendarColorKey(cal));
}}
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined} onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
onCreateEvent={(cal: Calendar) => { onCreateEvent={(cal: Calendar) => {
setDefaultCalendarIdForCreate(cal.id); setDefaultCalendarIdForCreate(cal.id);
@@ -1389,7 +1443,7 @@ export default function CalendarPage() {
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile} isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined} onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={calendars} calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks} enableCalendarTasks={enableCalendarTasks}
@@ -1419,7 +1473,7 @@ export default function CalendarPage() {
<EventModal <EventModal
key={editEvent?.id ?? 'new'} key={editEvent?.id ?? 'new'}
event={editEvent} event={editEvent}
calendars={calendars} calendars={displayCalendars}
defaultDate={defaultModalDate} defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate} defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay} defaultAllDay={defaultModalAllDay}
@@ -1442,7 +1496,7 @@ export default function CalendarPage() {
<TaskModal <TaskModal
key={editTask?.id ?? 'new-task'} key={editTask?.id ?? 'new-task'}
task={editTask} task={editTask}
calendars={calendars} calendars={displayCalendars}
onSave={handleSaveTask} onSave={handleSaveTask}
onDelete={handleDeleteTask} onDelete={handleDeleteTask}
onClose={() => { setShowTaskModal(false); setEditTask(null); }} onClose={() => { setShowTaskModal(false); setEditTask(null); }}
@@ -1529,7 +1583,7 @@ export default function CalendarPage() {
{detailEvent && detailAnchorRect && ( {detailEvent && detailAnchorRect && (
<EventDetailPopover <EventDetailPopover
event={detailEvent} event={detailEvent}
calendar={calendars.find(c => detailEvent.calendarIds[c.id])} calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
anchorRect={detailAnchorRect} anchorRect={detailAnchorRect}
onEdit={handleEditFromDetail} onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail} onDelete={handleDeleteFromDetail}
@@ -1549,7 +1603,7 @@ export default function CalendarPage() {
<EventModal <EventModal
key={editEvent?.id ?? 'new'} key={editEvent?.id ?? 'new'}
event={editEvent} event={editEvent}
calendars={calendars} calendars={displayCalendars}
defaultDate={defaultModalDate} defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate} defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay} defaultAllDay={defaultModalAllDay}
@@ -1566,7 +1620,7 @@ export default function CalendarPage() {
{showImportModal && client && ( {showImportModal && client && (
<ICalImportModal <ICalImportModal
calendars={calendars} calendars={displayCalendars}
client={client} client={client}
initialUrl={pendingSubscription?.url} initialUrl={pendingSubscription?.url}
onClose={() => { onClose={() => {
+15 -2
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react"; import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -11,6 +11,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
@@ -50,6 +51,7 @@ interface CalendarSidebarPanelProps {
selectedCalendarIds: string[]; selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void; onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void; onColorChange?: (calendarId: string, color: string) => void;
onResetColor?: (calendar: Calendar) => void;
onShareCalendar?: (calendar: Calendar) => void; onShareCalendar?: (calendar: Calendar) => void;
onCreateEvent?: (calendar: Calendar) => void; onCreateEvent?: (calendar: Calendar) => void;
onClearCalendar?: (calendar: Calendar) => void; onClearCalendar?: (calendar: Calendar) => void;
@@ -71,6 +73,7 @@ export function CalendarSidebarPanel({
selectedCalendarIds, selectedCalendarIds,
onToggleVisibility, onToggleVisibility,
onColorChange, onColorChange,
onResetColor,
onShareCalendar, onShareCalendar,
onCreateEvent, onCreateEvent,
onClearCalendar, onClearCalendar,
@@ -94,6 +97,7 @@ export function CalendarSidebarPanel({
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks); const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
const tasks = useTaskStore((s) => s.tasks); const tasks = useTaskStore((s) => s.tasks);
const setViewMode = useCalendarStore((s) => s.setViewMode); const setViewMode = useCalendarStore((s) => s.setViewMode);
@@ -303,9 +307,11 @@ export function CalendarSidebarPanel({
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false; const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared; const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canChangeColor = !!onColorChange; const canChangeColor = !!onColorChange;
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
const canResetColor = !!onResetColor && hasColorOverride;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false; const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared; const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete); const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6"; const color = cal.color || "#3b82f6";
return ( return (
@@ -335,6 +341,13 @@ export function CalendarSidebarPanel({
</div> </div>
</ContextMenuSubMenu> </ContextMenuSubMenu>
)} )}
{canResetColor && (
<ContextMenuItem
icon={Shuffle}
label={tMgmt('random_color')}
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
/>
)}
{showSeparator && <ContextMenuSeparator />} {showSeparator && <ContextMenuSeparator />}
{canClear && ( {canClear && (
<ContextMenuItem <ContextMenuItem
+5
View File
@@ -34,6 +34,11 @@ function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"):
} }
function getEventColor(event: CalendarEvent, calendar?: Calendar): string { function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
// A local color override on a shared calendar wins over per-event colors,
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
if (calendar?.colorIsLocalOverride && calendar.color) {
return sanitizeColor(calendar.color);
}
return sanitizeColor(event.color, sanitizeColor(calendar?.color)); return sanitizeColor(event.color, sanitizeColor(calendar?.color));
} }
@@ -15,25 +15,7 @@ import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal'; import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
const CALENDAR_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#06b6d4", // cyan
"#84cc16", // lime
"#6366f1", // indigo
"#a855f7", // purple
"#e11d48", // rose
"#0ea5e9", // sky
"#10b981", // emerald
"#d946ef", // fuchsia
];
function CalendarColorPicker({ function CalendarColorPicker({
value, value,
@@ -182,6 +164,8 @@ export function CalendarManagementSettings() {
const tImport = useTranslations('calendar.import'); const tImport = useTranslations('calendar.import');
const tSub = useTranslations('calendar.subscription'); const tSub = useTranslations('calendar.subscription');
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const colorPickerRef = useRef<HTMLDivElement>(null); const colorPickerRef = useRef<HTMLDivElement>(null);
// Load calendars if not yet loaded // Load calendars if not yet loaded
@@ -329,6 +313,15 @@ export function CalendarManagementSettings() {
const handleColorChange = async (calendarId: string, color: string) => { const handleColorChange = async (calendarId: string, color: string) => {
if (!client) return; if (!client) return;
// Shared calendars: recolor locally only - the viewer usually can't write
// the owner's calendar and it'd recolor it for everyone (see #345).
const cal = calendars.find((c) => c.id === calendarId);
if (cal?.isShared) {
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
toast.success(t('color_updated'));
setColorPickerId(null);
return;
}
try { try {
await updateCalendar(client, calendarId, { color }); await updateCalendar(client, calendarId, { color });
toast.success(t('color_updated')); toast.success(t('color_updated'));
@@ -396,7 +389,7 @@ export function CalendarManagementSettings() {
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2"> <div className="space-y-2">
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => { {calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
const color = cal.color || '#3b82f6'; const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
if (editingId === cal.id) { if (editingId === cal.id) {
return ( return (
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import type { Calendar } from '@/lib/jmap/types';
import {
CALENDAR_COLORS,
sharedCalendarColorKey,
pickUnusedCalendarColor,
} from '../shared-calendar-colors';
function makeCal(overrides: Partial<Calendar>): Calendar {
return {
id: 'cal-1',
name: 'Cal',
description: null,
color: null,
sortOrder: 0,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'all',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: {} as Calendar['myRights'],
...overrides,
};
}
describe('sharedCalendarColorKey', () => {
it('is built from local account, JMAP account, and the original id', () => {
const cal = makeCal({
id: 'acct-9:cal-7',
originalId: 'cal-7',
accountId: 'acct-9',
localAccountId: 'slot-2',
});
expect(sharedCalendarColorKey(cal)).toBe('slot-2|acct-9|cal-7');
});
it('is stable regardless of the Pro-shell id prefix', () => {
// Same underlying calendar, shown once under the active account (bare id)
// and once cross-account (prefixed id) - both must map to one key.
const active = makeCal({ id: 'acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
const prefixed = makeCal({ id: 'slot-2::acct-9:cal-7', originalId: 'cal-7', accountId: 'acct-9', localAccountId: 'slot-2' });
expect(sharedCalendarColorKey(active)).toBe(sharedCalendarColorKey(prefixed));
});
it('falls back to the id when originalId is absent', () => {
const cal = makeCal({ id: 'cal-7', accountId: 'acct-9' });
expect(sharedCalendarColorKey(cal)).toBe('|acct-9|cal-7');
});
});
describe('pickUnusedCalendarColor', () => {
it('returns a palette color not present in usedColors', () => {
const used = CALENDAR_COLORS.slice(0, CALENDAR_COLORS.length - 1);
const picked = pickUnusedCalendarColor(used);
expect(picked).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
});
it('ignores case when comparing used colors', () => {
const used = CALENDAR_COLORS.slice(0, -1).map((c) => c.toUpperCase());
expect(pickUnusedCalendarColor(used)).toBe(CALENDAR_COLORS[CALENDAR_COLORS.length - 1]);
});
it('still returns a palette color once every color is taken', () => {
expect(CALENDAR_COLORS).toContain(pickUnusedCalendarColor(CALENDAR_COLORS));
});
it('always returns a valid palette color for a small used set', () => {
for (let i = 0; i < 50; i++) {
const picked = pickUnusedCalendarColor(['#3b82f6']);
expect(CALENDAR_COLORS).toContain(picked);
expect(picked).not.toBe('#3b82f6');
}
});
});
+4
View File
@@ -488,6 +488,10 @@ export interface Calendar {
// can route mutations to the right client. Distinct from `accountId` // can route mutations to the right client. Distinct from `accountId`
// which is the JMAP server's own account UUID. // which is the JMAP server's own account UUID.
localAccountId?: string; localAccountId?: string;
// Set when `color` has been replaced by the viewer's local override for a
// shared calendar (see lib/shared-calendar-colors). When true, the override
// wins over per-event colors so the whole shared calendar paints uniformly.
colorIsLocalOverride?: boolean;
} }
export interface CalendarRights { export interface CalendarRights {
+55
View File
@@ -0,0 +1,55 @@
import type { Calendar } from '@/lib/jmap/types';
/**
* Palette of calendar colors offered in the color picker. Defined here (rather
* than in the settings UI component) so non-React modules can reuse it without
* pulling in component code. The settings color picker re-exports this.
*/
export const CALENDAR_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#06b6d4", // cyan
"#84cc16", // lime
"#6366f1", // indigo
"#a855f7", // purple
"#e11d48", // rose
"#0ea5e9", // sky
"#10b981", // emerald
"#d946ef", // fuchsia
];
/**
* Stable key for a shared calendar's local color override. Independent of the
* Pro-shell id prefix (which changes with the active account), so the override
* survives shell-mode and account switches. Built from the owning JMAP account
* + the calendar's original server id.
*/
export function sharedCalendarColorKey(
cal: Pick<Calendar, 'id' | 'originalId' | 'accountId' | 'localAccountId'>,
): string {
const localAccount = cal.localAccountId ?? '';
const account = cal.accountId ?? '';
const id = cal.originalId ?? cal.id;
return `${localAccount}|${account}|${id}`;
}
/**
* Pick a random palette color not present in `usedColors`. Once every palette
* entry is taken, fall back to a random palette color (collisions are
* unavoidable past CALENDAR_COLORS.length calendars).
*/
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
const used = new Set<string>();
for (const c of usedColors) {
if (c) used.add(c.toLowerCase());
}
const available = CALENDAR_COLORS.filter((c) => !used.has(c.toLowerCase()));
const pool = available.length > 0 ? available : CALENDAR_COLORS;
return pool[Math.floor(Math.random() * pool.length)];
}
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "Název kalendáře", "name_placeholder": "Název kalendáře",
"color": "Barva", "color": "Barva",
"change_color": "Změnit barvu", "change_color": "Změnit barvu",
"random_color": "Nová náhodná barva",
"add_calendar": "Přidat kalendář", "add_calendar": "Přidat kalendář",
"edit": "Upravit", "edit": "Upravit",
"delete": "Odstranit", "delete": "Odstranit",
+1
View File
@@ -2656,6 +2656,7 @@
"name_placeholder": "Kalendernavn", "name_placeholder": "Kalendernavn",
"color": "Farve", "color": "Farve",
"change_color": "Skift farve", "change_color": "Skift farve",
"random_color": "Ny tilfældig farve",
"add_calendar": "Tilføj kalender", "add_calendar": "Tilføj kalender",
"edit": "Redigér", "edit": "Redigér",
"delete": "Slet", "delete": "Slet",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "Kalendername", "name_placeholder": "Kalendername",
"color": "Farbe", "color": "Farbe",
"change_color": "Farbe ändern", "change_color": "Farbe ändern",
"random_color": "Neue zufällige Farbe",
"add_calendar": "Kalender hinzufügen", "add_calendar": "Kalender hinzufügen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"delete": "Löschen", "delete": "Löschen",
+1
View File
@@ -2657,6 +2657,7 @@
"name_placeholder": "Calendar name", "name_placeholder": "Calendar name",
"color": "Color", "color": "Color",
"change_color": "Change color", "change_color": "Change color",
"random_color": "New random color",
"add_calendar": "Add calendar", "add_calendar": "Add calendar",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "Nombre del calendario", "name_placeholder": "Nombre del calendario",
"color": "Color", "color": "Color",
"change_color": "Cambiar color", "change_color": "Cambiar color",
"random_color": "Nuevo color aleatorio",
"add_calendar": "Añadir calendario", "add_calendar": "Añadir calendario",
"edit": "Editar", "edit": "Editar",
"delete": "Eliminar", "delete": "Eliminar",
+1
View File
@@ -2656,6 +2656,7 @@
"name_placeholder": "Nom du calendrier", "name_placeholder": "Nom du calendrier",
"color": "Couleur", "color": "Couleur",
"change_color": "Changer la couleur", "change_color": "Changer la couleur",
"random_color": "Nouvelle couleur aléatoire",
"add_calendar": "Ajouter un calendrier", "add_calendar": "Ajouter un calendrier",
"edit": "Modifier", "edit": "Modifier",
"delete": "Supprimer", "delete": "Supprimer",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "Nome del calendario", "name_placeholder": "Nome del calendario",
"color": "Colore", "color": "Colore",
"change_color": "Cambia colore", "change_color": "Cambia colore",
"random_color": "Nuovo colore casuale",
"add_calendar": "Aggiungi calendario", "add_calendar": "Aggiungi calendario",
"edit": "Modifica", "edit": "Modifica",
"delete": "Elimina", "delete": "Elimina",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "カレンダー名", "name_placeholder": "カレンダー名",
"color": "色", "color": "色",
"change_color": "色を変更", "change_color": "色を変更",
"random_color": "新しいランダムな色",
"add_calendar": "カレンダーを追加", "add_calendar": "カレンダーを追加",
"edit": "編集", "edit": "編集",
"delete": "削除", "delete": "削除",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "캘린더 이름", "name_placeholder": "캘린더 이름",
"color": "색상", "color": "색상",
"change_color": "색상 변경", "change_color": "색상 변경",
"random_color": "새 무작위 색상",
"add_calendar": "캘린더 추가", "add_calendar": "캘린더 추가",
"edit": "수정", "edit": "수정",
"delete": "삭제", "delete": "삭제",
+1
View File
@@ -2641,6 +2641,7 @@
"name_placeholder": "Kalendāra nosaukums", "name_placeholder": "Kalendāra nosaukums",
"color": "Krāsa", "color": "Krāsa",
"change_color": "Mainīt krāsu", "change_color": "Mainīt krāsu",
"random_color": "Jauna nejauša krāsa",
"add_calendar": "Pievienot kalendāru", "add_calendar": "Pievienot kalendāru",
"edit": "Rediģēt", "edit": "Rediģēt",
"delete": "Dzēst", "delete": "Dzēst",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "Agendanaam", "name_placeholder": "Agendanaam",
"color": "Kleur", "color": "Kleur",
"change_color": "Kleur wijzigen", "change_color": "Kleur wijzigen",
"random_color": "Nieuwe willekeurige kleur",
"add_calendar": "Agenda toevoegen", "add_calendar": "Agenda toevoegen",
"edit": "Bewerken", "edit": "Bewerken",
"delete": "Verwijderen", "delete": "Verwijderen",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "Nazwa kalendarza", "name_placeholder": "Nazwa kalendarza",
"color": "Kolor", "color": "Kolor",
"change_color": "Zmień kolor", "change_color": "Zmień kolor",
"random_color": "Nowy losowy kolor",
"add_calendar": "Dodaj kalendarz", "add_calendar": "Dodaj kalendarz",
"edit": "Edytuj", "edit": "Edytuj",
"delete": "Usuń", "delete": "Usuń",
+1
View File
@@ -2656,6 +2656,7 @@
"name_placeholder": "Nome do calendário", "name_placeholder": "Nome do calendário",
"color": "Cor", "color": "Cor",
"change_color": "Alterar cor", "change_color": "Alterar cor",
"random_color": "Nova cor aleatória",
"add_calendar": "Adicionar calendário", "add_calendar": "Adicionar calendário",
"edit": "Editar", "edit": "Editar",
"delete": "Excluir", "delete": "Excluir",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "Название календаря", "name_placeholder": "Название календаря",
"color": "Цвет", "color": "Цвет",
"change_color": "Изменить цвет", "change_color": "Изменить цвет",
"random_color": "Новый случайный цвет",
"add_calendar": "Добавить календарь", "add_calendar": "Добавить календарь",
"edit": "Редактировать", "edit": "Редактировать",
"delete": "Удалить", "delete": "Удалить",
+1
View File
@@ -2656,6 +2656,7 @@
"name_placeholder": "Takvim adı", "name_placeholder": "Takvim adı",
"color": "Renk", "color": "Renk",
"change_color": "Rengi değiştir", "change_color": "Rengi değiştir",
"random_color": "Yeni rastgele renk",
"add_calendar": "Takvim ekle", "add_calendar": "Takvim ekle",
"edit": "Düzenle", "edit": "Düzenle",
"delete": "Sil", "delete": "Sil",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "Назва календаря", "name_placeholder": "Назва календаря",
"color": "колір", "color": "колір",
"change_color": "Змінити колір", "change_color": "Змінити колір",
"random_color": "Новий випадковий колір",
"add_calendar": "Додати календар", "add_calendar": "Додати календар",
"edit": "Редагувати", "edit": "Редагувати",
"delete": "Видалити", "delete": "Видалити",
+1
View File
@@ -2642,6 +2642,7 @@
"name_placeholder": "日历名称", "name_placeholder": "日历名称",
"color": "颜色", "color": "颜色",
"change_color": "更改颜色", "change_color": "更改颜色",
"random_color": "随机新颜色",
"add_calendar": "添加日历", "add_calendar": "添加日历",
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
+22
View File
@@ -183,6 +183,11 @@ interface SettingsState {
showBirthdayCalendar: boolean; showBirthdayCalendar: boolean;
birthdayCalendarColor: string; birthdayCalendarColor: string;
// Per-viewer color overrides for shared calendars, keyed by
// sharedCalendarColorKey(). Lets each recipient recolor calendars shared
// with them without changing the owner's color (see #345).
sharedCalendarColors: Record<string, string>;
// Contacts Display // Contacts Display
groupContactsByLetter: boolean; groupContactsByLetter: boolean;
@@ -274,6 +279,10 @@ interface SettingsState {
setFolderIcon: (mailboxId: string, icon: string) => void; setFolderIcon: (mailboxId: string, icon: string) => void;
removeFolderIcon: (mailboxId: string) => void; removeFolderIcon: (mailboxId: string) => void;
// Shared-calendar color overrides
setSharedCalendarColor: (key: string, color: string) => void;
removeSharedCalendarColor: (key: string) => void;
// Trusted senders // Trusted senders
addTrustedSender: (email: string) => void; addTrustedSender: (email: string) => void;
removeTrustedSender: (email: string) => void; removeTrustedSender: (email: string) => void;
@@ -360,6 +369,8 @@ const DEFAULT_SETTINGS = {
showBirthdayCalendar: false, showBirthdayCalendar: false,
birthdayCalendarColor: '#eab308', birthdayCalendarColor: '#eab308',
sharedCalendarColors: {} as Record<string, string>,
// Contacts Display // Contacts Display
groupContactsByLetter: true, groupContactsByLetter: true,
@@ -546,6 +557,7 @@ export const useSettingsStore = create<SettingsState>()(
showTasksOnCalendar: state.showTasksOnCalendar, showTasksOnCalendar: state.showTasksOnCalendar,
showBirthdayCalendar: state.showBirthdayCalendar, showBirthdayCalendar: state.showBirthdayCalendar,
birthdayCalendarColor: state.birthdayCalendarColor, birthdayCalendarColor: state.birthdayCalendarColor,
sharedCalendarColors: state.sharedCalendarColors,
groupContactsByLetter: state.groupContactsByLetter, groupContactsByLetter: state.groupContactsByLetter,
expandedFilterView: state.expandedFilterView, expandedFilterView: state.expandedFilterView,
showTimeInMonthView: state.showTimeInMonthView, showTimeInMonthView: state.showTimeInMonthView,
@@ -642,6 +654,16 @@ export const useSettingsStore = create<SettingsState>()(
set({ folderIcons: rest }); set({ folderIcons: rest });
}, },
// Shared-calendar color override methods
setSharedCalendarColor: (key: string, color: string) => {
set({ sharedCalendarColors: { ...get().sharedCalendarColors, [key]: color } });
},
removeSharedCalendarColor: (key: string) => {
const { [key]: _, ...rest } = get().sharedCalendarColors;
set({ sharedCalendarColors: rest });
},
// Trusted senders methods // Trusted senders methods
addTrustedSender: (email: string) => { addTrustedSender: (email: string) => {
const normalizedEmail = email.toLowerCase().trim(); const normalizedEmail = email.toLowerCase().trim();