mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1d1e84666 | ||
|
|
55484453a1 | ||
|
|
b525c52870 | ||
|
|
b64afb462f | ||
|
|
b92f85a236 | ||
|
|
643d704b74 | ||
|
|
9e383c3616 | ||
|
|
1543db49d1 | ||
|
|
910e5418e9 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.53",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.53",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.53",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
+14
-2
@@ -89,7 +89,18 @@ export async function getCalendars(): Promise<Calendar[]> {
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
|
||||
return list.map((cal) => ({ ...cal, accountId: sharedAccountId, isShared: true }));
|
||||
// JMAP calendar ids are only unique within an account, so a shared
|
||||
// "default" calendar collides with the user's own "default". Namespace
|
||||
// the id (keeping the real id in originalId) so visibility toggles,
|
||||
// event-query routing, and create/default mutations don't cross wires.
|
||||
// Mirrors webmail's getAllCalendars.
|
||||
return list.map((cal) => ({
|
||||
...cal,
|
||||
id: `${sharedAccountId}:${cal.id}`,
|
||||
originalId: cal.id,
|
||||
accountId: sharedAccountId,
|
||||
isShared: true,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -189,9 +200,10 @@ export async function createEvent(
|
||||
export async function batchCreateEvents(
|
||||
events: Partial<CalendarEvent>[],
|
||||
calendarId: string,
|
||||
targetAccountId?: string,
|
||||
): Promise<number> {
|
||||
if (events.length === 0) return 0;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const create: Record<string, Partial<CalendarEvent>> = {};
|
||||
events.forEach((e, i) => {
|
||||
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
|
||||
|
||||
@@ -455,6 +455,9 @@ export interface CalendarEvent {
|
||||
excludedRecurrenceRules?: RecurrenceRule[];
|
||||
recurrenceId?: string;
|
||||
originalId?: string;
|
||||
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept
|
||||
// when `calendarIds` is remapped to namespaced store ids for shared events.
|
||||
originalCalendarIds?: Record<string, boolean>;
|
||||
useDefaultAlerts?: boolean;
|
||||
alerts?: Record<string, Alert>;
|
||||
locations?: Record<string, EventLocation>;
|
||||
@@ -474,6 +477,8 @@ export interface CalendarEvent {
|
||||
// primary account; set for events on calendars shared with the user so
|
||||
// mutations can be routed to the owning account.
|
||||
accountId?: string;
|
||||
// Client-only: true when the event lives on a calendar shared with the user.
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
@@ -502,6 +507,10 @@ export interface Calendar {
|
||||
// Client-only: JMAP account the calendar was fetched from. Absent for the
|
||||
// primary account; set for calendars shared with the user.
|
||||
accountId?: string;
|
||||
// Client-only: the real JMAP id of a shared calendar, before its `id` was
|
||||
// namespaced as `${accountId}:${id}` to stay unique in the store. Mutations
|
||||
// and event queries must use this raw id against the owning account.
|
||||
originalId?: string;
|
||||
// Client-only: true when the calendar lives in another account (shared
|
||||
// with the user by its owner).
|
||||
isShared?: boolean;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react';
|
||||
import { SectionList, StyleSheet, Text, View } from 'react-native';
|
||||
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
|
||||
import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns';
|
||||
import { CalendarDays } from 'lucide-react-native';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
dayKey,
|
||||
eventsOnDayFromIndex,
|
||||
getEventStartDate,
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { EventCard } from './EventCard';
|
||||
|
||||
@@ -19,6 +20,7 @@ interface AgendaViewProps {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
eventsByDay?: EventDayIndex;
|
||||
timeFormat?: TimeFormat;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ export function AgendaView({
|
||||
events,
|
||||
calendars,
|
||||
eventsByDay,
|
||||
timeFormat,
|
||||
onSelectEvent,
|
||||
}: AgendaViewProps) {
|
||||
const c = useColors();
|
||||
@@ -50,32 +53,35 @@ export function AgendaView({
|
||||
);
|
||||
|
||||
const sections = React.useMemo<DaySection[]>(() => {
|
||||
const result: DaySection[] = [];
|
||||
const start = new Date(fromDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
// Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for
|
||||
// every day that actually has events, always include a "Today" anchor, and
|
||||
// render them sorted ascending. Driving the list off the events (rather than
|
||||
// a fixed day-counter) guarantees every upcoming loaded event appears - not
|
||||
// just the ones inside a brittle N-day window from the anchor.
|
||||
const anchor = startOfDay(fromDate);
|
||||
const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive)
|
||||
|
||||
for (let i = 0; i < daysAhead; i++) {
|
||||
const day = new Date(start);
|
||||
day.setDate(start.getDate() + i);
|
||||
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
|
||||
// All-day events first within a day, then by start time, then title
|
||||
// (stable tie-break) - mirrors the comparator the webmail uses for
|
||||
// packing week segments and keeps multi-event days predictable.
|
||||
if (a.showWithoutTime !== b.showWithoutTime) {
|
||||
return a.showWithoutTime ? -1 : 1;
|
||||
}
|
||||
const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
});
|
||||
if (dayEvents.length === 0 && !isToday(day)) continue;
|
||||
result.push({
|
||||
const days = new Map<string, Date>();
|
||||
for (const key of index.keys()) {
|
||||
const day = parseISO(key); // index keys are local yyyy-MM-dd
|
||||
if (isNaN(day.getTime())) continue;
|
||||
if (day < anchor || day >= horizonEnd) continue;
|
||||
days.set(key, day);
|
||||
}
|
||||
// Always anchor "Today" so the view has a stable starting point even when
|
||||
// the current day has no events (webmail injects the same anchor).
|
||||
const today = startOfDay(new Date());
|
||||
if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today);
|
||||
|
||||
return [...days.values()]
|
||||
.sort((a, b) => a.getTime() - b.getTime())
|
||||
.map((day) => ({
|
||||
title: formatDayHeader(day),
|
||||
date: day,
|
||||
data: dayEvents,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
// Buckets are pre-sorted by buildEventDayIndex (all-day first, then by
|
||||
// start, then title), so no extra sort is needed here.
|
||||
data: eventsOnDayFromIndex(index, day),
|
||||
}));
|
||||
}, [fromDate, daysAhead, index]);
|
||||
|
||||
if (sections.length === 0) {
|
||||
@@ -107,7 +113,7 @@ export function AgendaView({
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.itemWrap}>
|
||||
<EventCard event={item} calendars={calendars} onPress={onSelectEvent} />
|
||||
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
|
||||
</View>
|
||||
)}
|
||||
renderSectionFooter={({ section }) =>
|
||||
|
||||
@@ -5,11 +5,17 @@ import { format } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
|
||||
import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onPress?: (event: CalendarEvent) => void;
|
||||
onLongPress?: (event: CalendarEvent) => void;
|
||||
}
|
||||
@@ -18,17 +24,18 @@ function participantCount(event: CalendarEvent): number {
|
||||
return event.participants ? Object.keys(event.participants).length : 0;
|
||||
}
|
||||
|
||||
function formatTimeRange(event: CalendarEvent): string {
|
||||
function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
||||
const { start, end, allDay } = eventTimeRange(event);
|
||||
if (allDay) return 'All day';
|
||||
return `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
|
||||
const fmt = timePattern(timeFormat);
|
||||
return `${format(start, fmt)} – ${format(end, fmt)}`;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
|
||||
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const color = getEventColor(event, calendars);
|
||||
const time = formatTimeRange(event);
|
||||
const time = formatTimeRange(event, timeFormat);
|
||||
const count = participantCount(event);
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
getPrimaryCalendarId,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
interface EventDetailSheetProps {
|
||||
event: CalendarEvent | null;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onClose: () => void;
|
||||
onEdit?: (event: CalendarEvent) => void;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
@@ -57,7 +60,7 @@ interface EventDetailSheetProps {
|
||||
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function formatRange(event: CalendarEvent): string {
|
||||
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
||||
const { start, end, allDay } = eventTimeRange(event);
|
||||
if (allDay) {
|
||||
if (
|
||||
@@ -69,14 +72,15 @@ function formatRange(event: CalendarEvent): string {
|
||||
}
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
|
||||
}
|
||||
const t = timePattern(timeFormat);
|
||||
const sameDay =
|
||||
start.getFullYear() === end.getFullYear() &&
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getDate() === end.getDate();
|
||||
if (sameDay) {
|
||||
return `${format(start, 'EEE, MMM d')} · ${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
|
||||
return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`;
|
||||
}
|
||||
return `${format(start, 'MMM d, HH:mm')} – ${format(end, 'MMM d, HH:mm')}`;
|
||||
return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`;
|
||||
}
|
||||
|
||||
function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
@@ -100,6 +104,7 @@ function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
export function EventDetailSheet({
|
||||
event,
|
||||
calendars,
|
||||
timeFormat,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -156,7 +161,7 @@ export function EventDetailSheet({
|
||||
const color = getEventColor(event, calendars);
|
||||
const calendarId = getPrimaryCalendarId(event);
|
||||
const calendar = calendars.find((c) => c.id === calendarId);
|
||||
const range = formatRange(event);
|
||||
const range = formatRange(event, timeFormat);
|
||||
const recurrence = recurrenceLabel(event);
|
||||
const reminders = alertsToReminders(event.alerts);
|
||||
const participants = event.participants ? Object.values(event.participants) : [];
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
normalizeAllDayDuration,
|
||||
buildAllDayDuration,
|
||||
getCalendarColor,
|
||||
getEventStartDate,
|
||||
getEventEndDate,
|
||||
timePattern,
|
||||
layoutOverlappingEvents,
|
||||
CALENDAR_COLOR_PALETTE,
|
||||
} from '../calendar-utils';
|
||||
@@ -51,6 +54,32 @@ describe('parseLocalDateTime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventStartDate / getEventEndDate malformed utc fallback', () => {
|
||||
// Mirrors webmail (#316): a malformed utcStart/utcEnd must fall back to the
|
||||
// floating `start` + duration instead of producing an Invalid Date, which
|
||||
// would silently drop the event from the day index and every view.
|
||||
it('falls back to start when utcStart is unparseable', () => {
|
||||
const d = getEventStartDate(ev({ utcStart: 'garbage' }));
|
||||
expect(isNaN(d.getTime())).toBe(false);
|
||||
expect(d.getHours()).toBe(9);
|
||||
});
|
||||
|
||||
it('falls back to start + duration when utcEnd is unparseable', () => {
|
||||
const end = getEventEndDate(ev({ utcEnd: 'garbage', duration: 'PT1H' }));
|
||||
expect(isNaN(end.getTime())).toBe(false);
|
||||
const start = getEventStartDate(ev({}));
|
||||
expect(end.getTime() - start.getTime()).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timePattern', () => {
|
||||
it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => {
|
||||
expect(timePattern('12h')).toBe('h:mm a');
|
||||
expect(timePattern('24h')).toBe('HH:mm');
|
||||
expect(timePattern(undefined)).toBe('HH:mm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventTimeRange', () => {
|
||||
it('returns start, end, allDay for a timed event with utcStart/utcEnd', () => {
|
||||
const range = eventTimeRange(ev({
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Provide the Android native FCM surface setupPushNotifications needs. The
|
||||
// global test-setup mocks react-native with an empty NativeModules, so override
|
||||
// it here with a BulwarkFcm module and a pre-33 Platform.Version (which skips
|
||||
// the runtime permission request).
|
||||
vi.mock('react-native', () => {
|
||||
class NativeEventEmitter {
|
||||
addListener() {
|
||||
return { remove: () => undefined };
|
||||
}
|
||||
}
|
||||
return {
|
||||
Platform: { OS: 'android', Version: 30, select: <T,>(s: { default?: T; android?: T }) => s.android ?? s.default },
|
||||
NativeModules: {
|
||||
BulwarkFcm: {
|
||||
getToken: vi.fn(async () => 'fcm-token-xyz'),
|
||||
deleteToken: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
NativeEventEmitter,
|
||||
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com' },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/push', () => ({
|
||||
listPushSubscriptions: vi.fn(async () => []),
|
||||
createPushSubscription: vi.fn(async () => 'new-server-id'),
|
||||
verifyPushSubscription: vi.fn(async () => undefined),
|
||||
destroyPushSubscription: vi.fn(async () => undefined),
|
||||
updatePushSubscription: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { setupPushNotifications, deviceClientIdKey } from '../push-notifications';
|
||||
import {
|
||||
listPushSubscriptions,
|
||||
createPushSubscription,
|
||||
destroyPushSubscription,
|
||||
} from '../../api/push';
|
||||
import { generateAccountId } from '../account-utils';
|
||||
|
||||
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const ACCOUNT_ID = generateAccountId('user@example.com', 'https://mail.example.com');
|
||||
const RELAY = 'https://relay.example.com';
|
||||
|
||||
// State the fake relay reports for each foreign deviceClientId's /active probe.
|
||||
type RelayState = 'dead' | 'live' | 'unknown';
|
||||
|
||||
function installFetch(states: Record<string, RelayState>): void {
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/push/register')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/verify/')) {
|
||||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||||
}
|
||||
const active = url.match(/\/api\/push\/active\/([^/?]+)$/);
|
||||
if (active) {
|
||||
const dcid = decodeURIComponent(active[1]);
|
||||
const state = states[dcid] ?? 'unknown';
|
||||
if (state === 'unknown') {
|
||||
return { ok: false, status: 404, json: async () => ({ error: 'Unknown subscription' }) } as Response;
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ active: state === 'live' }) } as Response;
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
|
||||
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
|
||||
const createMock = createPushSubscription as ReturnType<typeof vi.fn>;
|
||||
|
||||
function sub(id: string, deviceClientId: string) {
|
||||
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
|
||||
}
|
||||
|
||||
describe('setupPushNotifications leftover reaping', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
// Pin our deviceClientId so we control which leftovers are "ours".
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
});
|
||||
|
||||
it('reaps our own and relay-confirmed-dead leftovers, keeps live and unverifiable ones', async () => {
|
||||
listMock.mockResolvedValue([
|
||||
sub('own-old', OUR_DCID), // our own previous attempt -> reap
|
||||
sub('foreign-dead', 'deaddeaddeaddeaddeaddeaddeaddead'), // relay: dead -> reap
|
||||
sub('foreign-live', 'livelivelivelivelivelivelivelive'), // relay: live -> keep
|
||||
sub('foreign-unknown', 'unknwunknwunknwunknwunknwunknwun'), // relay: 404 -> keep
|
||||
]);
|
||||
installFetch({
|
||||
deaddeaddeaddeaddeaddeaddeaddead: 'dead',
|
||||
livelivelivelivelivelivelivelive: 'live',
|
||||
unknwunknwunknwunknwunknwunknwun: 'unknown',
|
||||
});
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
const reaped = destroyMock.mock.calls.map((c) => c[0]);
|
||||
expect(reaped).toContain('own-old');
|
||||
expect(reaped).toContain('foreign-dead');
|
||||
expect(reaped).not.toContain('foreign-live');
|
||||
expect(reaped).not.toContain('foreign-unknown');
|
||||
});
|
||||
|
||||
it('keeps foreign subs when the relay probe fails (network error)', async () => {
|
||||
listMock.mockResolvedValue([sub('foreign-x', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')]);
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/push/register')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/verify/')) {
|
||||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/active/')) throw new Error('network down');
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('foreign-x');
|
||||
});
|
||||
|
||||
it('coalesces concurrent setups into a single flow (no subscription swarm)', async () => {
|
||||
listMock.mockResolvedValue([]);
|
||||
installFetch({});
|
||||
|
||||
// Fire several overlapping setups, as App.tsx does while auth settles and
|
||||
// on FCM token refresh. Only one underlying JMAP subscription must be made.
|
||||
const results = await Promise.all([
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
]);
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -35,13 +35,20 @@ export function parseLocalDateTime(iso: string): Date {
|
||||
export function getEventStartDate(
|
||||
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||
): Date {
|
||||
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
|
||||
return parseISO(source);
|
||||
// Prefer utcStart for timed events but fall back to start if utcStart is
|
||||
// missing or unparseable — a malformed utcStart used to surface as an
|
||||
// Invalid Date that silently dropped the event from every view (#316).
|
||||
if (!event.showWithoutTime && event.utcStart) {
|
||||
const utc = parseISO(event.utcStart);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
return parseISO(event.start);
|
||||
}
|
||||
|
||||
export function getEventEndDate(event: CalendarEvent): Date {
|
||||
if (!event.showWithoutTime && event.utcEnd) {
|
||||
return parseISO(event.utcEnd);
|
||||
const utc = parseISO(event.utcEnd);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
const start = getEventStartDate(event);
|
||||
if (!event.duration) return start;
|
||||
@@ -57,6 +64,17 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
||||
return subMilliseconds(end, 1);
|
||||
}
|
||||
|
||||
// ─── Time-of-day formatting ──────────────────────────────
|
||||
// Maps the user's 12h/24h preference to a date-fns pattern, matching webmail
|
||||
// (event-card.tsx: `timeFormat === "12h" ? "h:mm a" : "HH:mm"`). Components
|
||||
// thread the `calendarTimeFormat` setting through so the calendar renders
|
||||
// times in the same format the webmail does.
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
|
||||
export function timePattern(timeFormat: TimeFormat | undefined): string {
|
||||
return timeFormat === '12h' ? 'h:mm a' : 'HH:mm';
|
||||
}
|
||||
|
||||
export interface EventTimeRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
|
||||
@@ -217,6 +217,33 @@ async function registerWithRelay(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the relay whether a leftover subscription is dead. Returns true ONLY when
|
||||
* the relay positively reports it inactive - a record it knows about that has
|
||||
* never forwarded a push and isn't freshly registered. Every other outcome (the
|
||||
* relay doesn't recognise the id, an older relay without this endpoint, a
|
||||
* network blip, or a live subscription) returns false, so we never reap
|
||||
* anything we can't confirm is dead. This is what lets setup clear its own
|
||||
* abandoned attempts - and dead siblings left by reinstalls that regenerated
|
||||
* the deviceClientId - without disturbing another live device or the PWA that
|
||||
* shares the account.
|
||||
*/
|
||||
async function relayReportsDead(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { active?: unknown };
|
||||
return body.active === false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollVerificationCode(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
@@ -241,12 +268,36 @@ async function pollVerificationCode(
|
||||
throw new Error('Timed out waiting for PushVerification from JMAP server');
|
||||
}
|
||||
|
||||
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
|
||||
// effect as auth state settles during startup and again on every FCM token
|
||||
// refresh; because a failing attempt blocks for up to 75s polling for the
|
||||
// verification code, those re-fires overlap. Without this guard each
|
||||
// overlapping run mints its OWN deviceClientId and JMAP subscription, and the
|
||||
// resulting swarm starves Stalwart's one-PushVerification-per-60s slot so none
|
||||
// of them ever verifies (the symptom is a perpetual "Timed out waiting for
|
||||
// PushVerification"). Callers share the first in-flight run instead.
|
||||
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
|
||||
|
||||
/**
|
||||
* Full setup flow: ask permission, fetch the device's FCM token, register
|
||||
* with the relay, create a JMAP PushSubscription, poll for the verification
|
||||
* code, and finalise the subscription.
|
||||
* code, and finalise the subscription. Concurrent calls for the same account
|
||||
* are coalesced onto a single in-flight run.
|
||||
*/
|
||||
export async function setupPushNotifications(
|
||||
export function setupPushNotifications(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`;
|
||||
const existing = inFlightSetups.get(key);
|
||||
if (existing) return existing;
|
||||
const run = setupPushNotificationsInner(params).finally(() => {
|
||||
inFlightSetups.delete(key);
|
||||
});
|
||||
inFlightSetups.set(key, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function setupPushNotificationsInner(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const native = getNative();
|
||||
@@ -306,19 +357,29 @@ export async function setupPushNotifications(
|
||||
await AsyncStorage.removeItem(subKey);
|
||||
}
|
||||
|
||||
// Reap any leftover Stalwart subscriptions still bound to this account's
|
||||
// deviceClientId. These pile up when a previous enable attempt failed
|
||||
// mid-flow (verify race, network blip, app killed). Stalwart per-account
|
||||
// rate-limits PushVerification posts, so leaving stragglers around blocks
|
||||
// the new one's verification - the symptom is a confusing "code does not
|
||||
// match". Only reap matches for THIS account's deviceClientId; other
|
||||
// accounts' subscriptions on this server (rare but possible if the same
|
||||
// JMAP server backs multiple accounts) must be left alone.
|
||||
const stragglers = existingSubs.filter(
|
||||
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
|
||||
);
|
||||
for (const s of stragglers) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
// Reap leftover Stalwart subscriptions that would otherwise starve the new
|
||||
// one's verification. Stalwart emits only one PushVerification per account
|
||||
// per ~60s and picks the oldest unverified subscription, so a single stale
|
||||
// straggler blocks every fresh attempt - the symptom is the confusing
|
||||
// "Timed out waiting for PushVerification" error. We can't read a
|
||||
// subscription's verified state or URL over JMAP (Stalwart hides both), only
|
||||
// its deviceClientId, so we decide what's safe to remove like this:
|
||||
// - same deviceClientId as ours: a previous attempt from THIS device,
|
||||
// always safe to reap.
|
||||
// - a different deviceClientId: could be another live device or the PWA on
|
||||
// this account. Ask the relay whether it's still alive and only reap the
|
||||
// ones it confirms are dead. Anything live - or anything the relay can't
|
||||
// vouch for (a different relay, a non-Bulwark client, a network blip) -
|
||||
// is left untouched.
|
||||
for (const s of existingSubs) {
|
||||
if (s.id === storedServerId) continue;
|
||||
if (s.deviceClientId === deviceClientId) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
continue;
|
||||
}
|
||||
if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const serverAssignedId = await createPushSubscription({
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
pickUnusedCalendarColor,
|
||||
sharedCalendarColorKey,
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../lib/calendar-utils';
|
||||
import { buildReplyTo } from '../lib/calendar-invitation';
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
|
||||
@@ -291,25 +292,34 @@ export default function CalendarScreen() {
|
||||
[],
|
||||
);
|
||||
|
||||
// A recurring series member is a master (has recurrenceRules) or an expanded
|
||||
// occurrence (has recurrenceId) — those get the this/all scope dialog. Note:
|
||||
// we must NOT key off originalId here, since shared/group events now carry it
|
||||
// for id-namespacing even when they aren't recurring.
|
||||
const isRecurringSeriesMember = React.useCallback(
|
||||
(event: CalendarEvent) => !!event.recurrenceRules?.length || !!event.recurrenceId,
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
if (isRecurringSeriesMember(event)) {
|
||||
setPendingAction({ kind: 'edit', event });
|
||||
} else {
|
||||
openEditDirect(event);
|
||||
}
|
||||
}, [openEditDirect, isReadOnlyEvent]);
|
||||
}, [openEditDirect, isReadOnlyEvent, isRecurringSeriesMember]);
|
||||
|
||||
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
if (isRecurringSeriesMember(event)) {
|
||||
setPendingAction({ kind: 'delete', event });
|
||||
} else {
|
||||
void deleteEvent(event.id);
|
||||
}
|
||||
}, [deleteEvent, isReadOnlyEvent]);
|
||||
}, [deleteEvent, isReadOnlyEvent, isRecurringSeriesMember]);
|
||||
|
||||
const handleScopeSelect = React.useCallback(
|
||||
async (scope: RecurrenceEditScope) => {
|
||||
@@ -459,6 +469,7 @@ export default function CalendarScreen() {
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectEvent={setDetailEvent}
|
||||
/>
|
||||
)}
|
||||
@@ -475,6 +486,7 @@ export default function CalendarScreen() {
|
||||
date={selectedDate}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectEvent={setDetailEvent}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
@@ -486,6 +498,7 @@ export default function CalendarScreen() {
|
||||
<EventDetailSheet
|
||||
event={detailEvent}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
@@ -570,6 +583,7 @@ function DayEventList({
|
||||
date,
|
||||
eventsByDay,
|
||||
calendars,
|
||||
timeFormat,
|
||||
onSelectEvent,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
@@ -577,6 +591,7 @@ function DayEventList({
|
||||
date: Date;
|
||||
eventsByDay: EventDayIndex;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
@@ -613,6 +628,7 @@ function DayEventList({
|
||||
key={event.id}
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
timeFormat={timeFormat}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -101,6 +101,70 @@ describe('calendar-store', () => {
|
||||
expect(useCalendarStore.getState().events).toEqual([]);
|
||||
expect(mockGetEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('namespaces shared/group event ids by owning account and keeps the real id in originalId', async () => {
|
||||
// A shared calendar lives in another account; its event id ("ev1") can
|
||||
// collide with one of the user's own events, so the store must namespace
|
||||
// it. Mirrors webmail's mapServerEventToStoreEvent.
|
||||
useCalendarStore.setState({
|
||||
calendars: [
|
||||
{ id: 'cal-1', name: 'Personal' } as any,
|
||||
{ id: 'shared-cal', name: 'Team', accountId: 'acc-2', isShared: true } as any,
|
||||
],
|
||||
});
|
||||
mockQueryEvents.mockImplementation(async (_ids: string[], _a: string, _b: string, accountId?: string) =>
|
||||
accountId === 'acc-2' ? ['ev1'] : [],
|
||||
);
|
||||
mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) =>
|
||||
accountId === 'acc-2'
|
||||
? [{ id: 'ev1', title: 'Standup', start: '2026-03-15T10:00:00', calendarIds: { 'shared-cal': true } }]
|
||||
: [],
|
||||
);
|
||||
|
||||
await useCalendarStore.getState().fetchEvents(['cal-1', 'shared-cal'], '2026-03-01', '2026-03-31');
|
||||
|
||||
const stored = useCalendarStore.getState().events;
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].id).toBe('acc-2:ev1');
|
||||
expect(stored[0].originalId).toBe('ev1');
|
||||
expect(stored[0].accountId).toBe('acc-2');
|
||||
});
|
||||
|
||||
it('routes queries by account when own and shared calendars share a raw id', async () => {
|
||||
// The user's own "default" calendar and a family group's "default"
|
||||
// calendar collide on the raw JMAP id. The shared one is namespaced
|
||||
// (`acc-2:default`, originalId "default"); the query must use the raw id
|
||||
// against acc-2, and the returned event's calendarIds must be remapped to
|
||||
// the namespaced store id so it isn't hidden with the personal calendar.
|
||||
useCalendarStore.setState({
|
||||
calendars: [
|
||||
{ id: 'default', name: 'Personal' } as any,
|
||||
{ id: 'acc-2:default', originalId: 'default', name: 'Family', accountId: 'acc-2', isShared: true } as any,
|
||||
],
|
||||
});
|
||||
const queried: Record<string, string[]> = {};
|
||||
mockQueryEvents.mockImplementation(async (ids: string[], _a: string, _b: string, accountId?: string) => {
|
||||
queried[accountId ?? 'primary'] = ids;
|
||||
return accountId === 'acc-2' ? ['fam1'] : [];
|
||||
});
|
||||
mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) =>
|
||||
accountId === 'acc-2'
|
||||
? [{ id: 'fam1', title: 'Soccer', start: '2026-03-20T15:00:00', calendarIds: { default: true } }]
|
||||
: [],
|
||||
);
|
||||
|
||||
await useCalendarStore.getState().fetchEvents(['default', 'acc-2:default'], '2026-03-01', '2026-03-31');
|
||||
|
||||
// The shared account is queried with the raw calendar id, not the prefix.
|
||||
expect(queried['acc-2']).toEqual(['default']);
|
||||
const ev = useCalendarStore.getState().events[0];
|
||||
expect(ev.calendarIds).toEqual({ 'acc-2:default': true });
|
||||
|
||||
// Hiding the personal "default" must not hide the family event.
|
||||
useCalendarStore.setState({ hiddenCalendarIds: ['default'] });
|
||||
const visible = selectVisibleEvents(useCalendarStore.getState());
|
||||
expect(visible.map((e) => e.id)).toContain('acc-2:fam1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureRange', () => {
|
||||
|
||||
@@ -25,6 +25,37 @@ function hasSchedulingParticipants(event?: Partial<CalendarEvent> | null): boole
|
||||
|
||||
const HIDDEN_CALENDARS_STORAGE_KEY = 'webmail:calendar:hidden:v1';
|
||||
|
||||
// Map a server CalendarEvent onto its store representation. For events from a
|
||||
// shared/group account the JMAP id is only unique within that account, so we
|
||||
// namespace `id` (stashing the real id in originalId) and remap the event's
|
||||
// `calendarIds` from raw server ids onto the namespaced store calendar ids so
|
||||
// visibility filtering and per-calendar colour lookup line up. Events from the
|
||||
// primary account are returned unchanged. Mirrors webmail's
|
||||
// mapServerEventToStoreEvent.
|
||||
function mapServerEventToStoreEvent(
|
||||
event: CalendarEvent,
|
||||
calendars: Calendar[],
|
||||
accountId?: string,
|
||||
): CalendarEvent {
|
||||
if (!accountId) return event;
|
||||
const mapped: Record<string, boolean> = {};
|
||||
for (const [calId, included] of Object.entries(event.calendarIds || {})) {
|
||||
const cal = calendars.find(
|
||||
(c) => (c.originalId || c.id) === calId && c.accountId === accountId,
|
||||
);
|
||||
mapped[cal?.id || calId] = included;
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
id: `${accountId}:${event.id}`,
|
||||
originalId: event.id,
|
||||
originalCalendarIds: event.calendarIds,
|
||||
calendarIds: Object.keys(mapped).length > 0 ? mapped : event.calendarIds,
|
||||
accountId,
|
||||
isShared: true,
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoadedRange {
|
||||
after: string;
|
||||
before: string;
|
||||
@@ -135,14 +166,20 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
try {
|
||||
// Group the requested calendars by owning account: the primary account
|
||||
// (calendars without an accountId tag) plus one group per shared
|
||||
// account, since CalendarEvent/query is scoped to a single account.
|
||||
// account, since CalendarEvent/query is scoped to a single account. The
|
||||
// incoming ids are store ids (shared calendars are namespaced
|
||||
// `${accountId}:${id}`); map them back to the raw server ids the query
|
||||
// filter expects.
|
||||
const calendars = get().calendars;
|
||||
const byId = new Map(calendars.map((c) => [c.id, c]));
|
||||
const groups = new Map<string | undefined, string[]>();
|
||||
for (const id of calendarIds) {
|
||||
const accountId = calendars.find((c) => c.id === id)?.accountId;
|
||||
const cal = byId.get(id);
|
||||
const accountId = cal?.accountId;
|
||||
const serverId = cal?.originalId || id;
|
||||
const group = groups.get(accountId);
|
||||
if (group) group.push(id);
|
||||
else groups.set(accountId, [id]);
|
||||
if (group) group.push(serverId);
|
||||
else groups.set(accountId, [serverId]);
|
||||
}
|
||||
if (groups.size === 0) groups.set(undefined, []);
|
||||
|
||||
@@ -152,7 +189,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
const eventIds = (await queryEvents(ids, after, before, accountId)) ?? [];
|
||||
if (eventIds.length === 0) continue;
|
||||
const fetched = (await fetchEvents(eventIds, accountId)) ?? [];
|
||||
raw.push(...(accountId ? fetched.map((e) => ({ ...e, accountId })) : fetched));
|
||||
raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
|
||||
} catch (err) {
|
||||
// A failing shared account must not hide the user's own events.
|
||||
if (!accountId) throw err;
|
||||
@@ -225,15 +262,22 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
createEvent: async (event, calendarId) => {
|
||||
// Shared calendars live in the owner's account — route the create there.
|
||||
const accountId = get().calendars.find((c) => c.id === calendarId)?.accountId;
|
||||
// Shared calendars live in the owner's account — route the create there,
|
||||
// and against the calendar's raw server id (calendarId is the namespaced
|
||||
// store id for shared calendars).
|
||||
const calendars = get().calendars;
|
||||
const cal = calendars.find((c) => c.id === calendarId);
|
||||
const accountId = cal?.accountId;
|
||||
const created = await apiCreateEvent(
|
||||
event,
|
||||
calendarId,
|
||||
cal?.originalId || calendarId,
|
||||
hasSchedulingParticipants(event) ? true : undefined,
|
||||
accountId,
|
||||
);
|
||||
set({ events: [...get().events, accountId ? { ...created, accountId } : created] });
|
||||
// Map the same way fetchEvents does so the optimistic insert doesn't
|
||||
// collide with the user's own events and stays visible under the right
|
||||
// calendar filter.
|
||||
set({ events: [...get().events, mapServerEventToStoreEvent(created, calendars, accountId)] });
|
||||
return created;
|
||||
},
|
||||
|
||||
@@ -285,20 +329,26 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
|
||||
importEvents: async (events, calendarId) => {
|
||||
if (events.length === 0) return 0;
|
||||
// Shared calendars live in the owner's account and carry a namespaced
|
||||
// store id — resolve the raw server id + owning account so dedup and
|
||||
// create target the right place.
|
||||
const cal = get().calendars.find((c) => c.id === calendarId);
|
||||
const accountId = cal?.accountId;
|
||||
const serverCalendarId = cal?.originalId || calendarId;
|
||||
// Stalwart enforces UID uniqueness across calendars. Skip events whose UID
|
||||
// already exists; create the rest. (We don't attempt cross-calendar linking
|
||||
// on mobile — a duplicate is simply skipped.)
|
||||
let toCreate = events;
|
||||
try {
|
||||
const existingIds = await queryEvents([], '', '');
|
||||
const existing = existingIds.length > 0 ? await fetchEvents(existingIds) : [];
|
||||
const existingIds = await queryEvents([], '', '', accountId);
|
||||
const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : [];
|
||||
const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
|
||||
toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid));
|
||||
} catch {
|
||||
// Couldn't dedupe — proceed and let the server reject genuine dupes.
|
||||
}
|
||||
if (toCreate.length === 0) return 0;
|
||||
const count = await apiBatchCreateEvents(toCreate, calendarId);
|
||||
const count = await apiBatchCreateEvents(toCreate, serverCalendarId, accountId);
|
||||
await get().refresh();
|
||||
return count;
|
||||
},
|
||||
@@ -311,7 +361,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
|
||||
setDefaultCalendar: async (id) => {
|
||||
const cal = get().calendars.find((c) => c.id === id);
|
||||
await apiSetDefaultCalendar(id, cal?.accountId);
|
||||
await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId);
|
||||
set({
|
||||
calendars: get().calendars.map((c) => {
|
||||
if (c.id === id) return { ...c, isDefault: true };
|
||||
@@ -326,8 +376,13 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
createTask: async (task, calendarId) => {
|
||||
const accountId = get().calendars.find((c) => c.id === calendarId)?.accountId;
|
||||
await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId, undefined, accountId);
|
||||
const cal = get().calendars.find((c) => c.id === calendarId);
|
||||
await apiCreateEvent(
|
||||
{ ...task, '@type': 'Task' },
|
||||
cal?.originalId || calendarId,
|
||||
undefined,
|
||||
cal?.accountId,
|
||||
);
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user