Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

7216 default calendar reminder #7346

Merged
merged 2 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions schemas/tutanota.json
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,16 @@
"info": "RemoveValue Contact/autoTransmitPassword/78."
}
]
},
{
"version": 74,
"changes": [
{
"name": "AddAssociation",
"sourceType": "GroupSettings",
"info": "AddAssociation GroupSettings/defaultAlarmsList/AGGREGATION/1446."
}
]
}
]
}
3 changes: 2 additions & 1 deletion src/calendar-app/calendar/export/CalendarParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ import WindowsZones from "./WindowsZones"
import type { ParsedCalendarData } from "./CalendarImporter"
import { isMailAddress } from "../../../common/misc/FormatValidator"
import { CalendarAttendeeStatus, CalendarMethod, EndType, RepeatPeriod, reverse } from "../../../common/api/common/TutanotaConstants"
import { AlarmInterval, AlarmIntervalUnit, serializeAlarmInterval } from "../../../common/calendar/date/CalendarUtils.js"
import { AlarmInterval, AlarmIntervalUnit } from "../../../common/calendar/date/CalendarUtils.js"
import { AlarmInfoTemplate } from "../../../common/api/worker/facades/lazy/CalendarFacade.js"
import { serializeAlarmInterval } from "../../../common/api/common/utils/CommonCalendarUtils.js"

function parseDateString(dateString: string): {
year: number
Expand Down
8 changes: 8 additions & 0 deletions src/calendar-app/calendar/gui/CalendarGuiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,14 @@ export function getEventType(
return EventType.SHARED_RO
}

/**
* if the event has a _ownerGroup, it means there is a calendar set to it
* so, if the user is the owner of said calendar they are free to manage the event however they want
**/
if ((isOrganizer || existingOrganizer === null) && calendarInfoForEvent.userIsOwner) {
return EventType.OWN
}

if (calendarInfoForEvent.shared) {
const canWrite = hasCapabilityOnGroup(user, calendarInfoForEvent.group, ShareCapability.Write)
if (canWrite) {
Expand Down
21 changes: 19 additions & 2 deletions src/calendar-app/calendar/gui/EditCalendarDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import stream from "mithril/stream"
import { TextField } from "../../../common/gui/base/TextField.js"
import { lang } from "../../../common/misc/LanguageViewModel.js"
import type { TranslationKeyType } from "../../../common/misc/TranslationKey.js"
import { downcast } from "@tutao/tutanota-utils"
import { deepEqual, downcast } from "@tutao/tutanota-utils"
import { AlarmInterval } from "../../../common/calendar/date/CalendarUtils.js"
import { RemindersEditor } from "./RemindersEditor.js"

type CalendarProperties = {
name: string
color: string
alarms: AlarmInterval[]
}

export function showEditCalendarDialog(
{ name, color }: CalendarProperties,
{ name, color, alarms }: CalendarProperties,
titleTextId: TranslationKeyType,
shared: boolean,
okAction: (arg0: Dialog, arg1: CalendarProperties) => unknown,
Expand All @@ -22,6 +25,7 @@ export function showEditCalendarDialog(
const nameStream = stream(name)
let colorPickerDom: HTMLInputElement | null
const colorStream = stream("#" + color)

Dialog.showActionDialog({
title: () => lang.get(titleTextId),
allowOkWithReturn: true,
Expand All @@ -44,13 +48,26 @@ export function showEditCalendarDialog(
colorStream(target.value)
},
}),
!shared &&
m(RemindersEditor, {
alarms,
addAlarm: (alarm: AlarmInterval) => {
alarms?.push(alarm)
},
removeAlarm: (alarm: AlarmInterval) => {
const index = alarms?.findIndex((a: AlarmInterval) => deepEqual(a, alarm))
if (index !== -1) alarms?.splice(index, 1)
},
label: "calendarDefaultReminder_label",
}),
]),
},
okActionTextId: okTextId,
okAction: (dialog: Dialog) => {
okAction(dialog, {
name: nameStream(),
color: colorStream().substring(1),
alarms,
})
},
})
Expand Down
122 changes: 122 additions & 0 deletions src/calendar-app/calendar/gui/RemindersEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import m, { Children, Component, Vnode } from "mithril"
import { TextField, TextFieldAttrs, TextFieldType } from "../../../common/gui/base/TextField.js"
import { createAlarmIntervalItems, createCustomRepeatRuleUnitValues, humanDescriptionForAlarmInterval } from "./CalendarGuiUtils.js"
import { lang, TranslationKey } from "../../../common/misc/LanguageViewModel.js"
import { IconButton } from "../../../common/gui/base/IconButton.js"
import { Icons } from "../../../common/gui/base/icons/Icons.js"
import { attachDropdown } from "../../../common/gui/base/Dropdown.js"
import { AlarmInterval, AlarmIntervalUnit } from "../../../common/calendar/date/CalendarUtils.js"
import { Dialog } from "../../../common/gui/base/Dialog.js"
import { DropDownSelector } from "../../../common/gui/base/DropDownSelector.js"
import { deepEqual } from "@tutao/tutanota-utils"

export type RemindersEditorAttrs = {
addAlarm: (alarm: AlarmInterval) => unknown
removeAlarm: (alarm: AlarmInterval) => unknown
alarms: readonly AlarmInterval[]
label: TranslationKey
}

export class RemindersEditor implements Component<RemindersEditorAttrs> {
view(vnode: Vnode<RemindersEditorAttrs>): Children {
const { addAlarm, removeAlarm, alarms } = vnode.attrs
const addNewAlarm = (newAlarm: AlarmInterval) => {
const hasAlarm = alarms.find((alarm) => deepEqual(alarm, newAlarm))
if (hasAlarm) return
addAlarm(newAlarm)
}
const textFieldAttrs: Array<TextFieldAttrs> = alarms.map((a) => ({
value: humanDescriptionForAlarmInterval(a, lang.languageTag),
label: "emptyString_msg",
isReadOnly: true,
injectionsRight: () =>
m(IconButton, {
title: "delete_action",
icon: Icons.Cancel,
click: () => removeAlarm(a),
}),
}))

textFieldAttrs.push({
value: lang.get("add_action"),
label: "emptyString_msg",
isReadOnly: true,
injectionsRight: () =>
m(
IconButton,
attachDropdown({
mainButtonAttrs: {
title: "add_action",
icon: Icons.Add,
},
childAttrs: () => [
...createAlarmIntervalItems(lang.languageTag).map((i) => ({
label: () => i.name,
click: () => addNewAlarm(i.value),
})),
{
label: () => lang.get("calendarReminderIntervalDropdownCustomItem_label"),
click: () => {
this.showCustomReminderIntervalDialog((value, unit) => {
addNewAlarm({
value,
unit,
})
})
},
},
],
}),
),
})

textFieldAttrs[0].label = vnode.attrs.label

return m(
".flex.col.flex-half.pl-s",
textFieldAttrs.map((a) => m(TextField, a)),
)
}

private showCustomReminderIntervalDialog(onAddAction: (value: number, unit: AlarmIntervalUnit) => void) {
let timeReminderValue = 0
let timeReminderUnit: AlarmIntervalUnit = AlarmIntervalUnit.MINUTE

Dialog.showActionDialog({
title: () => lang.get("calendarReminderIntervalCustomDialog_title"),
allowOkWithReturn: true,
child: {
view: () => {
const unitItems = createCustomRepeatRuleUnitValues() ?? []
return m(".flex full-width pt-s", [
m(TextField, {
type: TextFieldType.Number,
min: 0,
label: "calendarReminderIntervalValue_label",
value: timeReminderValue.toString(),
oninput: (v) => {
const time = Number.parseInt(v)
const isEmpty = v === ""
if (!Number.isNaN(time) || isEmpty) timeReminderValue = isEmpty ? 0 : Math.abs(time)
},
class: "flex-half no-appearance", //Removes the up/down arrow from input number. Pressing arrow up/down key still working
}),
m(DropDownSelector, {
label: "emptyString_msg",
selectedValue: timeReminderUnit,
items: unitItems,
class: "flex-half pl-s",
selectionChangedHandler: (selectedValue: AlarmIntervalUnit) => (timeReminderUnit = selectedValue as AlarmIntervalUnit),
disabled: false,
}),
])
},
},
okActionTextId: "add_action",
okAction: (dialog: Dialog) => {
onAddAction(timeReminderValue, timeReminderUnit)
dialog.close()
},
})
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { generateEventElementId } from "../../../../common/api/common/utils/CommonCalendarUtils.js"
import { generateEventElementId, serializeAlarmInterval } from "../../../../common/api/common/utils/CommonCalendarUtils.js"
import { noOp, remove } from "@tutao/tutanota-utils"
import { EventType } from "./CalendarEventModel.js"
import { DateProvider } from "../../../../common/api/common/DateProvider.js"
import { AlarmInterval, alarmIntervalToLuxonDurationLikeObject, serializeAlarmInterval } from "../../../../common/calendar/date/CalendarUtils.js"
import { AlarmInterval, alarmIntervalToLuxonDurationLikeObject } from "../../../../common/calendar/date/CalendarUtils.js"
import { Duration } from "luxon"
import { AlarmInfoTemplate } from "../../../../common/api/worker/facades/lazy/CalendarFacade.js"

Expand Down Expand Up @@ -51,6 +51,14 @@ export class CalendarEventAlarmModel {
this.uiUpdateCallback()
}

removeAll() {
this._alarms.splice(0)
}

addAll(alarmIntervalList: AlarmInterval[]) {
this._alarms.push(...alarmIntervalList)
}

get alarms(): ReadonlyArray<AlarmInterval> {
return this._alarms
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,14 @@ export class CalendarEventWhoModel {
}

set selectedCalendar(v: CalendarInfo) {
if (v.shared && this._attendees.size > 0) {
/**
* when changing the calendar of an event, if the user is the organiser
* they can link any of their owned calendars(private or shared) to said event
* even if the event has guests
**/
if (!v.userIsOwner && v.shared && this._attendees.size > 0) {
throw new ProgrammingError("tried to select shared calendar while there are guests.")
} else if (v.shared && this.isNew && this._organizer != null) {
} else if (!v.userIsOwner && v.shared && this.isNew && this._organizer != null) {
// for new events, it's possible to have an organizer but no attendees if you only add yourself.
this._organizer = null
}
Expand Down Expand Up @@ -154,7 +159,11 @@ export class CalendarEventWhoModel {
* unable to send updates.
*/
get canModifyGuests(): boolean {
return !(this.selectedCalendar?.shared || this.eventType === EventType.INVITE || this.operation === CalendarOperation.EditThis)
/**
* if the user is the event's organiser and the owner of its linked calendar, the user can modify the guests freely
**/
const userIsOwner = this.eventType === EventType.OWN && this.selectedCalendar.userIsOwner
return userIsOwner || !(this.selectedCalendar?.shared || this.eventType === EventType.INVITE || this.operation === CalendarOperation.EditThis)
}

/**
Expand All @@ -168,7 +177,14 @@ export class CalendarEventWhoModel {
return [this.selectedCalendar]
} else if (this.isNew && this._attendees.size > 0) {
// if we added guests, we cannot select a shared calendar to create the event.
return calendarArray.filter((calendarInfo) => !calendarInfo.shared)
/**
* when changing the calendar of an event, if the user is the organiser
* they can link any of their owned calendars(private or shared) to said event
* even if the event has guests
**/
return calendarArray.filter((calendarInfo) => calendarInfo.userIsOwner || !calendarInfo.shared)
} else if (this._attendees.size > 0 && this.eventType === EventType.OWN) {
return calendarArray.filter((calendarInfo) => calendarInfo.userIsOwner)
} else if (this._attendees.size > 0 || this.eventType === EventType.INVITE) {
// We don't allow inviting in a shared calendar.
// If we have attendees, we cannot select a shared calendar.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Dialog } from "../../../../common/gui/base/Dialog.js"
import { lang } from "../../../../common/misc/LanguageViewModel.js"
import { ButtonType } from "../../../../common/gui/base/Button.js"
import { Keys } from "../../../../common/api/common/TutanotaConstants.js"
import { getStartOfTheWeekOffsetForUser, getTimeFormatForUser } from "../../../../common/calendar/date/CalendarUtils.js"
import { AlarmInterval, getStartOfTheWeekOffsetForUser, getTimeFormatForUser, parseAlarmInterval } from "../../../../common/calendar/date/CalendarUtils.js"
import { client } from "../../../../common/misc/ClientDetector.js"
import type { DialogHeaderBarAttrs } from "../../../../common/gui/base/DialogHeaderBar.js"
import { assertNotNull, noOp, Thunk } from "@tutao/tutanota-utils"
Expand Down Expand Up @@ -42,10 +42,21 @@ type EditDialogOkHandler = (posRect: PosRect, finish: Thunk) => Promise<unknown>
async function showCalendarEventEditDialog(model: CalendarEventModel, responseMail: Mail | null, handler: EditDialogOkHandler): Promise<void> {
const recipientsSearch = await locator.recipientsSearchModel()
const { HtmlEditor } = await import("../../../../common/gui/editor/HtmlEditor.js")
const groupColors: Map<Id, string> = locator.logins.getUserController().userSettingsGroupRoot.groupSettings.reduce((acc, gc) => {
const groupSettings = locator.logins.getUserController().userSettingsGroupRoot.groupSettings

const groupColors: Map<Id, string> = groupSettings.reduce((acc, gc) => {
acc.set(gc.group, gc.color)
return acc
}, new Map())

const defaultAlarms: Map<Id, AlarmInterval[]> = groupSettings.reduce((acc, gc) => {
acc.set(
gc.group,
gc.defaultAlarmsList.map((alarm) => parseAlarmInterval(alarm.trigger)),
)
return acc
}, new Map())

const descriptionText = convertTextToHtml(model.editModels.description.content)
const descriptionEditor: HtmlEditor = new HtmlEditor("description_label")
.setMinHeight(400)
Expand Down Expand Up @@ -89,13 +100,15 @@ async function showCalendarEventEditDialog(model: CalendarEventModel, responseMa
headerDom = dom
},
}

const dialog: Dialog = Dialog.editDialog(dialogHeaderBarAttrs, CalendarEventEditView, {
model,
recipientsSearch,
descriptionEditor,
startOfTheWeekOffset: getStartOfTheWeekOffsetForUser(locator.logins.getUserController().userSettingsGroupRoot),
timeFormat: getTimeFormatForUser(locator.logins.getUserController().userSettingsGroupRoot),
groupColors,
defaultAlarms,
})
.addShortcut({
key: Keys.ESC,
Expand All @@ -113,6 +126,7 @@ async function showCalendarEventEditDialog(model: CalendarEventModel, responseMa
// Prevent focusing text field automatically on mobile. It opens keyboard and you don't see all details.
dialog.setFocusOnLoadFunction(noOp)
}

dialog.show()
}

Expand Down
Loading