mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
fix: show an invitation's time in the user's time zone, with its name
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
|
||||
ShieldCheck, ShieldAlert, ChevronDown,
|
||||
} from '../icons';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { format, isSameDay } from 'date-fns';
|
||||
import type { Calendar, Email, CalendarEvent } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
calendarInvitationKey,
|
||||
findCalendarAttachment,
|
||||
findParticipantByEmail,
|
||||
getInvitationDisplayRange,
|
||||
getInvitationMethod,
|
||||
getInvitationTrustAssessment,
|
||||
getOrganizerName,
|
||||
invitationZoneLabel,
|
||||
buildReplyTo,
|
||||
type InvitationMethod,
|
||||
type InvitationTrustAssessment,
|
||||
@@ -65,7 +67,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const dateLocale = getDateFnsLocale(useLocaleStore((s) => s.locale));
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const dateLocale = getDateFnsLocale(locale);
|
||||
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
@@ -172,13 +175,25 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
||||
}
|
||||
if (!event) return null;
|
||||
|
||||
const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start);
|
||||
const startDate = startStr ? parseISO(startStr) : null;
|
||||
const dateLabel = startDate && !isNaN(startDate.getTime())
|
||||
? (event.showWithoutTime
|
||||
? format(startDate, 'EEEE, MMM d, yyyy', { locale: dateLocale })
|
||||
: format(startDate, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale: dateLocale }))
|
||||
: null;
|
||||
// In the user's zone, with its name, for an event at a fixed moment; the
|
||||
// organizer's wall clock otherwise read as a device time (webmail #1252).
|
||||
const range = getInvitationDisplayRange(event);
|
||||
let dateLabel: string | null = null;
|
||||
if (range) {
|
||||
const time = timePattern(timeFormat);
|
||||
if (event.showWithoutTime) {
|
||||
const day = 'EEEE, MMM d, yyyy';
|
||||
dateLabel = format(range.start, day, { locale: dateLocale })
|
||||
+ (range.end ? ` – ${format(range.end, day, { locale: dateLocale })}` : '');
|
||||
} else {
|
||||
dateLabel = format(range.start, `EEE, MMM d · ${time}`, { locale: dateLocale });
|
||||
if (range.end) {
|
||||
dateLabel += ` – ${format(range.end, isSameDay(range.start, range.end) ? time : `EEE, MMM d · ${time}`, { locale: dateLocale })}`;
|
||||
}
|
||||
const zone = range.instant ? invitationZoneLabel(range.instant, locale) : null;
|
||||
if (zone) dateLabel += ` ${zone}`;
|
||||
}
|
||||
}
|
||||
const organizer = getOrganizerName(event);
|
||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
||||
|
||||
@@ -1,16 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { format } from 'date-fns';
|
||||
import type { Email } from '../../api/types';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
calendarInvitationKey,
|
||||
extractMethodFromContentType,
|
||||
extractMethodFromRawIcs,
|
||||
findCalendarAttachment,
|
||||
getEmailAuthenticationResults,
|
||||
getInvitationDisplayRange,
|
||||
getInvitationMethod,
|
||||
getInvitationTrustAssessment,
|
||||
invitationZoneLabel,
|
||||
parseAuthenticationResults,
|
||||
} from '../calendar-invitation';
|
||||
|
||||
describe('getInvitationDisplayRange (webmail #1252)', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({ calendarTimeZone: 'auto' });
|
||||
});
|
||||
const shown = (d: Date | null | undefined) => (d ? format(d, 'yyyy-MM-dd HH:mm') : null);
|
||||
|
||||
it('shows an organizer\'s wall clock in the user\'s zone', () => {
|
||||
useSettingsStore.setState({ calendarTimeZone: 'Europe/Warsaw' });
|
||||
// As CalendarEvent/parse gives an Exchange invitation from London: no utcStart.
|
||||
const range = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', timeZone: 'Europe/London', duration: 'PT25M' });
|
||||
expect(shown(range?.start)).toBe('2026-10-20 16:00');
|
||||
expect(shown(range?.end)).toBe('2026-10-20 16:25');
|
||||
expect(range?.instant?.toISOString()).toBe('2026-10-20T14:00:00.000Z');
|
||||
expect(invitationZoneLabel(range!.instant!, 'en')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('takes the server\'s UTC start when it has one', () => {
|
||||
useSettingsStore.setState({ calendarTimeZone: 'America/New_York' });
|
||||
const range = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', timeZone: 'Europe/London', utcStart: '2026-10-20T14:00:00Z', utcEnd: '2026-10-20T15:00:00Z' });
|
||||
expect(shown(range?.start)).toBe('2026-10-20 10:00');
|
||||
expect(shown(range?.end)).toBe('2026-10-20 11:00');
|
||||
});
|
||||
|
||||
it('leaves floating times and all-day dates as written', () => {
|
||||
useSettingsStore.setState({ calendarTimeZone: 'America/New_York' });
|
||||
const floating = getInvitationDisplayRange({ start: '2026-10-20T15:00:00', duration: 'PT1H' });
|
||||
expect(shown(floating?.start)).toBe('2026-10-20 15:00');
|
||||
expect(floating?.instant).toBeUndefined();
|
||||
const oneDay = getInvitationDisplayRange({ start: '2026-10-20T00:00:00', duration: 'P1D', showWithoutTime: true });
|
||||
expect(shown(oneDay?.start)).toBe('2026-10-20 00:00');
|
||||
expect(oneDay?.end).toBeNull();
|
||||
// The stored end is exclusive: three days end on the third.
|
||||
const threeDays = getInvitationDisplayRange({ start: '2026-10-20T00:00:00', duration: 'P3D', showWithoutTime: true });
|
||||
expect(shown(threeDays?.end)).toBe('2026-10-22 00:00');
|
||||
});
|
||||
});
|
||||
|
||||
const request = {
|
||||
organizerCalendarAddress: 'mailto:alice@example.com',
|
||||
participants: {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { CalendarEvent, Participant, Email, Attachment, BodyPart, EmailAddress } from '../api/types';
|
||||
import { parseDuration, parseLocalDateTime } from './calendar-utils';
|
||||
import { getEffectiveTimeZone, isValidTimeZone, localDateTimeToInstant, toDisplayDate } from './calendar-timezone';
|
||||
|
||||
// ─── Address helpers ─────────────────────────────────────
|
||||
|
||||
@@ -451,3 +453,75 @@ export function getInvitationTrustAssessment(
|
||||
}
|
||||
return { level: 'trusted', reason: null, senderEmail, organizerEmail };
|
||||
}
|
||||
|
||||
// ─── When ────────────────────────────────────────────────
|
||||
|
||||
/** What the invitation banner shows as an event's time. */
|
||||
export interface InvitationDisplayRange {
|
||||
/** Display dates (see toDisplayDate): their local fields are what to show. */
|
||||
start: Date;
|
||||
end: Date | null;
|
||||
/**
|
||||
* The real start instant, for an event at a fixed moment (a UTC time or a
|
||||
* zoned wall clock) shown in the user's zone: worth a zone label. Unset
|
||||
* for all-day and floating events, which read the same everywhere.
|
||||
*/
|
||||
instant?: Date;
|
||||
}
|
||||
|
||||
const HAS_UTC_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
|
||||
|
||||
/**
|
||||
* The banner's start and end of an invitation. CalendarEvent/parse gives
|
||||
* `start` as a wall clock in `timeZone` and no `utcStart`, so the start has
|
||||
* to be resolved in that zone and shown in the user's: read as a device
|
||||
* time, it showed the organizer's clock without saying so (webmail #1252).
|
||||
* An all-day event gets its last day (the end is exclusive) or none for a
|
||||
* single day; a floating time stays as written.
|
||||
*/
|
||||
export function getInvitationDisplayRange(
|
||||
event: Partial<Pick<CalendarEvent, 'start' | 'duration' | 'timeZone' | 'showWithoutTime' | 'utcStart' | 'utcEnd'>>,
|
||||
): InvitationDisplayRange | null {
|
||||
if (!event.start) return null;
|
||||
const durationMs = parseDuration(event.duration);
|
||||
if (event.showWithoutTime) {
|
||||
const start = parseLocalDateTime(event.start.slice(0, 10));
|
||||
if (isNaN(start.getTime())) return null;
|
||||
const days = Math.round(durationMs / 86_400_000);
|
||||
if (days <= 1) return { start, end: null };
|
||||
const last = new Date(start);
|
||||
last.setDate(last.getDate() + days - 1);
|
||||
return { start, end: last };
|
||||
}
|
||||
|
||||
let instant: Date | null = null;
|
||||
if (event.utcStart) instant = new Date(event.utcStart);
|
||||
else if (HAS_UTC_OFFSET.test(event.start)) instant = new Date(event.start);
|
||||
else if (event.timeZone && isValidTimeZone(event.timeZone)) instant = localDateTimeToInstant(event.start, event.timeZone);
|
||||
if (instant && !isNaN(instant.getTime())) {
|
||||
let end: Date | null = event.utcEnd ? new Date(event.utcEnd) : null;
|
||||
if ((!end || isNaN(end.getTime())) && durationMs > 0) end = new Date(instant.getTime() + durationMs);
|
||||
return {
|
||||
start: toDisplayDate(instant),
|
||||
end: end && !isNaN(end.getTime()) && end.getTime() > instant.getTime() ? toDisplayDate(end) : null,
|
||||
instant,
|
||||
};
|
||||
}
|
||||
|
||||
// Floating: the same wall clock everywhere.
|
||||
const start = parseLocalDateTime(event.start);
|
||||
if (isNaN(start.getTime())) return null;
|
||||
return { start, end: durationMs > 0 ? new Date(start.getTime() + durationMs) : null };
|
||||
}
|
||||
|
||||
/** The short name of the user's calendar zone at `instant` ("CET", "GMT+1"), or null. */
|
||||
export function invitationZoneLabel(instant: Date, locale?: string): string | null {
|
||||
try {
|
||||
const part = new Intl.DateTimeFormat(locale, { timeZone: getEffectiveTimeZone(), timeZoneName: 'short' })
|
||||
.formatToParts(instant)
|
||||
.find((p) => p.type === 'timeZoneName');
|
||||
return part?.value ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user