From 58be5796b0c2b19aacf52d4a1c179aeb80924443 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Fri, 16 Jul 2021 13:53:46 -0400 Subject: [PATCH] Resolved Safari Date/Time Issues In Send (#428) * cleaned up date and time logic for Send * time rename * fixed casing * added suffix --- .../src/components/send/add-edit.component.ts | 249 +------------- .../components/send/efflux-dates.component.ts | 325 ++++++++++++++++++ 2 files changed, 330 insertions(+), 244 deletions(-) create mode 100644 angular/src/components/send/efflux-dates.component.ts diff --git a/angular/src/components/send/add-edit.component.ts b/angular/src/components/send/add-edit.component.ts index f497215340..a587a2df9a 100644 --- a/angular/src/components/send/add-edit.component.ts +++ b/angular/src/components/send/add-edit.component.ts @@ -26,18 +26,6 @@ import { SendView } from 'jslib-common/models/view/sendView'; import { EncArrayBuffer } from 'jslib-common/models/domain/encArrayBuffer'; import { Send } from 'jslib-common/models/domain/send'; -// TimeOption is used for the dropdown implementation of custom times -// Standard = displayed time; Military = stored time -interface TimeOption { - standard: string; - military: string; -} - -enum DateField { - DeletionDate = 'deletion', - ExpriationDate = 'expiration', -} - @Directive() export class AddEditComponent implements OnInit { @Input() sendId: string; @@ -52,11 +40,7 @@ export class AddEditComponent implements OnInit { disableHideEmail = false; send: SendView; deletionDate: string; - deletionDateFallback: string; - deletionTimeFallback: string; - expirationDate: string = null; - expirationDateFallback: string; - expirationTimeFallback: string; + expirationDate: string; hasPassword: boolean; password: string; showPassword = false; @@ -64,20 +48,11 @@ export class AddEditComponent implements OnInit { deletePromise: Promise; sendType = SendType; typeOptions: any[]; - deletionDateOptions: any[]; - expirationDateOptions: any[]; - deletionDateSelect = 168; - expirationDateSelect: number = null; canAccessPremium = true; emailVerified = true; alertShown = false; showOptions = false; - safariDeletionTime: string; - safariExpirationTime: string; - safariDeletionTimeOptions: TimeOption[]; - safariExpirationTimeOptions: TimeOption[]; - private sendLinkBaseUrl: string; constructor(protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, @@ -88,19 +63,6 @@ export class AddEditComponent implements OnInit { { name: i18nService.t('sendTypeFile'), value: SendType.File }, { name: i18nService.t('sendTypeText'), value: SendType.Text }, ]; - this.deletionDateOptions = this.expirationDateOptions = [ - { name: i18nService.t('oneHour'), value: 1 }, - { name: i18nService.t('oneDay'), value: 24 }, - { name: i18nService.t('days', '2'), value: 48 }, - { name: i18nService.t('days', '3'), value: 72 }, - { name: i18nService.t('days', '7'), value: 168 }, - { name: i18nService.t('days', '30'), value: 720 }, - { name: i18nService.t('custom'), value: 0 }, - ]; - this.expirationDateOptions = [ - { name: i18nService.t('never'), value: null }, - ].concat([...this.deletionDateOptions]); - const webVaultUrl = this.environmentService.getWebVaultUrl(); if (webVaultUrl == null) { this.sendLinkBaseUrl = 'https://send.bitwarden.com/#'; @@ -140,16 +102,9 @@ export class AddEditComponent implements OnInit { ); } - get expirationDateTimeFallback() { - return this.nullOrWhiteSpaceCount([this.expirationDateFallback, this.expirationTimeFallback]) > 0 ? - null : - `${this.formatDateFallbacks(this.expirationDateFallback)}T${this.expirationTimeFallback}`; - } - - get deletionDateTimeFallback() { - return this.nullOrWhiteSpaceCount([this.deletionDateFallback, this.deletionTimeFallback]) > 0 ? - null : - `${this.formatDateFallbacks(this.deletionDateFallback)}T${this.deletionTimeFallback}`; + setDates(event: {deletionDate: string, expirationDate: string}) { + this.deletionDate = event.deletionDate; + this.expirationDate = event.expirationDate; } async load() { @@ -193,64 +148,9 @@ export class AddEditComponent implements OnInit { } this.hasPassword = this.send.password != null && this.send.password.trim() !== ''; - - // Parse dates - if (!this.isDateTimeLocalSupported) { - const deletionDateParts = this.dateToSplitString(this.send.deletionDate); - if (deletionDateParts !== undefined && deletionDateParts.length > 0) { - this.deletionDateFallback = deletionDateParts[0]; - this.deletionTimeFallback = deletionDateParts[1]; - if (this.isSafari) { - this.safariDeletionTime = this.deletionTimeFallback; - } - } - - const expirationDateParts = this.dateToSplitString(this.send.expirationDate); - if (expirationDateParts !== undefined && expirationDateParts.length > 0) { - this.expirationDateFallback = expirationDateParts[0]; - this.expirationTimeFallback = expirationDateParts[1]; - if (this.isSafari) { - this.safariExpirationTime = this.expirationTimeFallback; - } - } - } else { - this.deletionDate = this.dateToString(this.send.deletionDate); - this.expirationDate = this.dateToString(this.send.expirationDate); - } - - if (this.isSafari) { - this.safariDeletionTimeOptions = this.safariTimeOptions(DateField.DeletionDate); - this.safariExpirationTimeOptions = this.safariTimeOptions(DateField.ExpriationDate); - } } async submit(): Promise { - if (!this.isDateTimeLocalSupported) { - if (this.isSafari) { - this.expirationTimeFallback = this.safariExpirationTime ?? this.expirationTimeFallback; - this.deletionTimeFallback = this.safariDeletionTime ?? this.deletionTimeFallback; - } - this.deletionDate = this.deletionDateTimeFallback; - if (this.expirationDateTimeFallback != null && isNaN(Date.parse(this.expirationDateTimeFallback))) { - this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), - this.i18nService.t('expirationDateIsInvalid')); - return; - } - if (isNaN(Date.parse(this.deletionDateTimeFallback))) { - this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), - this.i18nService.t('deletionDateIsInvalid')); - return; - } - if (this.nullOrWhiteSpaceCount([this.expirationDateFallback, this.expirationTimeFallback]) === 1) { - this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), - this.i18nService.t('expirationDateAndTimeRequired')); - return; - } - if (this.editMode || this.expirationDateSelect === 0) { - this.expirationDate = this.expirationDateTimeFallback; - } - } - if (this.disableSend) { this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), this.i18nService.t('sendDisabledWarning')); @@ -281,20 +181,6 @@ export class AddEditComponent implements OnInit { } } - if (!this.editMode) { - const now = new Date(); - if (this.deletionDateSelect > 0) { - const d = new Date(); - d.setHours(now.getHours() + this.deletionDateSelect); - this.deletionDate = this.dateToString(d); - } - if (this.expirationDateSelect != null && this.expirationDateSelect > 0) { - const d = new Date(); - d.setHours(now.getHours() + this.expirationDateSelect); - this.expirationDate = this.dateToString(d); - } - } - if (this.password != null && this.password.trim() === '') { this.password = null; } @@ -323,7 +209,6 @@ export class AddEditComponent implements OnInit { } } }); - try { await this.formPromise; return true; @@ -331,13 +216,6 @@ export class AddEditComponent implements OnInit { return false; } - clearExpiration() { - this.expirationDate = null; - this.expirationDateFallback = null; - this.expirationTimeFallback = null; - this.safariExpirationTime = null; - } - async copyLinkToClipboard(link: string): Promise { return Promise.resolve(this.platformUtilsService.copyToClipboard(link)); } @@ -367,8 +245,7 @@ export class AddEditComponent implements OnInit { } typeChanged() { - if (this.send.type === SendType.File && !this.alertShown) - { + if (this.send.type === SendType.File && !this.alertShown) { if (!this.canAccessPremium) { this.alertShown = true; this.messagingService.send('premiumRequired'); @@ -383,12 +260,6 @@ export class AddEditComponent implements OnInit { this.showOptions = !this.showOptions; } - expirationDateFallbackChanged() { - this.isSafari ? - this.safariExpirationTime = this.safariExpirationTime ?? '00:00' : - this.expirationTimeFallback = this.expirationTimeFallback ?? this.datePipe.transform(new Date(), 'HH:mm'); - } - protected async loadSend(): Promise { return this.sendService.get(this.sendId); } @@ -411,118 +282,8 @@ export class AddEditComponent implements OnInit { return sendData; } - protected dateToString(d: Date) { - return d == null ? null : this.datePipe.transform(d, 'yyyy-MM-ddTHH:mm'); - } - - protected formatDateFallbacks(dateString: string) { - try { - // The Firefox date picker doesn't supply a time, safari's polyfill does. - // Unknown if Safari's native date picker will or not when it releases. - if (!this.isSafari) { - dateString += ' 00:00'; - } - return this.datePipe.transform(new Date(dateString), 'yyyy-MM-dd'); - } catch { - // this should never happen - this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), - this.i18nService.t('dateParsingError')); - } - } - - protected dateToSplitString(d: Date) { - if (d != null) { - const date = !this.isSafari ? - this.datePipe.transform(d, 'yyyy-MM-dd') : - this.datePipe.transform(d, 'MM/dd/yyyy'); - const time = this.datePipe.transform(d, 'HH:mm'); - return [date, time]; - } - } - protected togglePasswordVisible() { this.showPassword = !this.showPassword; document.getElementById('password').focus(); } - - protected nullOrWhiteSpaceCount(strarray: string[]): number { - return strarray.filter(str => str == null || str.trim() === '').length; - } - - protected safariTimeOptions(field: DateField): TimeOption[] { - // init individual arrays for major sort groups - const noon: TimeOption[] = []; - const midnight: TimeOption[] = []; - const ams: TimeOption[] = []; - const pms: TimeOption[] = []; - - // determine minute skip (5 min, 10 min, 15 min, etc.) - const minuteIncrementer = 15; - - // loop through each hour on a 12 hour system - for (let h = 1; h <= 12; h++) { - // loop through each minute in the hour using the skip to incriment - for (let m = 0; m < 60; m += minuteIncrementer) { - // init the final strings that will be added to the lists - let hour = h.toString(); - let minutes = m.toString(); - - // add prepending 0s to single digit hours/minutes - if (h < 10) { - hour = '0' + hour; - } - if (m < 10) { - minutes = '0' + minutes; - } - - // build time strings and push to relevant sort groups - if (h === 12) { - const midnightOption: TimeOption = { - standard: `${hour}:${minutes} AM`, - military: `00:${minutes}`, - }; - midnight.push(midnightOption); - - const noonOption: TimeOption = { - standard: `${hour}:${minutes} PM`, - military: `${hour}:${minutes}`, - }; - noon.push(noonOption); - } else { - const amOption: TimeOption = { - standard: `${hour}:${minutes} AM`, - military: `${hour}:${minutes}`, - }; - ams.push(amOption); - - const pmOption: TimeOption = { - standard: `${hour}:${minutes} PM`, - military: `${h + 12}:${minutes}`, - }; - pms.push(pmOption); - } - } - } - - // bring all the arrays together in the right order - const validTimes = [...midnight, ...ams, ...noon, ...pms]; - - // determine if an unsupported value already exists on the send & add that to the top of the option list - // example: if the Send was created with a different client - if (field === DateField.ExpriationDate && this.expirationDateTimeFallback != null && this.editMode) { - const previousValue: TimeOption = { - standard: this.datePipe.transform(this.expirationDateTimeFallback, 'hh:mm a'), - military: this.datePipe.transform(this.expirationDateTimeFallback, 'HH:mm'), - }; - return [previousValue, { standard: null, military: null }, ...validTimes]; - } else if (field === DateField.DeletionDate && this.deletionDateTimeFallback != null && this.editMode) { - const previousValue: TimeOption = { - standard: this.datePipe.transform(this.deletionDateTimeFallback, 'hh:mm a'), - military: this.datePipe.transform(this.deletionDateTimeFallback, 'HH:mm'), - }; - return [previousValue, ...validTimes]; - } else { - return [{ standard: null, military: null }, ...validTimes]; - } - } } diff --git a/angular/src/components/send/efflux-dates.component.ts b/angular/src/components/send/efflux-dates.component.ts new file mode 100644 index 0000000000..3645c0d875 --- /dev/null +++ b/angular/src/components/send/efflux-dates.component.ts @@ -0,0 +1,325 @@ +import { DatePipe } from '@angular/common'; +import { + Directive, + EventEmitter, + Input, + OnInit, + Output +} from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; + +import { I18nService } from 'jslib-common/abstractions/i18n.service'; +import { PlatformUtilsService } from 'jslib-common/abstractions/platformUtils.service'; + +// Different BrowserPath = different controls. +enum BrowserPath { + // Native datetime-locale. + // We are happy. + Default = 'default', + + // Native date and time inputs, but no datetime-locale. + // We use individual date and time inputs and create a datetime programatically on submit. + Firefox = 'firefox', + + // No native date, time, or datetime-locale inputs. + // We use a polyfill for dates and a dropdown for times. + Safari = 'safari', +} + +enum DateField { + DeletionDate = 'deletion', + ExpriationDate = 'expiration', +} + +// Value = hours +enum DatePreset { + OneHour = 1, + OneDay = 24, + TwoDays = 48, + ThreeDays = 72, + SevenDays = 168, + ThirtyDays = 720, + Custom = 0, + Never = null, +} + +// TimeOption is used for the dropdown implementation of custom times +// twelveHour = displayed time; twentyFourHour = time used in logic +interface TimeOption { + twelveHour: string; + twentyFourHour: string; +} + +@Directive() +export class EffluxDatesComponent implements OnInit { + @Input() readonly initialDeletionDate: Date; + @Input() readonly initialExpirationDate: Date; + @Input() readonly editMode: boolean; + @Input() readonly disabled: boolean; + + @Output() datesChanged = new EventEmitter<{deletionDate: string, expirationDate: string}>(); + + get browserPath(): BrowserPath { + if (this.platformUtilsService.isFirefox()) { + return BrowserPath.Firefox; + } else if (this.platformUtilsService.isSafari()) { + return BrowserPath.Safari; + } + return BrowserPath.Default; + } + + datesForm = new FormGroup({ + selectedDeletionDatePreset: new FormControl(), + selectedExpirationDatePreset: new FormControl(), + defaultDeletionDateTime: new FormControl(), + defaultExpirationDateTime: new FormControl(), + fallbackDeletionDate: new FormControl(), + fallbackDeletionTime: new FormControl(), + fallbackExpirationDate: new FormControl(), + fallbackExpirationTime: new FormControl(), + }); + + deletionDatePresets: any[] = [ + { name: this.i18nService.t('oneHour'), value: DatePreset.OneHour }, + { name: this.i18nService.t('oneDay'), value: DatePreset.OneDay }, + { name: this.i18nService.t('days', '2'), value: DatePreset.TwoDays }, + { name: this.i18nService.t('days', '3'), value: DatePreset.ThreeDays }, + { name: this.i18nService.t('days', '7'), value: DatePreset.SevenDays }, + { name: this.i18nService.t('days', '30'), value: DatePreset.ThirtyDays }, + { name: this.i18nService.t('custom'), value: DatePreset.Custom }, + ]; + + expirationDatePresets: any[] = [ + { name: this.i18nService.t('never'), value: DatePreset.Never }, + ].concat([...this.deletionDatePresets]); + + get selectedDeletionDatePreset(): FormControl { + return this.datesForm.get('selectedDeletionDatePreset') as FormControl; + } + + get selectedExpirationDatePreset(): FormControl { + return this.datesForm.get('selectedExpirationDatePreset') as FormControl; + } + + get defaultDeletionDateTime(): FormControl { + return this.datesForm.get('defaultDeletionDateTime') as FormControl; + } + + get defaultExpirationDateTime(): FormControl { + return this.datesForm.get('defaultExpirationDateTime') as FormControl; + } + + get fallbackDeletionDate(): FormControl { + return this.datesForm.get('fallbackDeletionDate') as FormControl; + } + + get fallbackDeletionTime(): FormControl { + return this.datesForm.get('fallbackDeletionTime') as FormControl; + } + + get fallbackExpirationDate(): FormControl { + return this.datesForm.get('fallbackExpirationDate') as FormControl; + } + + get fallbackExpirationTime(): FormControl { + return this.datesForm.get('fallbackExpirationTime') as FormControl; + } + + // Should be able to call these at any time and compute a submitable value + get formattedDeletionDate(): string { + switch (this.selectedDeletionDatePreset.value as DatePreset) { + case DatePreset.Never: + this.selectedDeletionDatePreset.setValue(DatePreset.SevenDays); + return this.formattedDeletionDate; + case DatePreset.Custom: + switch (this.browserPath) { + case BrowserPath.Safari: + case BrowserPath.Firefox: + return this.fallbackDeletionDate.value + 'T' + this.fallbackDeletionTime.value; + default: + return this.defaultDeletionDateTime.value; + } + default: + const now = new Date(); + const miliseconds = now.setTime(now.getTime() + + (this.selectedDeletionDatePreset.value as number * 60 * 60 * 1000)) ; + return new Date(miliseconds).toString(); + } + } + + get formattedExpirationDate(): string { + switch (this.selectedExpirationDatePreset.value as DatePreset) { + case DatePreset.Never: + return null; + case DatePreset.Custom: + switch (this.browserPath) { + case BrowserPath.Safari: + case BrowserPath.Firefox: + if (!this.fallbackExpirationDate.value || !this.fallbackExpirationTime.value) { + return null; + } + return this.fallbackExpirationDate.value + 'T' + this.fallbackExpirationTime.value; + default: + if (!this.defaultExpirationDateTime.value) { + return null; + } + return this.defaultExpirationDateTime.value; + } + default: + const now = new Date(); + const miliseconds = now.setTime(now.getTime() + + (this.selectedExpirationDatePreset.value as number * 60 * 60 * 1000)); + return new Date(miliseconds).toString(); + } + } + // + + get safariDeletionTimePresetOptions() { + return this.safariTimePresetOptions(DateField.DeletionDate); + } + + get safariExpirationTimePresetOptions() { + return this.safariTimePresetOptions(DateField.ExpriationDate); + } + + constructor(protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, + protected datePipe: DatePipe) { + } + + ngOnInit(): void { + this.setInitialFormValues(); + this.emitDates(); + this.datesForm.valueChanges.subscribe(() => { + this.emitDates(); + }); + } + + onDeletionDatePresetSelect(value: DatePreset) { + this.selectedDeletionDatePreset.setValue(value); + } + + clearExpiration() { + switch (this.browserPath) { + case BrowserPath.Safari: + case BrowserPath.Firefox: + this.fallbackExpirationDate.setValue(null); + this.fallbackExpirationTime.setValue(null); + break; + case BrowserPath.Default: + this.defaultExpirationDateTime.setValue(null); + break; + } + } + + protected emitDates() { + this.datesChanged.emit({ + deletionDate: this.formattedDeletionDate, + expirationDate: this.formattedExpirationDate, + }); + } + + protected setInitialFormValues() { + if (this.editMode) { + this.selectedDeletionDatePreset.setValue(DatePreset.Custom); + this.selectedExpirationDatePreset.setValue(DatePreset.Custom); + switch (this.browserPath) { + case BrowserPath.Safari: + case BrowserPath.Firefox: + this.fallbackDeletionDate.setValue(this.initialDeletionDate.toISOString().slice(0, 10)); + this.fallbackDeletionTime.setValue(this.initialDeletionDate.toTimeString().slice(0, 5)); + if (this.initialExpirationDate != null) { + this.fallbackExpirationDate.setValue(this.initialExpirationDate.toISOString().slice(0, 10)); + this.fallbackExpirationTime.setValue(this.initialExpirationDate.toTimeString().slice(0, 5)); + } + break; + case BrowserPath.Default: + if (this.initialExpirationDate) { + this.defaultExpirationDateTime.setValue( + this.datePipe.transform(new Date(this.initialExpirationDate), 'yyyy-MM-ddTHH:mm')); + } + this.defaultDeletionDateTime.setValue(this.datePipe.transform(new Date(this.initialDeletionDate), 'yyyy-MM-ddTHH:mm')); + break; + } + } else { + this.selectedDeletionDatePreset.setValue(DatePreset.SevenDays); + this.selectedExpirationDatePreset.setValue(DatePreset.Never); + } + } + + protected safariTimePresetOptions(field: DateField): TimeOption[] { + // init individual arrays for major sort groups + const noon: TimeOption[] = []; + const midnight: TimeOption[] = []; + const ams: TimeOption[] = []; + const pms: TimeOption[] = []; + + // determine minute skip (5 min, 10 min, 15 min, etc.) + const minuteIncrementer = 15; + + // loop through each hour on a 12 hour system + for (let h = 1; h <= 12; h++) { + // loop through each minute in the hour using the skip to incriment + for (let m = 0; m < 60; m += minuteIncrementer) { + // init the final strings that will be added to the lists + let hour = h.toString(); + let minutes = m.toString(); + + // add prepending 0s to single digit hours/minutes + if (h < 10) { + hour = '0' + hour; + } + if (m < 10) { + minutes = '0' + minutes; + } + + // build time strings and push to relevant sort groups + if (h === 12) { + const midnightOption: TimeOption = { + twelveHour: `${hour}:${minutes} AM`, + twentyFourHour: `00:${minutes}`, + }; + midnight.push(midnightOption); + + const noonOption: TimeOption = { + twelveHour: `${hour}:${minutes} PM`, + twentyFourHour: `${hour}:${minutes}`, + }; + noon.push(noonOption); + } else { + const amOption: TimeOption = { + twelveHour: `${hour}:${minutes} AM`, + twentyFourHour: `${hour}:${minutes}`, + }; + ams.push(amOption); + + const pmOption: TimeOption = { + twelveHour: `${hour}:${minutes} PM`, + twentyFourHour: `${h + 12}:${minutes}`, + }; + pms.push(pmOption); + } + } + } + + // bring all the arrays together in the right order + const validTimes = [...midnight, ...ams, ...noon, ...pms]; + + // determine if an unsupported value already exists on the send & add that to the top of the option list + // example: if the Send was created with a different client + if (field === DateField.ExpriationDate && this.initialExpirationDate != null && this.editMode) { + const previousValue: TimeOption = { + twelveHour: this.datePipe.transform(this.initialExpirationDate, 'hh:mm a'), + twentyFourHour: this.datePipe.transform(this.initialExpirationDate, 'HH:mm'), + }; + return [previousValue, { twelveHour: null, twentyFourHour: null }, ...validTimes]; + } else if (field === DateField.DeletionDate && this.initialDeletionDate != null && this.editMode) { + const previousValue: TimeOption = { + twelveHour: this.datePipe.transform(this.initialDeletionDate, 'hh:mm a'), + twentyFourHour: this.datePipe.transform(this.initialDeletionDate, 'HH:mm'), + }; + return [previousValue, ...validTimes]; + } else { + return [{ twelveHour: null, twentyFourHour: null }, ...validTimes]; + } + } +}