From 3641db5b19ab245599f701f71926167f93072ce0 Mon Sep 17 00:00:00 2001 From: mup Date: Thu, 15 Aug 2024 15:35:08 +0200 Subject: [PATCH] Adds ics file import/handling to mobile apps This commit adds the capacity of handling ICS files on mobile apps, if the user shares the file with Mail or Calendar app, it'll try to import the files. --- app-android/app/src/main/AndroidManifest.xml | 11 ++ .../calendar/src/main/AndroidManifest.xml | 11 ++ .../calendar/export/CalendarImporter.ts | 147 ++++++++++++++++++ .../calendar/export/CalendarImporterDialog.ts | 24 ++- .../calendar/gui/CalendarGuiUtils.ts | 11 ++ .../calendar/{view => gui}/CalendarRow.ts | 4 +- .../eventeditor-view/CalendarEventEditView.ts | 14 +- .../calendar/search/view/SearchListView.ts | 2 +- .../calendar/view/CalendarView.ts | 7 +- src/calendar-app/calendarLocator.ts | 39 ++++- src/common/file/FileController.ts | 35 ++++- src/common/misc/TranslationKey.ts | 3 + .../native/main/WebCommonNativeFacade.ts | 28 +++- src/mail-app/mailLocator.ts | 67 ++++++-- src/mail-app/search/view/SearchListView.ts | 2 +- src/mail-app/translations/de.ts | 6 +- src/mail-app/translations/de_sie.ts | 6 +- src/mail-app/translations/en.ts | 6 +- 18 files changed, 365 insertions(+), 58 deletions(-) rename src/calendar-app/calendar/{view => gui}/CalendarRow.ts (97%) diff --git a/app-android/app/src/main/AndroidManifest.xml b/app-android/app/src/main/AndroidManifest.xml index c942762659c2..c2a366d08378 100644 --- a/app-android/app/src/main/AndroidManifest.xml +++ b/app-android/app/src/main/AndroidManifest.xml @@ -78,6 +78,17 @@ + + + + + + + + + + + diff --git a/app-android/calendar/src/main/AndroidManifest.xml b/app-android/calendar/src/main/AndroidManifest.xml index da59a897c5b1..df68288b3bab 100644 --- a/app-android/calendar/src/main/AndroidManifest.xml +++ b/app-android/calendar/src/main/AndroidManifest.xml @@ -76,6 +76,17 @@ + + + + + + + + + + + @@ -34,3 +50,134 @@ export function parseCalendarStringData(value: string, zone: string): ParsedCale const tree = parseICalendar(value) return parseCalendarEvents(tree, zone) } + +/** + * Show a dialog with a preview of a given list of events + * @param events The event list to be previewed + * @param okAction The action to be executed when the user press the ok or continue button + * @param title + */ +export function showEventsImportDialog(events: CalendarEvent[], okAction: (dialog: Dialog) => unknown, title: TranslationText) { + const renderConfig: RenderConfig = { + itemHeight: size.list_row_height, + multiselectionAllowed: MultiselectMode.Disabled, + swipe: null, + createElement: (dom) => { + return new KindaCalendarRow(dom) + }, + } + + const dialog = new Dialog(DialogType.EditSmall, { + view: () => [ + m(DialogHeaderBar, { + left: [ + { + type: ButtonType.Secondary, + label: "cancel_action", + click: () => { + dialog.close() + }, + }, + ], + middle: () => lang.getMaybeLazy(title), + right: [ + { + type: ButtonType.Primary, + label: "import_action", + click: () => { + okAction(dialog) + }, + }, + ], + }), + /** variable-size child container that may be scrollable. */ + m(".dialog-max-height.plr-s.pb.text-break.nav-bg", [ + m( + ".flex.col.rel.mt-s", + { + style: { + height: "80vh", + }, + }, + m(List, { + renderConfig, + state: { + items: events, + loadingStatus: ListLoadingState.Done, + loadingAll: false, + inMultiselect: true, + activeIndex: null, + selectedItems: new Set(), + }, + onLoadMore() { + }, + onRangeSelectionTowards(item: CalendarEvent) { + }, + onRetryLoading() { + }, + onSingleSelection(item: CalendarEvent) { + }, + onSingleTogglingMultiselection(item: CalendarEvent) { + }, + onStopLoading() { + }, + } satisfies ListAttrs), + ), + ]), + ], + }).show() +} + +export function calendarSelectionDialog( + calendars: CalendarInfo[], + userController: UserController, + groupColors: GroupColors, + okAction: (dialog: Dialog, selectedCalendar: CalendarInfo) => unknown, +) { + const availableCalendars = calendars.filter((calendarInfo) => hasCapabilityOnGroup(userController.user, calendarInfo.group, ShareCapability.Write)) + let selectedCalendar = availableCalendars[0] + + const dialog = new Dialog(DialogType.EditSmall, { + view: () => [ + m(DialogHeaderBar, { + left: [ + { + type: ButtonType.Secondary, + label: "cancel_action", + click: () => { + dialog.close() + }, + }, + ], + middle: () => lang.getMaybeLazy("calendar_label"), + right: [ + { + type: ButtonType.Primary, + label: "pricing.select_action", + click: () => { + okAction(dialog, selectedCalendar) + }, + }, + ], + }), + + m(".dialog-max-height.plr-l.pt.pb.text-break.scroll", [ + m(".text-break.selectable", lang.get("calendarImportSelection_label")), + m(DropDownSelector, { + label: "calendar_label", + items: availableCalendars.map((calendarInfo) => { + return { + name: getSharedGroupName(calendarInfo.groupInfo, userController, calendarInfo.shared), + value: calendarInfo, + } + }), + selectedValue: selectedCalendar, + selectionChangedHandler: (v) => (selectedCalendar = v), + icon: BootIcons.Expand, + disabled: availableCalendars.length < 2, + helpLabel: () => renderCalendarColor(selectedCalendar, groupColors), + } satisfies DropDownSelectorAttrs) + ]), + ], + }).show() +} diff --git a/src/calendar-app/calendar/export/CalendarImporterDialog.ts b/src/calendar-app/calendar/export/CalendarImporterDialog.ts index 201affd2e266..bc5aaf724e45 100644 --- a/src/calendar-app/calendar/export/CalendarImporterDialog.ts +++ b/src/calendar-app/calendar/export/CalendarImporterDialog.ts @@ -1,13 +1,13 @@ import type { CalendarEvent, CalendarGroupRoot } from "../../../common/api/entities/tutanota/TypeRefs.js" import { CalendarEventTypeRef, createFile } from "../../../common/api/entities/tutanota/TypeRefs.js" -import { CALENDAR_MIME_TYPE, showFileChooser } from "../../../common/file/FileController" +import { CALENDAR_MIME_TYPE, showFileChooser, showNativeFilePicker } from "../../../common/file/FileController" import { generateEventElementId } from "../../../common/api/common/utils/CommonCalendarUtils" import { showProgressDialog } from "../../../common/gui/dialogs/ProgressDialog" import { ParserError } from "../../../common/misc/parsing/ParserCombinator" import { Dialog } from "../../../common/gui/base/Dialog" import { lang } from "../../../common/misc/LanguageViewModel" import { serializeCalendar } from "./CalendarExporter.js" -import { parseCalendarFile, ParsedEvent } from "./CalendarImporter.js" +import { parseCalendarFile, ParsedEvent, showEventsImportDialog } from "./CalendarImporter.js" import { elementIdPart, isSameId, listIdPart } from "../../../common/api/common/utils/EntityUtils" import type { UserAlarmInfo } from "../../../common/api/entities/sys/TypeRefs.js" import { createDateWrapper, UserAlarmInfoTypeRef } from "../../../common/api/entities/sys/TypeRefs.js" @@ -18,6 +18,7 @@ import { assignEventId, CalendarEventValidity, checkEventValidity, getTimeZone } import { ImportError } from "../../../common/api/common/error/ImportError" import { TranslationKeyType } from "../../../common/misc/TranslationKey" import { AlarmInfoTemplate } from "../../../common/api/worker/facades/lazy/CalendarFacade.js" +import { isApp } from "../../../common/api/common/Env.js" export const enum EventImportRejectionReason { Pre1970, @@ -47,8 +48,8 @@ async function partialImportConfirmation(skippedEvents: CalendarEvent[], confirm ) } -export async function showCalendarImportDialog(calendarGroupRoot: CalendarGroupRoot): Promise { - const parsedEvents: ParsedEvent[] = await selectAndParseIcalFile() +export async function showCalendarImportDialog(calendarGroupRoot: CalendarGroupRoot, events: ParsedEvent[] = []): Promise { + const parsedEvents: ParsedEvent[] = events.length > 0 ? events : await showProgressDialog("loading_msg", selectAndParseIcalFile()) if (parsedEvents.length === 0) return const zone = getTimeZone() const existingEvents = await showProgressDialog("loading_msg", loadAllEvents(calendarGroupRoot)) @@ -60,12 +61,23 @@ export async function showCalendarImportDialog(calendarGroupRoot: CalendarGroupR if (!(await partialImportConfirmation(rejectedEvents.get(EventImportRejectionReason.Inversed) ?? [], "importEndNotAfterStartInEvent_msg", total))) return if (!(await partialImportConfirmation(rejectedEvents.get(EventImportRejectionReason.Pre1970) ?? [], "importPre1970StartInEvent_msg", total))) return - return await importEvents(eventsForCreation) + if (eventsForCreation.length > 0) { + showEventsImportDialog( + eventsForCreation.map((ev) => ev.event), + async (dialog) => { + dialog.close() + await importEvents(eventsForCreation) + }, + "importEvents_label", + ) + } } async function selectAndParseIcalFile(): Promise { try { - const dataFiles = await showFileChooser(true, ["ical", "ics", "ifb", "icalendar"]) + const dataFiles = isApp() + ? await showNativeFilePicker(["ical", "ics", "ifb", "icalendar"]) + : await showFileChooser(true, ["ical", "ics", "ifb", "icalendar"]) const contents = dataFiles.map((file) => parseCalendarFile(file).contents) return contents.flat() } catch (e) { diff --git a/src/calendar-app/calendar/gui/CalendarGuiUtils.ts b/src/calendar-app/calendar/gui/CalendarGuiUtils.ts index acda71d93f7c..598cfc22c330 100644 --- a/src/calendar-app/calendar/gui/CalendarGuiUtils.ts +++ b/src/calendar-app/calendar/gui/CalendarGuiUtils.ts @@ -866,3 +866,14 @@ async function confirmDeleteClose(model: CalendarEventPreviewViewModel, onClose? export function getDisplayEventTitle(title: string): string { return title ?? title !== "" ? title : lang.get("noTitle_label") } + +export function renderCalendarColor(selectedCalendar: CalendarInfo | null, groupColors: Map) { + const color = selectedCalendar ? groupColors.get(selectedCalendar.groupInfo.group) ?? defaultCalendarColor : null + return m(".mt-xs", { + style: { + width: "100px", + height: "10px", + background: color ? "#" + color : "transparent", + }, + }) +} diff --git a/src/calendar-app/calendar/view/CalendarRow.ts b/src/calendar-app/calendar/gui/CalendarRow.ts similarity index 97% rename from src/calendar-app/calendar/view/CalendarRow.ts rename to src/calendar-app/calendar/gui/CalendarRow.ts index 35901f38a27a..8bc13e6673c9 100644 --- a/src/calendar-app/calendar/view/CalendarRow.ts +++ b/src/calendar-app/calendar/gui/CalendarRow.ts @@ -9,8 +9,8 @@ import { ViewHolder } from "../../../common/gui/base/List.js" import { styles } from "../../../common/gui/styles.js" import { DefaultAnimationTime } from "../../../common/gui/animation/Animations.js" -import { formatEventDuration, getDisplayEventTitle, getEventColor, getGroupColors } from "../gui/CalendarGuiUtils.js" -import { GroupColors } from "./CalendarView.js" +import { formatEventDuration, getDisplayEventTitle, getEventColor, getGroupColors } from "./CalendarGuiUtils.js" +import { GroupColors } from "../view/CalendarView.js" import { lang } from "../../../common/misc/LanguageViewModel.js" export class CalendarRow implements VirtualRow { diff --git a/src/calendar-app/calendar/gui/eventeditor-view/CalendarEventEditView.ts b/src/calendar-app/calendar/gui/eventeditor-view/CalendarEventEditView.ts index b5be4d1ca71a..488eb6f2477c 100644 --- a/src/calendar-app/calendar/gui/eventeditor-view/CalendarEventEditView.ts +++ b/src/calendar-app/calendar/gui/eventeditor-view/CalendarEventEditView.ts @@ -21,6 +21,7 @@ import { CalendarEventModel, CalendarOperation, ReadonlyReason } from "../evente import { getSharedGroupName } from "../../../../common/sharing/GroupUtils.js" +import { createAlarmIntervalItems, createCustomRepeatRuleUnitValues, humanDescriptionForAlarmInterval, renderCalendarColor } from "../CalendarGuiUtils.js" import { RemindersEditor, RemindersEditorAttrs } from "../RemindersEditor.js" export type CalendarEventEditViewAttrs = { @@ -197,22 +198,11 @@ export class CalendarEventEditView implements Component this.renderCalendarColor(model.editModels.whoModel.selectedCalendar, vnode.attrs.groupColors), + helpLabel: () => renderCalendarColor(model.editModels.whoModel.selectedCalendar, vnode.attrs.groupColors), } satisfies DropDownSelectorAttrs), ) } - private renderCalendarColor(selectedCalendar: CalendarInfo | null, groupColors: Map) { - const color = selectedCalendar ? groupColors.get(selectedCalendar.groupInfo.group) ?? defaultCalendarColor : null - return m(".mt-xs", { - style: { - width: "100px", - height: "10px", - background: color ? "#" + color : "transparent", - }, - }) - } - private renderRemindersEditor(vnode: Vnode): Children { if (!vnode.attrs.model.editModels.alarmModel.canEditReminders) return null const { alarmModel } = vnode.attrs.model.editModels diff --git a/src/calendar-app/calendar/search/view/SearchListView.ts b/src/calendar-app/calendar/search/view/SearchListView.ts index 7b4b311d268f..7b2b42ba1c0c 100644 --- a/src/calendar-app/calendar/search/view/SearchListView.ts +++ b/src/calendar-app/calendar/search/view/SearchListView.ts @@ -11,7 +11,7 @@ import { lang } from "../../../../common/misc/LanguageViewModel.js" import { theme } from "../../../../common/gui/theme.js" import { VirtualRow } from "../../../../common/gui/base/ListUtils.js" import { styles } from "../../../../common/gui/styles.js" -import { KindaCalendarRow } from "../../view/CalendarRow.js" +import { KindaCalendarRow } from "../../gui/CalendarRow.js" assertMainOrNode() diff --git a/src/calendar-app/calendar/view/CalendarView.ts b/src/calendar-app/calendar/view/CalendarView.ts index 9300f2858d58..8e844ec669b0 100644 --- a/src/calendar-app/calendar/view/CalendarView.ts +++ b/src/calendar-app/calendar/view/CalendarView.ts @@ -727,7 +727,7 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView this.onPressedEditCalendar(calendarInfo, colorValue, existingGroupSettings, userSettingsGroupRoot, sharedCalendar), + click: () => this.onPressedEditCalendar(groupInfo, colorValue, existingGroupSettings, userSettingsGroupRoot, sharedCalendar), }, { label: "sharing_label", @@ -740,7 +740,7 @@ export class CalendarView extends BaseTopLevelView implements TopLevelView this.fileApp, async () => this.pushService, - async (filesUris: ReadonlyArray) => noOp(), + this.handleFileImport.bind(this), ), cryptoFacade, calendarFacade, @@ -724,6 +725,38 @@ class CalendarLocator { } } + private async handleFileImport(filesUris: ReadonlyArray) { + const files = await this.fileApp.getFilesMetaData(filesUris) + const areAllICSFiles = files.every(file => file.mimeType === CALENDAR_MIME_TYPE) + if (areAllICSFiles) { + const calendarModel = await this.calendarModel() + const groupSettings = this.logins.getUserController().userSettingsGroupRoot.groupSettings + const calendarInfos = await calendarModel.getCalendarInfos() + const groupColors: Map = groupSettings.reduce((acc, gc) => { + acc.set(gc.group, gc.color) + return acc + }, new Map()) + + const { calendarSelectionDialog, parseCalendarFile } = await import("../calendar-app/calendar/export/CalendarImporter.js") + const { showCalendarImportDialog } = await import("../calendar-app/calendar/export/CalendarImporterDialog.js") + + let parsedEvents: ParsedEvent[] = [] + + for (const fileRef of files) { + const dataFile = await this.fileApp.readDataFile(fileRef.location) + if (dataFile == null) continue + + const data = parseCalendarFile(dataFile) + parsedEvents.push(...data.contents) + } + + calendarSelectionDialog(Array.from(calendarInfos.values()), this.logins.getUserController(), groupColors, (dialog, selectedCalendar) => { + dialog.close() + showCalendarImportDialog(selectedCalendar.groupRoot, parsedEvents) + }) + } + } + readonly calendarModel: () => Promise = lazyMemoized(async () => { const { DefaultDateProvider } = await import("../common/calendar/date/CalendarUtils") const { CalendarModel } = await import("./calendar/model/CalendarModel") diff --git a/src/common/file/FileController.ts b/src/common/file/FileController.ts index 252fe8a9cc66..c31d824589a0 100644 --- a/src/common/file/FileController.ts +++ b/src/common/file/FileController.ts @@ -1,6 +1,6 @@ import { Dialog } from "../gui/base/Dialog.js" import { convertToDataFile, createDataFile, DataFile } from "../api/common/DataFile" -import { assertMainOrNode } from "../api/common/Env" +import { assertMainOrNode, isApp } from "../api/common/Env" import { assertNotNull, neverNull, promiseMap } from "@tutao/tutanota-utils" import { lang, TranslationKey } from "../misc/LanguageViewModel.js" import { BrowserType } from "../misc/ClientConstants.js" @@ -19,6 +19,9 @@ import { elementIdPart, listIdPart } from "../api/common/utils/EntityUtils.js" import { BlobReferencingInstance } from "../api/worker/facades/BlobAccessTokenFacade.js" import { CryptoError } from "@tutao/tutanota-crypto/error.js" import { isOfflineError } from "../api/common/utils/ErrorUtils.js" +import { locator } from "../api/main/CommonLocator.js" +import { PermissionError } from "../api/common/error/PermissionError.js" +import { FileNotFoundError } from "../api/common/error/FileNotFoundError.js" assertMainOrNode() export const CALENDAR_MIME_TYPE = "text/calendar" @@ -325,3 +328,33 @@ export async function guiDownload(downloadPromise: Promise, progress?: str await handleDownloadErrors(e, Dialog.message) } } + +export async function showNativeFilePicker(fileTypes?: Array): Promise> { + if (isApp()) { + const rect = { width: 0, height: 0, left: 0, top: 0 } as DOMRect + try { + const fileApp = locator.fileApp + const fileList = await fileApp.openFileChooser(rect, fileTypes) + const readFiles: DataFile[] = [] + for (const file of fileList) { + const data = await fileApp.readDataFile(file.location) + + if (!data) continue + + readFiles.push(data) + } + + return Promise.resolve(readFiles) + } catch (err) { + if (err instanceof PermissionError) { + Dialog.message("fileAccessDeniedMobile_msg") + } else if (err instanceof FileNotFoundError) { + Dialog.message("couldNotAttachFile_msg") + } + + console.log("Failed to import calendar files", err) + } + } + + return Promise.resolve([]) +} diff --git a/src/common/misc/TranslationKey.ts b/src/common/misc/TranslationKey.ts index 052f0620bfec..dac11021bef3 100644 --- a/src/common/misc/TranslationKey.ts +++ b/src/common/misc/TranslationKey.ts @@ -1732,3 +1732,6 @@ export type TranslationKeyType = | "you_label" | "emptyString_msg" | "calendarDefaultReminder_label" + | "importEvents_label" + | "calendarImportSelection_label" + | "icsInSharingFiles_msg" diff --git a/src/common/native/main/WebCommonNativeFacade.ts b/src/common/native/main/WebCommonNativeFacade.ts index b9f24f49305f..d595169ec552 100644 --- a/src/common/native/main/WebCommonNativeFacade.ts +++ b/src/common/native/main/WebCommonNativeFacade.ts @@ -22,7 +22,8 @@ export class WebCommonNativeFacade implements CommonNativeFacade { private readonly fileApp: lazyAsync, private readonly pushService: lazyAsync, private readonly fileImportHandler: (filesUris: ReadonlyArray) => unknown, - ) {} + ) { + } /** * create a mail editor as requested from the native side, ie because a @@ -57,6 +58,7 @@ export class WebCommonNativeFacade implements CommonNativeFacade { const fileApp = await this.fileApp() const files = await fileApp.getFilesMetaData(filesUris) const allFilesAreVCards = files.length > 0 && files.every((file) => getAttachmentType(file.mimeType) === AttachmentType.CONTACT) + const allFilesAreICS = files.length > 0 && files.every((file) => getAttachmentType(file.mimeType) === AttachmentType.CALENDAR) let willImport = false if (allFilesAreVCards) { @@ -67,6 +69,14 @@ export class WebCommonNativeFacade implements CommonNativeFacade { }, { text: "attachFiles_action", value: false }, ]) + } else if (allFilesAreICS) { + willImport = await Dialog.choice("icsInSharingFiles_msg", [ + { + text: "import_action", + value: true, + }, + { text: "attachFiles_action", value: false }, + ]) } if (willImport) { @@ -75,13 +85,13 @@ export class WebCommonNativeFacade implements CommonNativeFacade { const address = (addresses && addresses[0]) || "" const recipients = address ? { - to: [ - { - name: "", - address: address, - }, - ], - } + to: [ + { + name: "", + address: address, + }, + ], + } : {} editor = await newMailEditorFromTemplate( mailboxDetails, @@ -185,6 +195,8 @@ export class WebCommonNativeFacade implements CommonNativeFacade { * @param filesUris List of files URI to be parsed */ async handleFileImport(filesUris: ReadonlyArray): Promise { + // Since we might be handling calendar files, we must wait for full login + await this.logins.waitForFullLogin() await this.fileImportHandler(filesUris) } } diff --git a/src/mail-app/mailLocator.ts b/src/mail-app/mailLocator.ts index fc2a877b2996..c19081632274 100644 --- a/src/mail-app/mailLocator.ts +++ b/src/mail-app/mailLocator.ts @@ -8,7 +8,7 @@ import { EntityClient } from "../common/api/common/EntityClient.js" import { ProgressTracker } from "../common/api/main/ProgressTracker.js" import { CredentialsProvider } from "../common/misc/credentials/CredentialsProvider.js" import { bootstrapWorker, WorkerClient } from "../common/api/main/WorkerClient.js" -import { FileController, guiDownload } from "../common/file/FileController.js" +import { CALENDAR_MIME_TYPE, FileController, guiDownload, VCARD_MIME_TYPES } from "../common/file/FileController.js" import { SecondFactorHandler } from "../common/misc/2fa/SecondFactorHandler.js" import { WebauthnClient } from "../common/misc/2fa/webauthn/WebauthnClient.js" import { LoginFacade } from "../common/api/worker/facades/LoginFacade.js" @@ -75,7 +75,6 @@ import { RecipientsSearchModel } from "../common/misc/RecipientsSearchModel.js" import { PermissionError } from "../common/api/common/error/PermissionError.js" import { ConversationViewModel, ConversationViewModelFactory } from "./mail/view/ConversationViewModel.js" import { CreateMailViewerOptions } from "./mail/view/MailViewer.js" -import type { ContactImporter } from "./contacts/ContactImporter.js" import { MailViewerViewModel } from "./mail/view/MailViewerViewModel.js" import { ExternalLoginViewModel } from "./mail/view/ExternalLoginView.js" import { NativeInterfaceMain } from "../common/native/main/NativeInterfaceMain.js" @@ -118,6 +117,8 @@ import { AppStorePaymentPicker } from "../common/misc/AppStorePaymentPicker.js" import { MAIL_PREFIX } from "../common/misc/RouteChange.js" import { getDisplayedSender } from "../common/api/common/CommonMailUtils.js" import { AppType } from "../common/misc/ClientConstants.js" +import type { ParsedEvent } from "../calendar-app/calendar/export/CalendarImporter.js" +import type { ContactImporter } from "./contacts/ContactImporter.js" assertMainOrNode() @@ -279,8 +280,8 @@ class MailLocator { readonly mailOpenedListener: MailOpenedListener = { onEmailOpened: isDesktop() ? (mail) => { - this.desktopSystemFacade.sendSocketMessage(getDisplayedSender(mail).address) - } + this.desktopSystemFacade.sendSocketMessage(getDisplayedSender(mail).address) + } : noOp, } @@ -564,7 +565,7 @@ class MailLocator { const domainConfig = isBrowser() ? mailLocator.domainConfigProvider().getDomainConfigForHostname(location.hostname, location.protocol, location.port) : // in this case, we know that we have a staticUrl set that we need to use - mailLocator.domainConfigProvider().getCurrentDomainConfig() + mailLocator.domainConfigProvider().getCurrentDomainConfig() return new LoginViewModel( mailLocator.logins, @@ -694,7 +695,6 @@ class MailLocator { const { WebInterWindowEventFacade } = await import("../common/native/main/WebInterWindowEventFacade.js") const { WebAuthnFacadeSendDispatcher } = await import("../common/native/common/generatedipc/WebAuthnFacadeSendDispatcher.js") const { createNativeInterfaces, createDesktopInterfaces } = await import("../common/native/main/NativeInterfaceFactory.js") - const { parseContacts } = await import("./contacts/ContactImporter.js") this.webMobileFacade = new WebMobileFacade(this.connectivityModel, this.mailModel, MAIL_PREFIX) this.nativeInterfaces = createNativeInterfaces( @@ -707,17 +707,7 @@ class MailLocator { this.usageTestController, async () => this.fileApp, async () => this.pushService, - async (filesUris: ReadonlyArray) => { - const importer = await mailLocator.contactImporter() - - // For now, we just handle .vcf files, so we don't need to care about the file type - const files = await mailLocator.fileApp.getFilesMetaData(filesUris) - const contacts = await parseContacts(files, mailLocator.fileApp) - const vCardData = contacts.join("\n") - const contactListId = assertNotNull(await mailLocator.contactModel.getContactListId()) - - await importer.importContactsFromFile(vCardData, contactListId) - }, + this.handleFileImport.bind(this), ), cryptoFacade, calendarFacade, @@ -874,6 +864,49 @@ class MailLocator { ) }) + private async handleFileImport(filesUris: ReadonlyArray) { + const files = await this.fileApp.getFilesMetaData(filesUris) + const areAllFilesVCard = files.every(file => file.mimeType === VCARD_MIME_TYPES.X_VCARD || file.mimeType === VCARD_MIME_TYPES.VCARD) + const areAllFilesICS = files.every(file => file.mimeType === CALENDAR_MIME_TYPE) + + if (areAllFilesVCard) { + const importer = await this.contactImporter() + const { parseContacts } = await import("../mail-app/contacts/ContactImporter.js") + // For now, we just handle .vcf files, so we don't need to care about the file type + const contacts = await parseContacts(files, this.fileApp) + const vCardData = contacts.join("\n") + const contactListId = assertNotNull(await this.contactModel.getContactListId()) + + await importer.importContactsFromFile(vCardData, contactListId) + } else if (areAllFilesICS) { + const calendarModel = await this.calendarModel() + const groupSettings = this.logins.getUserController().userSettingsGroupRoot.groupSettings + const calendarInfos = await calendarModel.getCalendarInfos() + const groupColors: Map = groupSettings.reduce((acc, gc) => { + acc.set(gc.group, gc.color) + return acc + }, new Map()) + + const { calendarSelectionDialog, parseCalendarFile } = await import("../calendar-app/calendar/export/CalendarImporter.js") + const { showCalendarImportDialog } = await import("../calendar-app/calendar/export/CalendarImporterDialog.js") + + let parsedEvents: ParsedEvent[] = [] + + for (const fileRef of files) { + const dataFile = await this.fileApp.readDataFile(fileRef.location) + if (dataFile == null) continue + + const data = parseCalendarFile(dataFile) + parsedEvents.push(...data.contents) + } + + calendarSelectionDialog(Array.from(calendarInfos.values()), this.logins.getUserController(), groupColors, (dialog, selectedCalendar) => { + dialog.close() + showCalendarImportDialog(selectedCalendar.groupRoot, parsedEvents) + }) + } + } + private alarmScheduler: () => Promise = lazyMemoized(async () => { const { AlarmScheduler } = await import("../common/calendar/date/AlarmScheduler") const { DefaultDateProvider } = await import("../common/calendar/date/CalendarUtils") diff --git a/src/mail-app/search/view/SearchListView.ts b/src/mail-app/search/view/SearchListView.ts index 1ab0caa21210..2c192930dbe7 100644 --- a/src/mail-app/search/view/SearchListView.ts +++ b/src/mail-app/search/view/SearchListView.ts @@ -14,7 +14,7 @@ import { lang } from "../../../common/misc/LanguageViewModel.js" import { theme } from "../../../common/gui/theme.js" import { VirtualRow } from "../../../common/gui/base/ListUtils.js" import { styles } from "../../../common/gui/styles.js" -import { KindaCalendarRow } from "../../../calendar-app/calendar/view/CalendarRow.js" +import { KindaCalendarRow } from "../../../calendar-app/calendar/gui/CalendarRow.js" import { AllIcons } from "../../../common/gui/base/Icon.js" assertMainOrNode() diff --git a/src/mail-app/translations/de.ts b/src/mail-app/translations/de.ts index 93eccdaed3a6..3cdbd5d6a47d 100644 --- a/src/mail-app/translations/de.ts +++ b/src/mail-app/translations/de.ts @@ -1750,6 +1750,10 @@ export default { "yourCalendars_label": "Deine Kalender", "yourFolders_action": "DEINE ORDNER", "yourMessage_label": "Deine Nachricht", - "you_label": "Du" + "you_label": "Du", + "importEvents_label": "Terminen wird importiert", + "calendarDefaultReminder_label": "Standard-Terminerinnerung", + "calendarImportSelection_label": "Wählen Sie einen Kalender aus, in den Sie Ihre Ereignisse importieren möchten.", + "icsInSharingFiles_msg": "Eine oder mehrere Kalenderdateien wurden erkannt. Möchten Sie sie importieren oder anhängen?", } } diff --git a/src/mail-app/translations/de_sie.ts b/src/mail-app/translations/de_sie.ts index 5b6c548d4fb6..63b90dcf7e4e 100644 --- a/src/mail-app/translations/de_sie.ts +++ b/src/mail-app/translations/de_sie.ts @@ -1750,6 +1750,10 @@ export default { "yourCalendars_label": "Deine Kalender", "yourFolders_action": "Ihre ORDNER", "yourMessage_label": "Ihre Nachricht", - "you_label": "Sie" + "you_label": "Sie", + "importEvents_label": "Terminen wird importiert", + "calendarDefaultReminder_label": "Standard-Terminerinnerung", + "calendarImportSelection_label": "Wählen Sie einen Kalender aus, in den Sie Ihre Ereignisse importieren möchten.", + "icsInSharingFiles_msg": "Eine oder mehrere Kalenderdateien wurden erkannt. Möchten Sie sie importieren oder anhängen?", } } diff --git a/src/mail-app/translations/en.ts b/src/mail-app/translations/en.ts index 0143c51b5583..4ce1833c4e95 100644 --- a/src/mail-app/translations/en.ts +++ b/src/mail-app/translations/en.ts @@ -1746,6 +1746,10 @@ export default { "yourCalendars_label": "Your calendars", "yourFolders_action": "YOUR FOLDERS", "yourMessage_label": "Your message", - "you_label": "You" + "you_label": "You", + "importEvents_label": "Import Events", + "calendarDefaultReminder_label": "Default reminder before event", + "calendarImportSelection_label": "Select a calendar to import your events into", + "icsInSharingFiles_msg": "One or more calendar files were detected. Would you like to import or attach them?", } }