feat: enhance EventModal with alert and recurrence labels, add view/edit mode toggle

This commit is contained in:
Linus Rath
2026-03-16 22:17:59 +01:00
parent 0f5d030d5f
commit d86d109725
4 changed files with 265 additions and 48 deletions
@@ -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 */}