feat: enhance EventModal with alert and recurrence labels, add view/edit mode toggle
This commit is contained in:
@@ -4,10 +4,10 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration } from "./event-card";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput } from "./participant-input";
|
||||
import {
|
||||
@@ -59,6 +59,41 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
||||
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
|
||||
|
||||
function formatDurationDisplay(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h${m}min`;
|
||||
}
|
||||
|
||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
if (!event.alerts) return null;
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return t("alerts.at_time");
|
||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
||||
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
||||
const hourMatch = offset.match(/-?PT?(\d+)H$/);
|
||||
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
if (!event.recurrenceRules?.length) return null;
|
||||
const freq = event.recurrenceRules[0].frequency;
|
||||
const labels: Record<string, string> = {
|
||||
daily: t("recurrence.daily"),
|
||||
weekly: t("recurrence.weekly"),
|
||||
monthly: t("recurrence.monthly"),
|
||||
yearly: t("recurrence.yearly"),
|
||||
};
|
||||
return labels[freq] || null;
|
||||
}
|
||||
|
||||
export function EventModal({
|
||||
event,
|
||||
calendars,
|
||||
@@ -74,6 +109,7 @@ export function EventModal({
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const isEdit = !!event;
|
||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
||||
|
||||
const userIsOrganizer = useMemo(() => {
|
||||
if (!event) return true;
|
||||
@@ -340,7 +376,13 @@ export function EventModal({
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape") {
|
||||
if (mode === "edit" && isEdit) {
|
||||
setMode("view");
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (!isAttendeeMode) handleSave();
|
||||
@@ -348,7 +390,7 @@ export function EventModal({
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, handleSave, isAttendeeMode]);
|
||||
}, [onClose, handleSave, isAttendeeMode, mode, isEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
const modal = modalRef.current;
|
||||
@@ -486,6 +528,175 @@ export function EventModal({
|
||||
);
|
||||
}
|
||||
|
||||
// View mode: read-only display of event details with Edit button
|
||||
if (mode === "view" && event) {
|
||||
const startD = parseISO(event.start);
|
||||
const durMin = parseDuration(event.duration);
|
||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||
const viewParticipants = getParticipantList(event);
|
||||
const recurrenceLabel = getRecurrenceLabel(event, t);
|
||||
const alertLabel = getAlertLabel(event, t);
|
||||
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
||||
const color = getEventColor(event, eventCalendar);
|
||||
|
||||
return (
|
||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||
{/* Color accent bar */}
|
||||
<div className="h-1 w-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||
</div>
|
||||
{eventCalendar && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">{eventCalendar.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
{/* Date & Time */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{format(startD, "EEE, MMM d, yyyy")}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, "HH:mm")} – {format(endD, "HH:mm")}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
|
||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtual Location */}
|
||||
{virtualLoc && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
|
||||
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{viewParticipants.length > 0 && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Users className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm min-w-0">
|
||||
<span className="text-muted-foreground">
|
||||
{t("participants.count", { count: viewParticipants.length })}
|
||||
</span>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{viewParticipants.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate text-foreground">
|
||||
{p.name || p.email}
|
||||
{p.isOrganizer && (
|
||||
<span className="text-muted-foreground ml-1">({t("participants.organizer").toLowerCase()})</span>
|
||||
)}
|
||||
</span>
|
||||
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence */}
|
||||
{recurrenceLabel && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Repeat className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-foreground">{recurrenceLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reminder */}
|
||||
{alertLabel && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Bell className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-foreground">{alertLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{event.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{onDelete && (
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600 dark:text-red-400">{t("form.delete_confirm")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700">
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400">
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{onDuplicate && !showDeleteConfirm && (
|
||||
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!showDeleteConfirm && (
|
||||
<Button onClick={() => setMode("edit")}>
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
{t("events.edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
@@ -728,7 +939,7 @@ export function EventModal({
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!title.trim()}>
|
||||
|
||||
@@ -138,9 +138,9 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
||||
|
||||
{participants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{participants.map((p) => (
|
||||
{participants.map((p, i) => (
|
||||
<span
|
||||
key={p.email}
|
||||
key={`${p.email}-${i}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
|
||||
>
|
||||
<span className="truncate">{p.name || p.email}</span>
|
||||
|
||||
@@ -76,6 +76,7 @@ vi.mock('next-intl', () => ({
|
||||
actor_declined_counter_info: '{name} declined the counter proposal.',
|
||||
actor_note: 'Note: {comment}',
|
||||
actor_unknown: 'Someone',
|
||||
sender_mismatch_unverified_info: 'This invitation was sent from {sender}, while the organizer listed in the calendar data is {organizer}, and the message could not be verified.',
|
||||
action_failed: 'Could not complete that calendar action.',
|
||||
proposal_applied: 'Proposed changes applied.',
|
||||
proposed_changes: 'Proposed changes',
|
||||
@@ -172,6 +173,48 @@ describe('CalendarInvitationBanner', () => {
|
||||
expect(mocks.clientMock.parseCalendarEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show trust warnings on sent invitations from the current user', async () => {
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-sent',
|
||||
title: 'My Sent Invite',
|
||||
start: '2026-03-22T14:00:00Z',
|
||||
organizerCalendarAddress: 'mailto:other-organizer@example.com',
|
||||
participants: {
|
||||
organizer: {
|
||||
'@type': 'Participant',
|
||||
calendarAddress: 'mailto:other-organizer@example.com',
|
||||
name: 'Other Organizer',
|
||||
roles: { owner: true },
|
||||
participationStatus: 'accepted',
|
||||
},
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'guest@example.com',
|
||||
name: 'Guest',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<CalendarInvitationBanner
|
||||
email={makeEmail({
|
||||
id: 'email-sent',
|
||||
from: [{ email: 'user@example.com', name: 'User' }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'My Sent Invite' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/sent from/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows existing event status, current response, and view-in-calendar action', async () => {
|
||||
mocks.calendarState.events = [
|
||||
{
|
||||
|
||||
@@ -347,39 +347,6 @@ function getMethodAccentClass(method: InvitationMethod, actorStatus?: string | n
|
||||
}
|
||||
}
|
||||
|
||||
function getMethodBadgeClass(method: InvitationMethod): string {
|
||||
switch (method) {
|
||||
case 'cancel':
|
||||
case 'declinecounter':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'request':
|
||||
case 'add':
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'counter':
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
||||
case 'reply':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'publish':
|
||||
case 'refresh':
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function getMethodBadgeLabel(method: InvitationMethod): string | null {
|
||||
switch (method) {
|
||||
case 'publish': return 'PUBLISH';
|
||||
case 'request': return 'REQUEST';
|
||||
case 'reply': return 'REPLY';
|
||||
case 'add': return 'ADD';
|
||||
case 'cancel': return 'CANCEL';
|
||||
case 'refresh': return 'REFRESH';
|
||||
case 'counter': return 'COUNTER';
|
||||
case 'declinecounter': return 'DECLINE-COUNTER';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface CalendarInvitationBannerProps {
|
||||
email: Email;
|
||||
}
|
||||
@@ -502,9 +469,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
|| myParticipant?.participant.participationStatus
|
||||
|| null;
|
||||
const userIsOrganizer = Boolean(isOnlyOrganizer);
|
||||
const userIsSender = Boolean(
|
||||
currentUserEmail && email.from?.[0]?.email?.toLowerCase() === currentUserEmail.toLowerCase()
|
||||
);
|
||||
const bannerTitle = getBannerTitle(t, method);
|
||||
const bannerInfo = getBannerInfo(t, method, userIsOrganizer, supportsCalendar);
|
||||
const trustAssessment = parsedEvent ? getInvitationTrustAssessment(parsedEvent, email, method) : null;
|
||||
const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method) : null;
|
||||
const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null;
|
||||
const participationLabel = getParticipationLabel(t, currentRsvp);
|
||||
const actorSummary = parsedEvent ? getInvitationActorSummary(parsedEvent, method) : null;
|
||||
@@ -735,9 +705,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
}
|
||||
};
|
||||
|
||||
const methodBadgeLabel = getMethodBadgeLabel(method);
|
||||
const accentClass = getMethodAccentClass(method, actorSummary?.participationStatus);
|
||||
const badgeClass = getMethodBadgeClass(method);
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
@@ -770,11 +738,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
|
||||
</div>
|
||||
{methodBadgeLabel && (
|
||||
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide flex-shrink-0 ml-2", badgeClass)}>
|
||||
iTIP: {methodBadgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
Reference in New Issue
Block a user