feat: calendar invitations RSVP, trust assessment, file preview
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CalendarInvitationBanner } from '../calendar-invitation-banner';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const pushMock = vi.fn();
|
||||
const clientMock = {
|
||||
parseCalendarEvents: vi.fn(),
|
||||
getCalendarsAccountId: vi.fn(() => 'calendar-account'),
|
||||
getCalendarEvent: vi.fn(),
|
||||
queryCalendarEvents: vi.fn(async () => []),
|
||||
};
|
||||
|
||||
const authState = {
|
||||
client: clientMock,
|
||||
primaryIdentity: { email: 'user@example.com' },
|
||||
};
|
||||
|
||||
const settingsState = {
|
||||
calendarInvitationParsingEnabled: true,
|
||||
};
|
||||
|
||||
const calendarState = {
|
||||
calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }],
|
||||
supportsCalendar: true,
|
||||
importEvents: vi.fn(),
|
||||
rsvpEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
events: [] as Array<Record<string, unknown>>,
|
||||
setSelectedDate: vi.fn(),
|
||||
};
|
||||
|
||||
const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => (
|
||||
typeof selector === 'function' ? selector(calendarState) : calendarState
|
||||
)) as {
|
||||
(selector?: (state: typeof calendarState) => unknown): unknown;
|
||||
getState: () => typeof calendarState;
|
||||
setState: (updater: Partial<typeof calendarState> | ((state: typeof calendarState) => Partial<typeof calendarState> | typeof calendarState)) => void;
|
||||
};
|
||||
|
||||
useCalendarStoreMock.getState = () => calendarState;
|
||||
useCalendarStoreMock.setState = (updater) => {
|
||||
const nextState = typeof updater === 'function' ? updater(calendarState) : updater;
|
||||
Object.assign(calendarState, nextState);
|
||||
};
|
||||
|
||||
return {
|
||||
pushMock,
|
||||
clientMock,
|
||||
authState,
|
||||
settingsState,
|
||||
calendarState,
|
||||
useCalendarStoreMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, string>) => {
|
||||
const strings: Record<string, string> = {
|
||||
loading: 'Loading event details…',
|
||||
title: 'Calendar Invitation',
|
||||
organizer: 'Organized by {name}',
|
||||
attendees: 'attendees',
|
||||
add_to_calendar: 'Add to calendar',
|
||||
added: 'Added to calendar',
|
||||
rsvp_sent: 'Response sent',
|
||||
already_in_calendar: 'Already in your calendar',
|
||||
your_response: 'Your response: {status}',
|
||||
response_accepted: 'Accepted',
|
||||
response_needed: 'Needs response',
|
||||
actor_response_info: '{name} responded {status}.',
|
||||
actor_sent_info: 'Sent by {name}.',
|
||||
actor_counter_info: '{name} proposed changes to this event.',
|
||||
actor_refresh_info: '{name} asked for the latest event details.',
|
||||
actor_declined_counter_info: '{name} declined the counter proposal.',
|
||||
actor_note: 'Note: {comment}',
|
||||
actor_unknown: 'Someone',
|
||||
action_failed: 'Could not complete that calendar action.',
|
||||
proposal_applied: 'Proposed changes applied.',
|
||||
proposed_changes: 'Proposed changes',
|
||||
change_title: 'Title',
|
||||
change_time: 'Time',
|
||||
change_location: 'Location',
|
||||
change_description: 'Description',
|
||||
change_empty: 'None',
|
||||
change_from_to: '{before} -> {after}',
|
||||
apply_proposal: 'Apply proposed changes',
|
||||
review_proposal: 'Review proposal',
|
||||
review_request: 'Review request',
|
||||
view_in_calendar: 'View in calendar',
|
||||
organizer_role: 'You organize this event',
|
||||
accept: 'Accept',
|
||||
maybe: 'Maybe',
|
||||
decline: 'Decline',
|
||||
select_calendar: 'Select calendar',
|
||||
};
|
||||
|
||||
let message = strings[key] ?? key;
|
||||
if (values) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
message = message.replace(`{${name}}`, String(value));
|
||||
}
|
||||
}
|
||||
return message;
|
||||
},
|
||||
useFormatter: () => ({
|
||||
dateTime: (date: Date) => date.toISOString(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: mocks.pushMock }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/settings-store', () => ({
|
||||
useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/calendar-store', () => ({
|
||||
useCalendarStore: mocks.useCalendarStoreMock,
|
||||
}));
|
||||
|
||||
function makeEmail(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
size: 512,
|
||||
receivedAt: '2026-03-16T10:00:00Z',
|
||||
hasAttachment: true,
|
||||
attachments: [
|
||||
{
|
||||
partId: '1',
|
||||
blobId: 'blob-1',
|
||||
size: 256,
|
||||
type: 'text/calendar; method=REQUEST',
|
||||
name: 'invite.ics',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CalendarInvitationBanner', () => {
|
||||
beforeEach(() => {
|
||||
mocks.pushMock.mockReset();
|
||||
mocks.settingsState.calendarInvitationParsingEnabled = true;
|
||||
mocks.clientMock.parseCalendarEvents.mockReset();
|
||||
mocks.clientMock.getCalendarEvent.mockReset();
|
||||
mocks.clientMock.getCalendarEvent.mockResolvedValue(null);
|
||||
mocks.clientMock.queryCalendarEvents.mockReset();
|
||||
mocks.clientMock.queryCalendarEvents.mockResolvedValue([]);
|
||||
mocks.calendarState.importEvents.mockReset();
|
||||
mocks.calendarState.rsvpEvent.mockReset();
|
||||
mocks.calendarState.updateEvent.mockReset();
|
||||
mocks.calendarState.setSelectedDate.mockReset();
|
||||
mocks.calendarState.events = [];
|
||||
});
|
||||
|
||||
it('does not parse invitations when invitation parsing is disabled', () => {
|
||||
mocks.settingsState.calendarInvitationParsingEnabled = false;
|
||||
|
||||
const { container } = render(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(mocks.clientMock.parseCalendarEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows existing event status, current response, and view-in-calendar action', async () => {
|
||||
mocks.calendarState.events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
uid: 'uid-1',
|
||||
start: '2026-03-20T09:00:00Z',
|
||||
participants: {
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'accepted',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-1',
|
||||
title: 'Team Sync',
|
||||
start: '2026-03-20T09:00:00Z',
|
||||
participants: {
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'accepted',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Already in your calendar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Your response: Accepted')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('View in calendar'));
|
||||
|
||||
expect(mocks.calendarState.setSelectedDate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushMock).toHaveBeenCalledWith('/calendar');
|
||||
});
|
||||
|
||||
it('keeps the invitation summary visible after importing', async () => {
|
||||
mocks.calendarState.importEvents.mockResolvedValue(1);
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-2',
|
||||
title: 'Planning Session',
|
||||
start: '2026-03-22T13:00:00Z',
|
||||
participants: {
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-2' })} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Add to calendar'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Added to calendar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Planning Session' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show RSVP actions when the current user is the organizer', async () => {
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-3',
|
||||
title: 'Organizer Review',
|
||||
start: '2026-03-25T11:00:00Z',
|
||||
participants: {
|
||||
organizer: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { owner: true },
|
||||
participationStatus: 'accepted',
|
||||
},
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-3' })} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('You organize this event')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Accept')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Maybe')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Decline')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the banner visible when an import action fails', async () => {
|
||||
mocks.calendarState.importEvents.mockResolvedValue(0);
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-4',
|
||||
title: 'Failed Import Event',
|
||||
start: '2026-03-26T15:00:00Z',
|
||||
participants: {
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-4' })} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Add to calendar'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Could not complete that calendar action.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Failed Import Event' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hydrates sparse existing events before sending RSVP', async () => {
|
||||
mocks.calendarState.events = [
|
||||
{
|
||||
id: 'event-6',
|
||||
uid: 'uid-6',
|
||||
start: '2026-03-28T09:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-6',
|
||||
title: 'Sparse Event',
|
||||
start: '2026-03-28T09:00:00Z',
|
||||
participants: {
|
||||
parsedAttendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { required: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mocks.clientMock.getCalendarEvent.mockResolvedValue({
|
||||
id: 'event-6',
|
||||
uid: 'uid-6',
|
||||
start: '2026-03-28T09:00:00Z',
|
||||
participants: {
|
||||
canonicalAttendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-6' })} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Accept')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Accept'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.clientMock.getCalendarEvent).toHaveBeenCalledWith('event-6');
|
||||
});
|
||||
|
||||
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
|
||||
mocks.clientMock,
|
||||
'event-6',
|
||||
'canonicalAttendee',
|
||||
'accepted',
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to organizerCalendarAddress when replyTo is missing', async () => {
|
||||
mocks.calendarState.events = [
|
||||
{
|
||||
id: 'event-7',
|
||||
uid: 'uid-7',
|
||||
start: '2026-03-29T09:00:00Z',
|
||||
participants: {
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
calendarAddress: 'mailto:user@example.com',
|
||||
name: 'User',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-7',
|
||||
title: 'Invite Without Reply-To',
|
||||
start: '2026-03-29T09:00:00Z',
|
||||
organizerCalendarAddress: 'mailto:organizer@example.com',
|
||||
participants: {
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
calendarAddress: 'mailto:user@example.com',
|
||||
name: 'User',
|
||||
roles: { required: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-7' })} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Accept')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Accept'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
|
||||
mocks.clientMock,
|
||||
'event-7',
|
||||
'attendee',
|
||||
'accepted',
|
||||
{ imip: 'mailto:organizer@example.com' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs sparse existing events with parsed participants before RSVPing', async () => {
|
||||
mocks.calendarState.events = [
|
||||
{
|
||||
id: 'event-8',
|
||||
uid: 'uid-8',
|
||||
start: '2026-03-30T09:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mocks.clientMock.getCalendarEvent.mockResolvedValue({
|
||||
id: 'event-8',
|
||||
uid: 'uid-8',
|
||||
start: '2026-03-30T09:00:00Z',
|
||||
});
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-8',
|
||||
title: 'Sparse Stored Event',
|
||||
start: '2026-03-30T09:00:00Z',
|
||||
organizerCalendarAddress: 'mailto:organizer@example.com',
|
||||
participants: {
|
||||
organizer: {
|
||||
'@type': 'Participant',
|
||||
calendarAddress: 'mailto:organizer@example.com',
|
||||
name: 'Organizer',
|
||||
roles: { required: true },
|
||||
participationStatus: 'accepted',
|
||||
},
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
calendarAddress: 'mailto:user@example.com',
|
||||
name: 'User',
|
||||
roles: { required: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
mocks.calendarState.updateEvent.mockResolvedValue(undefined);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-8' })} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Accept')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Accept'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.calendarState.updateEvent).toHaveBeenCalledWith(
|
||||
mocks.clientMock,
|
||||
'event-8',
|
||||
expect.objectContaining({
|
||||
replyTo: { imip: 'mailto:organizer@example.com' },
|
||||
participants: expect.objectContaining({
|
||||
attendee: expect.objectContaining({
|
||||
participationStatus: 'accepted',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.calendarState.rsvpEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows counter changes and lets organizers apply the proposal', async () => {
|
||||
mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.calendarState.events = [
|
||||
{
|
||||
id: 'event-5',
|
||||
uid: 'uid-5',
|
||||
title: 'Original Sync',
|
||||
start: '2026-03-27T09:00:00Z',
|
||||
duration: 'PT1H',
|
||||
locations: {
|
||||
room: { '@type': 'Location', name: 'Room A' },
|
||||
},
|
||||
participants: {
|
||||
organizer: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { owner: true },
|
||||
participationStatus: 'accepted',
|
||||
},
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'tentative',
|
||||
participationComment: 'Could we move this later?',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-5',
|
||||
title: 'Original Sync',
|
||||
start: '2026-03-27T10:00:00Z',
|
||||
duration: 'PT1H',
|
||||
locations: {
|
||||
room: { '@type': 'Location', name: 'Room B' },
|
||||
},
|
||||
participants: {
|
||||
organizer: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { owner: true },
|
||||
participationStatus: 'accepted',
|
||||
},
|
||||
attendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'tentative',
|
||||
participationComment: 'Could we move this later?',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-5', attachments: [{ partId: '1', blobId: 'blob-5', size: 256, type: 'text/calendar; method=COUNTER', name: 'counter.ics' }] })} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Proposed changes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Location/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Review proposal')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Apply proposed changes'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.calendarState.updateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Proposed changes applied.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ import {
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
File,
|
||||
Eye,
|
||||
Shield,
|
||||
Image,
|
||||
Tag,
|
||||
@@ -76,6 +77,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
|
||||
interface EmailViewerProps {
|
||||
email: Email | null;
|
||||
@@ -483,12 +485,15 @@ export function EmailViewer({
|
||||
const t = useTranslations('email_viewer');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tCommon = useTranslations('common');
|
||||
const tFiles = useTranslations('files');
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
|
||||
// Detect if current mailbox is Junk folder
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
@@ -1139,7 +1144,9 @@ export function EmailViewer({
|
||||
listHeaders?.listUnsubscribe?.preferred &&
|
||||
!dismissedUnsubBanners.has(email?.messageId || '');
|
||||
|
||||
const hasCalendarInvitation = email ? !!findCalendarAttachment(email) : false;
|
||||
const hasCalendarInvitation = email
|
||||
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
|
||||
: false;
|
||||
|
||||
// Show loading skeleton while email is being fetched
|
||||
if (isLoading && !email) {
|
||||
@@ -2798,11 +2805,13 @@ export function EmailViewer({
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
{email.attachments.map((attachment, i) => {
|
||||
const FileIcon = getFileIcon(attachment.name, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 bg-muted/60 hover:bg-accent rounded-lg transition-colors group border border-border/50"
|
||||
title={`${t('download')} ${attachment.name} (${formatFileSize(attachment.size)})`}
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${attachment.name} (${formatFileSize(attachment.size)})`}
|
||||
onClick={() => {
|
||||
if (attachment.blobId && onDownloadAttachment) {
|
||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
|
||||
@@ -2818,7 +2827,11 @@ export function EmailViewer({
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
</div>
|
||||
<Download className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
{opensPreview ? (
|
||||
<Eye className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -2931,7 +2944,7 @@ export function EmailViewer({
|
||||
|
||||
{/* Calendar Invitation Banner */}
|
||||
{hasCalendarInvitation && (
|
||||
<div className="rounded-md px-3 py-1 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<div className="py-1">
|
||||
<CalendarInvitationBanner email={email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,10 +26,12 @@ import {
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
File,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
|
||||
interface ThreadConversationViewProps {
|
||||
thread: ThreadGroup;
|
||||
@@ -222,6 +224,7 @@ function EmailCard({
|
||||
const t = useTranslations();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
const sender = email.from?.[0];
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
@@ -526,6 +529,8 @@ function EmailCard({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{email.attachments.map((attachment, idx) => {
|
||||
const Icon = getFileIcon(attachment.name, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
@@ -533,6 +538,7 @@ function EmailCard({
|
||||
e.stopPropagation();
|
||||
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
|
||||
}}
|
||||
title={opensPreview ? t('files.preview') : t('email_viewer.download')}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -540,7 +546,11 @@ function EmailCard({
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
{opensPreview ? (
|
||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,32 +4,13 @@ import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getFilePreviewKind } from "@/lib/file-preview";
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onDownload: (name: string) => Promise<void>;
|
||||
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
|
||||
}
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts",
|
||||
"jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs",
|
||||
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env",
|
||||
"log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl",
|
||||
"swift", "kt", "scala", "r", "lua", "vim",
|
||||
]);
|
||||
|
||||
function getFileType(name: string): "text" | "pdf" | "audio" | "video" | "markdown" | "unknown" {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = name.toLowerCase();
|
||||
|
||||
if (ext === "md" || ext === "markdown") return "markdown";
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"].includes(ext)) return "audio";
|
||||
if (["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"].includes(ext)) return "video";
|
||||
if (TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName)) return "text";
|
||||
return "unknown";
|
||||
onDownload: () => Promise<void> | void;
|
||||
getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
|
||||
}
|
||||
|
||||
function SimpleMarkdown({ content }: { content: string }) {
|
||||
@@ -129,24 +110,35 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name));
|
||||
|
||||
const fileType = getFileType(name);
|
||||
const fileType = resolvedFileType;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let revokeUrl: string | null = null;
|
||||
|
||||
setContent(null);
|
||||
setObjectUrl(null);
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setResolvedFileType(getFilePreviewKind(name));
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { blob, contentType } = await getFileContent(name);
|
||||
const { blob, contentType } = await getFileContent();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (fileType === "text" || fileType === "markdown") {
|
||||
const previewType = getFilePreviewKind(name, contentType || blob.type);
|
||||
setResolvedFileType(previewType);
|
||||
|
||||
if (previewType === "text" || previewType === "markdown") {
|
||||
const text = await blob.text();
|
||||
if (!cancelled) setContent(text);
|
||||
} else {
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (!cancelled) setObjectUrl(url);
|
||||
revokeUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setObjectUrl(revokeUrl);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
@@ -159,9 +151,11 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
if (revokeUrl) {
|
||||
URL.revokeObjectURL(revokeUrl);
|
||||
}
|
||||
};
|
||||
}, [name]);
|
||||
}, [getFileContent, name]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -176,7 +170,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-medium truncate">{name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onDownload(name)}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
|
||||
@@ -208,6 +202,24 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "image" && objectUrl && (
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={name}
|
||||
className="max-w-full max-h-full object-contain rounded-lg bg-background"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "html" && objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
sandbox=""
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
title={name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
|
||||
@@ -11,7 +11,14 @@ export function CalendarSettings() {
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { viewMode, setViewMode } = useCalendarStore();
|
||||
const { timeFormat, firstDayOfWeek, calendarNotificationsEnabled, calendarNotificationSound, updateSetting } = useSettingsStore();
|
||||
const {
|
||||
timeFormat,
|
||||
firstDayOfWeek,
|
||||
calendarNotificationsEnabled,
|
||||
calendarNotificationSound,
|
||||
calendarInvitationParsingEnabled,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')}>
|
||||
@@ -71,6 +78,16 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('invitation_parsing')}
|
||||
description={t('invitation_parsing_desc')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={calendarInvitationParsingEnabled}
|
||||
onChange={(checked) => updateSetting('calendarInvitationParsingEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function EmailSettings() {
|
||||
showPreview,
|
||||
emailsPerPage,
|
||||
externalContentPolicy,
|
||||
mailAttachmentAction,
|
||||
trustedSenders,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
@@ -79,6 +80,17 @@ export function EmailSettings() {
|
||||
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
|
||||
<Select
|
||||
value={mailAttachmentAction}
|
||||
onChange={(value) => updateSetting('mailAttachmentAction', value as 'preview' | 'download')}
|
||||
options={[
|
||||
{ value: 'preview', label: t('attachment_click_action.preview') },
|
||||
{ value: 'download', label: t('attachment_click_action.download') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Emails Per Page */}
|
||||
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
|
||||
<Select
|
||||
|
||||
Reference in New Issue
Block a user