diff --git a/app/assets/javascripts/discourse/app/components/bookmark-local-date.js b/app/assets/javascripts/discourse/app/components/bookmark-local-date.js deleted file mode 100644 index d89c2000737..00000000000 --- a/app/assets/javascripts/discourse/app/components/bookmark-local-date.js +++ /dev/null @@ -1,57 +0,0 @@ -import Component from "@ember/component"; -import I18n from "I18n"; -import { action } from "@ember/object"; -import { getOwner } from "discourse-common/lib/get-owner"; -import { or } from "@ember/object/computed"; - -export default Component.extend({ - tagName: "", - - init() { - this._super(...arguments); - - this.loadLocalDates(); - }, - - get postLocalDateFormatted() { - return this.postLocalDate().format(I18n.t("dates.long_no_year")); - }, - - showPostLocalDate: or("postDetectedLocalDate", "postDetectedLocalTime"), - - loadLocalDates() { - let postEl = document.querySelector(`[data-post-id="${this.postId}"]`); - let localDateEl = null; - if (postEl) { - localDateEl = postEl.querySelector(".discourse-local-date"); - } - - this.setProperties({ - postDetectedLocalDate: localDateEl ? localDateEl.dataset.date : null, - postDetectedLocalTime: localDateEl ? localDateEl.dataset.time : null, - postDetectedLocalTimezone: localDateEl - ? localDateEl.dataset.timezone - : null, - }); - }, - - postLocalDate() { - const bookmarkController = getOwner(this).lookup("controller:bookmark"); - let parsedPostLocalDate = bookmarkController._parseCustomDateTime( - this.postDetectedLocalDate, - this.postDetectedLocalTime, - this.postDetectedLocalTimezone - ); - - if (!this.postDetectedLocalTime) { - return bookmarkController.startOfDay(parsedPostLocalDate); - } - - return parsedPostLocalDate; - }, - - @action - setReminder() { - return this.onChange(this.postLocalDate()); - }, -}); diff --git a/app/assets/javascripts/discourse/app/components/bookmark.js b/app/assets/javascripts/discourse/app/components/bookmark.js new file mode 100644 index 00000000000..67c95ddc779 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/bookmark.js @@ -0,0 +1,416 @@ +import { + LATER_TODAY_CUTOFF_HOUR, + MOMENT_THURSDAY, + laterToday, + now, + parseCustomDatetime, + startOfDay, + tomorrow, +} from "discourse/lib/time-utils"; + +import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark"; +import Component from "@ember/component"; +import I18n from "I18n"; +import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts"; +import { Promise } from "rsvp"; +import { TIME_SHORTCUT_TYPES } from "discourse/lib/time-shortcut"; + +import { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import bootbox from "bootbox"; +import discourseComputed, { on } from "discourse-common/utils/decorators"; +import { formattedReminderTime } from "discourse/lib/bookmark"; +import { and, notEmpty, or } from "@ember/object/computed"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +// global shortcuts that interfere with these modal shortcuts, they are rebound when the +// modal is closed +// +// d deletePost +const GLOBAL_SHORTCUTS_TO_PAUSE = ["d"]; +const BOOKMARK_BINDINGS = { + enter: { handler: "saveAndClose" }, + "d d": { handler: "delete" }, +}; + +export default Component.extend({ + tagName: "", + + errorMessage: null, + selectedReminderType: null, + _closeWithoutSaving: null, + _savingBookmarkManually: null, + _saving: null, + _deleting: null, + postDetectedLocalDate: null, + postDetectedLocalTime: null, + postDetectedLocalTimezone: null, + prefilledDatetime: null, + userTimezone: null, + showOptions: null, + model: null, + + afterSave: null, + + @on("init") + _setup() { + this.setProperties({ + errorMessage: null, + selectedReminderType: TIME_SHORTCUT_TYPES.NONE, + _closeWithoutSaving: false, + _savingBookmarkManually: false, + _saving: false, + _deleting: false, + postDetectedLocalDate: null, + postDetectedLocalTime: null, + postDetectedLocalTimezone: null, + prefilledDatetime: null, + userTimezone: this.currentUser.resolvedTimezone(this.currentUser), + showOptions: false, + }); + + this.registerOnCloseHandler(this._onModalClose.bind(this)); + + this._loadBookmarkOptions(); + this._bindKeyboardShortcuts(); + + if (this.editingExistingBookmark) { + this._initializeExistingBookmarkData(); + } + + this._loadPostLocalDates(); + }, + + @on("didInsertElement") + _prepareUI() { + if (this.site.isMobileDevice) { + document.getElementById("bookmark-name").blur(); + } + + // we want to make sure the options panel opens so the user + // knows they have set these options previously. + if (this.autoDeletePreference) { + this.toggleOptionsPanel(); + } + }, + + _initializeExistingBookmarkData() { + if (this.existingBookmarkHasReminder) { + this.set("prefilledDatetime", this.model.reminderAt); + + let parsedDatetime = parseCustomDatetime( + this.prefilledDatetime, + null, + this.userTimezone + ); + + this.set("selectedDatetime", parsedDatetime); + } + }, + + _loadBookmarkOptions() { + this.set( + "autoDeletePreference", + this.model.autoDeletePreference || this._preferredDeleteOption() || 0 + ); + }, + + _preferredDeleteOption() { + let preferred = localStorage.bookmarkDeleteOption; + if (preferred && preferred !== "") { + preferred = parseInt(preferred, 10); + } + return preferred; + }, + + _bindKeyboardShortcuts() { + KeyboardShortcuts.pause(GLOBAL_SHORTCUTS_TO_PAUSE); + Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => { + KeyboardShortcuts.addShortcut(shortcut, () => { + let binding = BOOKMARK_BINDINGS[shortcut]; + if (binding.args) { + return this.send(binding.handler, ...binding.args); + } + this.send(binding.handler); + }); + }); + }, + + _unbindKeyboardShortcuts() { + KeyboardShortcuts.unbind(BOOKMARK_BINDINGS); + }, + + _restoreGlobalShortcuts() { + KeyboardShortcuts.unpause(GLOBAL_SHORTCUTS_TO_PAUSE); + }, + + _loadPostLocalDates() { + let postEl = document.querySelector( + `[data-post-id="${this.model.postId}"]` + ); + let localDateEl; + if (postEl) { + localDateEl = postEl.querySelector(".discourse-local-date"); + } + + if (localDateEl) { + this.setProperties({ + postDetectedLocalDate: localDateEl.dataset.date, + postDetectedLocalTime: localDateEl.dataset.time, + postDetectedLocalTimezone: localDateEl.dataset.timezone, + }); + } + }, + + _saveBookmark() { + let reminderAt; + if (this.selectedReminderType) { + reminderAt = this.selectedDatetime; + } + + const reminderAtISO = reminderAt ? reminderAt.toISOString() : null; + + if (this.selectedReminderType === TIME_SHORTCUT_TYPES.CUSTOM) { + if (!reminderAt) { + return Promise.reject(I18n.t("bookmarks.invalid_custom_datetime")); + } + } + + localStorage.bookmarkDeleteOption = this.autoDeletePreference; + + let reminderType; + if (this.selectedReminderType === TIME_SHORTCUT_TYPES.NONE) { + reminderType = null; + } else if ( + this.selectedReminderType === TIME_SHORTCUT_TYPES.LAST_CUSTOM || + this.selectedReminderType === TIME_SHORTCUT_TYPES.POST_LOCAL_DATE + ) { + reminderType = TIME_SHORTCUT_TYPES.CUSTOM; + } else { + reminderType = this.selectedReminderType; + } + + const data = { + reminder_type: reminderType, + reminder_at: reminderAtISO, + name: this.model.name, + post_id: this.model.postId, + id: this.model.id, + auto_delete_preference: this.autoDeletePreference, + }; + + if (this.editingExistingBookmark) { + return ajax(`/bookmarks/${this.model.id}`, { + type: "PUT", + data, + }).then(() => { + this._executeAfterSave(reminderAtISO); + }); + } else { + return ajax("/bookmarks", { type: "POST", data }).then((response) => { + this.set("model.id", response.id); + this._executeAfterSave(reminderAtISO); + }); + } + }, + + _executeAfterSave(reminderAtISO) { + if (!this.afterSave) { + return; + } + this.afterSave({ + reminderAt: reminderAtISO, + reminderType: this.selectedReminderType, + autoDeletePreference: this.autoDeletePreference, + id: this.model.id, + name: this.model.name, + }); + }, + + _deleteBookmark() { + return ajax("/bookmarks/" + this.model.id, { + type: "DELETE", + }).then((response) => { + if (this.afterDelete) { + this.afterDelete(response.topic_bookmarked); + } + }); + }, + + _postLocalDate() { + let parsedPostLocalDate = parseCustomDatetime( + this.postDetectedLocalDate, + this.postDetectedLocalTime, + this.userTimezone, + this.postDetectedLocalTimezone + ); + + if (!this.postDetectedLocalTime) { + return startOfDay(parsedPostLocalDate); + } + + return parsedPostLocalDate; + }, + + _handleSaveError(e) { + this._savingBookmarkManually = false; + if (typeof e === "string") { + bootbox.alert(e); + } else { + popupAjaxError(e); + } + }, + + _onModalClose(initiatedByCloseButton) { + // we want to close without saving if the user already saved + // manually or deleted the bookmark, as well as when the modal + // is just closed with the X button + this._closeWithoutSaving = + this._closeWithoutSaving || initiatedByCloseButton; + + this._unbindKeyboardShortcuts(); + this._restoreGlobalShortcuts(); + + if (!this._closeWithoutSaving && !this._savingBookmarkManually) { + this._saveBookmark().catch((e) => this._handleSaveError(e)); + } + if (this.onCloseWithoutSaving && this._closeWithoutSaving) { + this.onCloseWithoutSaving(); + } + }, + + showExistingReminderAt: notEmpty("model.reminderAt"), + showDelete: notEmpty("model.id"), + userHasTimezoneSet: notEmpty("userTimezone"), + showPostLocalDate: or("postDetectedLocalDate", "postDetectedLocalTime"), + editingExistingBookmark: and("model", "model.id"), + existingBookmarkHasReminder: and("model", "model.reminderAt"), + + @discourseComputed() + autoDeletePreferences: () => { + return Object.keys(AUTO_DELETE_PREFERENCES).map((key) => { + return { + id: AUTO_DELETE_PREFERENCES[key], + name: I18n.t(`bookmarks.auto_delete_preference.${key.toLowerCase()}`), + }; + }); + }, + + @discourseComputed() + customTimeShortcutOptions() { + let customOptions = []; + + if (this.showPostLocalDate) { + customOptions.push({ + icon: "globe-americas", + id: TIME_SHORTCUT_TYPES.POST_LOCAL_DATE, + label: "bookmarks.reminders.post_local_date", + time: this._postLocalDate(), + timeFormatted: this._postLocalDate().format( + I18n.t("dates.long_no_year") + ), + hidden: false, + }); + } + + return customOptions; + }, + + @discourseComputed() + additionalTimeShortcutOptions() { + let additional = []; + + let later = laterToday(this.userTimezone); + if ( + !later.isSame(tomorrow(this.userTimezone), "date") && + now(this.userTimezone).hour() < LATER_TODAY_CUTOFF_HOUR + ) { + additional.push(TIME_SHORTCUT_TYPES.LATER_TODAY); + } + + if (now(this.userTimezone).day() < MOMENT_THURSDAY) { + additional.push(TIME_SHORTCUT_TYPES.LATER_THIS_WEEK); + } + + return additional; + }, + + @discourseComputed("model.reminderAt") + existingReminderAtFormatted(existingReminderAt) { + return formattedReminderTime(existingReminderAt, this.userTimezone); + }, + + @action + saveAndClose() { + if (this._saving || this._deleting) { + return; + } + + this._saving = true; + this._savingBookmarkManually = true; + return this._saveBookmark() + .then(() => this.closeModal()) + .catch((e) => this._handleSaveError(e)) + .finally(() => (this._saving = false)); + }, + + @action + toggleOptionsPanel() { + if (this.showOptions) { + $(".bookmark-options-panel").slideUp("fast"); + } else { + $(".bookmark-options-panel").slideDown("fast"); + } + this.toggleProperty("showOptions"); + }, + + @action + delete() { + this._deleting = true; + let deleteAction = () => { + this._closeWithoutSaving = true; + this._deleteBookmark() + .then(() => { + this._deleting = false; + this.closeModal(); + }) + .catch((e) => this._handleSaveError(e)); + }; + + if (this.existingBookmarkHasReminder) { + bootbox.confirm(I18n.t("bookmarks.confirm_delete"), (result) => { + if (result) { + deleteAction(); + } + }); + } else { + deleteAction(); + } + }, + + @action + closeWithoutSavingBookmark() { + this._closeWithoutSaving = true; + this.closeModal(); + }, + + @action + onTimeSelected(type, time) { + this.setProperties({ selectedReminderType: type, selectedDatetime: time }); + + // if the type is custom, we need to wait for the user to click save, as + // they could still be adjusting the date and time + if (type !== TIME_SHORTCUT_TYPES.CUSTOM) { + return this.saveAndClose(); + } + }, + + @action + selectPostLocalDate(date) { + this.setProperties({ + selectedReminderType: this.reminderTypes.POST_LOCAL_DATE, + postLocalDate: date, + }); + return this.saveAndClose(); + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js b/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js new file mode 100644 index 00000000000..cdd1be06a19 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js @@ -0,0 +1,242 @@ +import { + START_OF_DAY_HOUR, + laterToday, + now, + parseCustomDatetime, +} from "discourse/lib/time-utils"; +import { + TIME_SHORTCUT_TYPES, + defaultShortcutOptions, +} from "discourse/lib/time-shortcut"; +import discourseComputed, { + observes, + on, +} from "discourse-common/utils/decorators"; + +import Component from "@ember/component"; +import I18n from "I18n"; +import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts"; +import { action } from "@ember/object"; +import { and, equal } from "@ember/object/computed"; + +// global shortcuts that interfere with these modal shortcuts, they are rebound when the +// component is destroyed +// +// c createTopic +// r replyToPost +// l toggle like +// t replyAsNewTopic +const GLOBAL_SHORTCUTS_TO_PAUSE = ["c", "r", "l", "t"]; +const BINDINGS = { + "l t": { + handler: "selectShortcut", + args: [TIME_SHORTCUT_TYPES.LATER_TODAY], + }, + "l w": { + handler: "selectShortcut", + args: [TIME_SHORTCUT_TYPES.LATER_THIS_WEEK], + }, + "n d": { + handler: "selectShortcut", + args: [TIME_SHORTCUT_TYPES.TOMORROW], + }, + "n w": { + handler: "selectShortcut", + args: [TIME_SHORTCUT_TYPES.NEXT_WEEK], + }, + "n b w": { + handler: "selectShortcut", + args: [TIME_SHORTCUT_TYPES.START_OF_NEXT_BUSINESS_WEEK], + }, + "n m": { + handler: "selectShortcut", + args: [TIME_SHORTCUT_TYPES.NEXT_MONTH], + }, + "c r": { handler: "selectShortcut", args: [TIME_SHORTCUT_TYPES.CUSTOM] }, + "n r": { handler: "selectShortcut", args: [TIME_SHORTCUT_TYPES.NONE] }, +}; + +export default Component.extend({ + tagName: "", + + userTimezone: null, + + onTimeSelected: null, + + selectedShortcut: null, + selectedTime: null, + selectedDate: null, + selectedDatetime: null, + prefilledDatetime: null, + + additionalOptionsToShow: null, + hiddenOptions: null, + customOptions: null, + + lastCustomDate: null, + lastCustomTime: null, + parsedLastCustomDatetime: null, + customDate: null, + customTime: null, + + defaultCustomReminderTime: `0${START_OF_DAY_HOUR}:00`, + + @on("init") + _setupPicker() { + this.setProperties({ + customTime: this.defaultCustomReminderTime, + userTimezone: this.currentUser.resolvedTimezone(this.currentUser), + additionalOptionsToShow: this.additionalOptionsToShow || [], + hiddenOptions: this.hiddenOptions || [], + customOptions: this.customOptions || [], + }); + + if (this.prefilledDatetime) { + let parsedDatetime = parseCustomDatetime( + this.prefilledDatetime, + null, + this.userTimezone + ); + + if (parsedDatetime.isSame(laterToday())) { + return this.set("selectedShortcut", TIME_SHORTCUT_TYPES.LATER_TODAY); + } + + this.setProperties({ + customDate: parsedDatetime.format("YYYY-MM-DD"), + customTime: parsedDatetime.format("HH:mm"), + selectedShortcut: TIME_SHORTCUT_TYPES.CUSTOM, + }); + } + + this._bindKeyboardShortcuts(); + this._loadLastUsedCustomDatetime(); + }, + + @on("willDestroyElement") + _resetKeyboardShortcuts() { + KeyboardShortcuts.unbind(BINDINGS); + KeyboardShortcuts.unpause(GLOBAL_SHORTCUTS_TO_PAUSE); + }, + + _loadLastUsedCustomDatetime() { + let lastTime = localStorage.lastCustomTime; + let lastDate = localStorage.lastCustomDate; + + if (lastTime && lastDate) { + let parsed = parseCustomDatetime(lastDate, lastTime, this.userTimezone); + + if (parsed < now(this.userTimezone)) { + return; + } + + this.setProperties({ + lastCustomDate: lastDate, + lastCustomTime: lastTime, + parsedLastCustomDatetime: parsed, + }); + } + }, + + _bindKeyboardShortcuts() { + KeyboardShortcuts.pause(GLOBAL_SHORTCUTS_TO_PAUSE); + Object.keys(BINDINGS).forEach((shortcut) => { + KeyboardShortcuts.addShortcut(shortcut, () => { + let binding = BINDINGS[shortcut]; + if (binding.args) { + return this.send(binding.handler, ...binding.args); + } + this.send(binding.handler); + }); + }); + }, + + customDatetimeSelected: equal("selectedShortcut", TIME_SHORTCUT_TYPES.CUSTOM), + customDatetimeFilled: and("customDate", "customTime"), + + @observes("customDate", "customTime") + customDatetimeChanged() { + if (!this.customDatetimeFilled) { + return; + } + this.selectShortcut(TIME_SHORTCUT_TYPES.CUSTOM); + }, + + @discourseComputed( + "additionalOptionsToShow", + "hiddenOptions", + "customOptions", + "userTimezone" + ) + options(additionalOptionsToShow, hiddenOptions, customOptions, userTimezone) { + let options = defaultShortcutOptions(userTimezone); + + if (additionalOptionsToShow.length > 0) { + options.forEach((opt) => { + if (additionalOptionsToShow.includes(opt.id)) { + opt.hidden = false; + } + }); + } + + if (hiddenOptions.length > 0) { + options.forEach((opt) => { + if (hiddenOptions.includes(opt.id)) { + opt.hidden = true; + } + }); + } + + if (this.lastCustomDate && this.lastCustomTime) { + let lastCustom = options.findBy("id", TIME_SHORTCUT_TYPES.LAST_CUSTOM); + lastCustom.time = this.parsedLastCustomDatetime; + lastCustom.timeFormatted = this.parsedLastCustomDatetime.format( + I18n.t("dates.long_no_year") + ); + lastCustom.hidden = false; + } + + let customOptionIndex = options.findIndex( + (opt) => opt.id === TIME_SHORTCUT_TYPES.CUSTOM + ); + + options.splice(customOptionIndex, 0, ...customOptions); + + return options; + }, + + @action + selectShortcut(type) { + if (this.options.filterBy("hidden").mapBy("id").includes(type)) { + return; + } + + let dateTime = null; + if (type === TIME_SHORTCUT_TYPES.CUSTOM) { + this.set("customTime", this.customTime || this.defaultCustomReminderTime); + const customDatetime = parseCustomDatetime( + this.customDate, + this.customTime, + this.userTimezone + ); + + if (customDatetime.isValid()) { + dateTime = customDatetime; + + localStorage.lastCustomTime = this.customTime; + localStorage.lastCustomDate = this.customDate; + } + } else { + dateTime = this.options.findBy("id", type).time; + } + + this.setProperties({ + selectedShortcut: type, + selectedDatetime: dateTime, + }); + + if (this.onTimeSelected) { + this.onTimeSelected(type, dateTime); + } + }, +}); diff --git a/app/assets/javascripts/discourse/app/controllers/bookmark.js b/app/assets/javascripts/discourse/app/controllers/bookmark.js index ec0e0010bbc..6b57d740c7c 100644 --- a/app/assets/javascripts/discourse/app/controllers/bookmark.js +++ b/app/assets/javascripts/discourse/app/controllers/bookmark.js @@ -1,105 +1,18 @@ -import { REMINDER_TYPES, formattedReminderTime } from "discourse/lib/bookmark"; -import { isEmpty, isPresent } from "@ember/utils"; -import { next, schedule } from "@ember/runloop"; -import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark"; import Controller from "@ember/controller"; -import I18n from "I18n"; -import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts"; import ModalFunctionality from "discourse/mixins/modal-functionality"; -import { Promise } from "rsvp"; import { action } from "@ember/object"; -import { ajax } from "discourse/lib/ajax"; -import { and } from "@ember/object/computed"; -import bootbox from "bootbox"; -import discourseComputed from "discourse-common/utils/decorators"; -import { popupAjaxError } from "discourse/lib/ajax-error"; - -// global shortcuts that interfere with these modal shortcuts, they are rebound when the -// modal is closed -// -// c createTopic -// r replyToPost -// l toggle like -// d deletePost -// t replyAsNewTopic -const GLOBAL_SHORTCUTS_TO_PAUSE = ["c", "r", "l", "d", "t"]; -const START_OF_DAY_HOUR = 8; -const LATER_TODAY_CUTOFF_HOUR = 17; -const LATER_TODAY_MAX_HOUR = 18; -const MOMENT_MONDAY = 1; -const MOMENT_THURSDAY = 4; -const BOOKMARK_BINDINGS = { - enter: { handler: "saveAndClose" }, - "l t": { handler: "selectReminderType", args: [REMINDER_TYPES.LATER_TODAY] }, - "l w": { - handler: "selectReminderType", - args: [REMINDER_TYPES.LATER_THIS_WEEK], - }, - "n b d": { - handler: "selectReminderType", - args: [REMINDER_TYPES.NEXT_BUSINESS_DAY], - }, - "n d": { handler: "selectReminderType", args: [REMINDER_TYPES.TOMORROW] }, - "n w": { handler: "selectReminderType", args: [REMINDER_TYPES.NEXT_WEEK] }, - "n b w": { - handler: "selectReminderType", - args: [REMINDER_TYPES.START_OF_NEXT_BUSINESS_WEEK], - }, - "n m": { handler: "selectReminderType", args: [REMINDER_TYPES.NEXT_MONTH] }, - "c r": { handler: "selectReminderType", args: [REMINDER_TYPES.CUSTOM] }, - "n r": { handler: "selectReminderType", args: [REMINDER_TYPES.NONE] }, - "d d": { handler: "delete" }, -}; export default Controller.extend(ModalFunctionality, { - loading: false, - errorMessage: null, - selectedReminderType: null, - _closeWithoutSaving: false, - _savingBookmarkManually: false, - onCloseWithoutSaving: null, - customReminderDate: null, - customReminderTime: null, - lastCustomReminderDate: null, - lastCustomReminderTime: null, - postLocalDate: null, - mouseTrap: null, - userTimezone: null, - showOptions: false, - onShow() { this.setProperties({ - errorMessage: null, - selectedReminderType: REMINDER_TYPES.NONE, - _closeWithoutSaving: false, - _savingBookmarkManually: false, - customReminderDate: null, - customReminderTime: this._defaultCustomReminderTime(), - lastCustomReminderDate: null, - lastCustomReminderTime: null, - postDetectedLocalDate: null, - postDetectedLocalTime: null, - postDetectedLocalTimezone: null, - userTimezone: this.currentUser.resolvedTimezone(this.currentUser), - showOptions: false, model: this.model || {}, + allowSave: true, }); + }, - this._loadBookmarkOptions(); - this._bindKeyboardShortcuts(); - this._loadLastUsedCustomReminderDatetime(); - - if (this._editingExistingBookmark()) { - this._initializeExistingBookmarkData(); - } - - this.loadLocalDates(); - - schedule("afterRender", () => { - if (this.site.isMobileDevice) { - document.getElementById("bookmark-name").blur(); - } - }); + @action + registerOnCloseHandler(handlerFn) { + this.set("onCloseHandler", handlerFn); }, /** @@ -107,473 +20,8 @@ export default Controller.extend(ModalFunctionality, { * clicks the save or cancel button to mimic browser behaviour. */ onClose(opts = {}) { - if (opts.initiatedByCloseButton) { - this._closeWithoutSaving = true; + if (this.onCloseHandler) { + this.onCloseHandler(opts.initiatedByCloseButton); } - - this._unbindKeyboardShortcuts(); - this._restoreGlobalShortcuts(); - if (!this._closeWithoutSaving && !this._savingBookmarkManually) { - this._saveBookmark().catch((e) => this._handleSaveError(e)); - } - if (this.onCloseWithoutSaving && this._closeWithoutSaving) { - this.onCloseWithoutSaving(); - } - }, - - _initializeExistingBookmarkData() { - if (this._existingBookmarkHasReminder()) { - let parsedReminderAt = this._parseCustomDateTime(this.model.reminderAt); - - if (parsedReminderAt.isSame(this.laterToday())) { - return this.set("selectedReminderType", REMINDER_TYPES.LATER_TODAY); - } - - this.setProperties({ - customReminderDate: parsedReminderAt.format("YYYY-MM-DD"), - customReminderTime: parsedReminderAt.format("HH:mm"), - selectedReminderType: REMINDER_TYPES.CUSTOM, - }); - } - }, - - _editingExistingBookmark() { - return isPresent(this.model) && isPresent(this.model.id); - }, - - _existingBookmarkHasReminder() { - return isPresent(this.model) && isPresent(this.model.reminderAt); - }, - - _loadBookmarkOptions() { - this.set( - "autoDeletePreference", - this.model.autoDeletePreference || this._preferredDeleteOption() || 0 - ); - - // we want to make sure the options panel opens so the user - // knows they have set these options previously. run next otherwise - // the modal is not visible when it tries to slide down the options - if (this.autoDeletePreference) { - next(() => this.toggleOptionsPanel()); - } - }, - - _preferredDeleteOption() { - let preferred = localStorage.bookmarkDeleteOption; - if (preferred && preferred !== "") { - preferred = parseInt(preferred, 10); - } - return preferred; - }, - - _loadLastUsedCustomReminderDatetime() { - let lastTime = localStorage.lastCustomBookmarkReminderTime; - let lastDate = localStorage.lastCustomBookmarkReminderDate; - - if (lastTime && lastDate) { - let parsed = this._parseCustomDateTime(lastDate, lastTime); - - // can't set reminders in the past - if (parsed < this.now()) { - return; - } - - this.setProperties({ - lastCustomReminderDate: lastDate, - lastCustomReminderTime: lastTime, - parsedLastCustomReminderDatetime: parsed, - }); - } - }, - - _bindKeyboardShortcuts() { - KeyboardShortcuts.pause(GLOBAL_SHORTCUTS_TO_PAUSE); - Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => { - KeyboardShortcuts.addShortcut(shortcut, () => { - let binding = BOOKMARK_BINDINGS[shortcut]; - if (binding.args) { - return this.send(binding.handler, ...binding.args); - } - this.send(binding.handler); - }); - }); - }, - - _unbindKeyboardShortcuts() { - KeyboardShortcuts.unbind(BOOKMARK_BINDINGS); - }, - - _restoreGlobalShortcuts() { - KeyboardShortcuts.unpause(GLOBAL_SHORTCUTS_TO_PAUSE); - }, - - @discourseComputed("model.reminderAt") - showExistingReminderAt(existingReminderAt) { - return isPresent(existingReminderAt); - }, - - @discourseComputed("model.id") - showDelete(id) { - return isPresent(id); - }, - - @discourseComputed("selectedReminderType") - customDateTimeSelected(selectedReminderType) { - return selectedReminderType === REMINDER_TYPES.CUSTOM; - }, - - @discourseComputed() - reminderTypes: () => { - return REMINDER_TYPES; - }, - - @discourseComputed() - autoDeletePreferences: () => { - return Object.keys(AUTO_DELETE_PREFERENCES).map((key) => { - return { - id: AUTO_DELETE_PREFERENCES[key], - name: I18n.t(`bookmarks.auto_delete_preference.${key.toLowerCase()}`), - }; - }); - }, - - showLastCustom: and("lastCustomReminderTime", "lastCustomReminderDate"), - - get showLaterToday() { - let later = this.laterToday(); - return ( - !later.isSame(this.tomorrow(), "date") && - this.now().hour() < LATER_TODAY_CUTOFF_HOUR - ); - }, - - get showLaterThisWeek() { - return this.now().day() < MOMENT_THURSDAY; - }, - - @discourseComputed("parsedLastCustomReminderDatetime") - lastCustomFormatted(parsedLastCustomReminderDatetime) { - return parsedLastCustomReminderDatetime.format( - I18n.t("dates.long_no_year") - ); - }, - - @discourseComputed("model.reminderAt") - existingReminderAtFormatted(existingReminderAt) { - return formattedReminderTime(existingReminderAt, this.userTimezone); - }, - - get startNextBusinessWeekLabel() { - if (this.now().day() === MOMENT_MONDAY) { - return I18n.t("bookmarks.reminders.start_of_next_business_week_alt"); - } - return I18n.t("bookmarks.reminders.start_of_next_business_week"); - }, - - get startNextBusinessWeekFormatted() { - return this.nextWeek() - .day(MOMENT_MONDAY) - .format(I18n.t("dates.long_no_year")); - }, - - get laterTodayFormatted() { - return this.laterToday().format(I18n.t("dates.time")); - }, - - get tomorrowFormatted() { - return this.tomorrow().format(I18n.t("dates.time_short_day")); - }, - - get nextWeekFormatted() { - return this.nextWeek().format(I18n.t("dates.long_no_year")); - }, - - get laterThisWeekFormatted() { - return this.laterThisWeek().format(I18n.t("dates.time_short_day")); - }, - - get nextMonthFormatted() { - return this.nextMonth().format(I18n.t("dates.long_no_year")); - }, - - loadLocalDates() { - let postEl = document.querySelector( - `[data-post-id="${this.model.postId}"]` - ); - let localDateEl = null; - if (postEl) { - localDateEl = postEl.querySelector(".discourse-local-date"); - } - - if (localDateEl) { - this.setProperties({ - postDetectedLocalDate: localDateEl.dataset.date, - postDetectedLocalTime: localDateEl.dataset.time, - postDetectedLocalTimezone: localDateEl.dataset.timezone, - }); - } - }, - - @discourseComputed("userTimezone") - userHasTimezoneSet(userTimezone) { - return !isEmpty(userTimezone); - }, - - _saveBookmark() { - const reminderAt = this._reminderAt(); - const reminderAtISO = reminderAt ? reminderAt.toISOString() : null; - - if (this.selectedReminderType === REMINDER_TYPES.CUSTOM) { - if (!reminderAt) { - return Promise.reject(I18n.t("bookmarks.invalid_custom_datetime")); - } - - localStorage.lastCustomBookmarkReminderTime = this.customReminderTime; - localStorage.lastCustomBookmarkReminderDate = this.customReminderDate; - } - - localStorage.bookmarkDeleteOption = this.autoDeletePreference; - - let reminderType; - if (this.selectedReminderType === REMINDER_TYPES.NONE) { - reminderType = null; - } else if ( - this.selectedReminderType === REMINDER_TYPES.LAST_CUSTOM || - this.selectedReminderType === REMINDER_TYPES.POST_LOCAL_DATE - ) { - reminderType = REMINDER_TYPES.CUSTOM; - } else { - reminderType = this.selectedReminderType; - } - - const data = { - reminder_type: reminderType, - reminder_at: reminderAtISO, - name: this.model.name, - post_id: this.model.postId, - id: this.model.id, - auto_delete_preference: this.autoDeletePreference, - }; - - if (this._editingExistingBookmark()) { - return ajax("/bookmarks/" + this.model.id, { - type: "PUT", - data, - }).then(() => { - if (this.afterSave) { - this.afterSave({ - reminderAt: reminderAtISO, - reminderType: this.selectedReminderType, - autoDeletePreference: this.autoDeletePreference, - id: this.model.id, - name: this.model.name, - }); - } - }); - } else { - return ajax("/bookmarks", { type: "POST", data }).then((response) => { - if (this.afterSave) { - this.afterSave({ - reminderAt: reminderAtISO, - reminderType: this.selectedReminderType, - autoDeletePreference: this.autoDeletePreference, - id: response.id, - name: this.model.name, - }); - } - }); - } - }, - - _deleteBookmark() { - return ajax("/bookmarks/" + this.model.id, { - type: "DELETE", - }).then((response) => { - if (this.afterDelete) { - this.afterDelete(response.topic_bookmarked); - } - }); - }, - - _parseCustomDateTime(date, time, parseTimezone = this.userTimezone) { - let dateTime = isPresent(time) ? date + " " + time : date; - let parsed = moment.tz(dateTime, parseTimezone); - - if (parseTimezone !== this.userTimezone) { - parsed = parsed.tz(this.userTimezone); - } - - return parsed; - }, - - _defaultCustomReminderTime() { - return `0${START_OF_DAY_HOUR}:00`; - }, - - _reminderAt() { - if (!this.selectedReminderType) { - return; - } - - switch (this.selectedReminderType) { - case REMINDER_TYPES.LATER_TODAY: - return this.laterToday(); - case REMINDER_TYPES.NEXT_BUSINESS_DAY: - return this.nextBusinessDay(); - case REMINDER_TYPES.TOMORROW: - return this.tomorrow(); - case REMINDER_TYPES.NEXT_WEEK: - return this.nextWeek(); - case REMINDER_TYPES.START_OF_NEXT_BUSINESS_WEEK: - return this.nextWeek().day(MOMENT_MONDAY); - case REMINDER_TYPES.LATER_THIS_WEEK: - return this.laterThisWeek(); - case REMINDER_TYPES.NEXT_MONTH: - return this.nextMonth(); - case REMINDER_TYPES.CUSTOM: - this.set( - "customReminderTime", - this.customReminderTime || this._defaultCustomReminderTime() - ); - const customDateTime = this._parseCustomDateTime( - this.customReminderDate, - this.customReminderTime - ); - if (!customDateTime.isValid()) { - this.setProperties({ - customReminderTime: null, - customReminderDate: null, - }); - return; - } - return customDateTime; - case REMINDER_TYPES.LAST_CUSTOM: - return this.parsedLastCustomReminderDatetime; - case REMINDER_TYPES.POST_LOCAL_DATE: - return this.postLocalDate; - } - }, - - nextWeek() { - return this.startOfDay(this.now().add(7, "days")); - }, - - nextMonth() { - return this.startOfDay(this.now().add(1, "month")); - }, - - tomorrow() { - return this.startOfDay(this.now().add(1, "day")); - }, - - startOfDay(momentDate) { - return momentDate.hour(START_OF_DAY_HOUR).startOf("hour"); - }, - - now() { - return moment.tz(this.userTimezone); - }, - - laterToday() { - let later = this.now().add(3, "hours"); - if (later.hour() >= LATER_TODAY_MAX_HOUR) { - return later.hour(LATER_TODAY_MAX_HOUR).startOf("hour"); - } - return later.minutes() < 30 - ? later.startOf("hour") - : later.add(30, "minutes").startOf("hour"); - }, - - laterThisWeek() { - if (!this.showLaterThisWeek) { - return; - } - return this.startOfDay(this.now().add(2, "days")); - }, - - _handleSaveError(e) { - this._savingBookmarkManually = false; - if (typeof e === "string") { - bootbox.alert(e); - } else { - popupAjaxError(e); - } - }, - - @action - toggleOptionsPanel() { - if (this.showOptions) { - $(".bookmark-options-panel").slideUp("fast"); - } else { - $(".bookmark-options-panel").slideDown("fast"); - } - this.toggleProperty("showOptions"); - }, - - @action - saveAndClose() { - if (this._saving || this._deleting) { - return; - } - - this._saving = true; - this._savingBookmarkManually = true; - return this._saveBookmark() - .then(() => this.send("closeModal")) - .catch((e) => this._handleSaveError(e)) - .finally(() => (this._saving = false)); - }, - - @action - delete() { - this._deleting = true; - let deleteAction = () => { - this._closeWithoutSaving = true; - this._deleteBookmark() - .then(() => { - this._deleting = false; - this.send("closeModal"); - }) - .catch((e) => this._handleSaveError(e)); - }; - - if (this._existingBookmarkHasReminder()) { - bootbox.confirm(I18n.t("bookmarks.confirm_delete"), (result) => { - if (result) { - deleteAction(); - } - }); - } else { - deleteAction(); - } - }, - - @action - closeWithoutSavingBookmark() { - this._closeWithoutSaving = true; - this.send("closeModal"); - }, - - @action - selectReminderType(type) { - if (type === REMINDER_TYPES.LATER_TODAY && !this.showLaterToday) { - return; - } - - this.set("selectedReminderType", type); - - if (type !== REMINDER_TYPES.CUSTOM) { - return this.saveAndClose(); - } - }, - - @action - selectPostLocalDate(date) { - this.setProperties({ - selectedReminderType: this.reminderTypes.POST_LOCAL_DATE, - postLocalDate: date, - }); - return this.saveAndClose(); }, }); diff --git a/app/assets/javascripts/discourse/app/lib/bookmark.js b/app/assets/javascripts/discourse/app/lib/bookmark.js index 8fd496083cd..3ed24cfb111 100644 --- a/app/assets/javascripts/discourse/app/lib/bookmark.js +++ b/app/assets/javascripts/discourse/app/lib/bookmark.js @@ -16,17 +16,3 @@ export function formattedReminderTime(reminderAt, timezone) { date_time: reminderAtDate.format(I18n.t("dates.long_with_year")), }); } - -export const REMINDER_TYPES = { - LATER_TODAY: "later_today", - NEXT_BUSINESS_DAY: "next_business_day", - TOMORROW: "tomorrow", - NEXT_WEEK: "next_week", - NEXT_MONTH: "next_month", - CUSTOM: "custom", - LAST_CUSTOM: "last_custom", - NONE: "none", - START_OF_NEXT_BUSINESS_WEEK: "start_of_next_business_week", - LATER_THIS_WEEK: "later_this_week", - POST_LOCAL_DATE: "post_local_date", -}; diff --git a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js index eda349b253e..8a366ffc6a8 100644 --- a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js +++ b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js @@ -124,7 +124,15 @@ export default { this.container = null; }, + isTornDown() { + return this.keyTrapper == null || this.container == null; + }, + bindKey(key, binding = null) { + if (this.isTornDown()) { + return; + } + if (!binding) { binding = DEFAULT_BINDINGS[key]; } @@ -152,11 +160,21 @@ export default { // for cases when you want to disable global keyboard shortcuts // so that you can override them (e.g. inside a modal) pause(combinations) { + if (this.isTornDown()) { + return; + } combinations.forEach((combo) => this.keyTrapper.unbind(combo)); }, // restore global shortcuts that you have paused unpause(combinations) { + if (this.isTornDown()) { + return; + } + // if the keytrapper has already been torn down this will error + if (this.keyTrapper == null) { + return; + } combinations.forEach((combo) => this.bindKey(combo)); }, diff --git a/app/assets/javascripts/discourse/app/lib/time-shortcut.js b/app/assets/javascripts/discourse/app/lib/time-shortcut.js new file mode 100644 index 00000000000..6c85d19fcc4 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/time-shortcut.js @@ -0,0 +1,103 @@ +import { + MOMENT_MONDAY, + laterThisWeek, + laterToday, + nextBusinessWeekStart, + nextMonth, + nextWeek, + now, + tomorrow, +} from "discourse/lib/time-utils"; +import I18n from "I18n"; + +export const TIME_SHORTCUT_TYPES = { + LATER_TODAY: "later_today", + TOMORROW: "tomorrow", + NEXT_WEEK: "next_week", + NEXT_MONTH: "next_month", + CUSTOM: "custom", + LAST_CUSTOM: "last_custom", + NONE: "none", + START_OF_NEXT_BUSINESS_WEEK: "start_of_next_business_week", + LATER_THIS_WEEK: "later_this_week", + POST_LOCAL_DATE: "post_local_date", +}; + +export function defaultShortcutOptions(timezone) { + return [ + { + icon: "angle-right", + id: TIME_SHORTCUT_TYPES.LATER_TODAY, + label: "time_shortcut.later_today", + time: laterToday(timezone), + timeFormatted: laterToday(timezone).format(I18n.t("dates.time")), + hidden: true, + }, + { + icon: "far-sun", + id: TIME_SHORTCUT_TYPES.TOMORROW, + label: "time_shortcut.tomorrow", + time: tomorrow(timezone), + timeFormatted: tomorrow(timezone).format(I18n.t("dates.time_short_day")), + }, + { + icon: "angle-double-right", + id: TIME_SHORTCUT_TYPES.LATER_THIS_WEEK, + label: "time_shortcut.later_this_week", + time: laterThisWeek(timezone), + timeFormatted: laterThisWeek(timezone).format( + I18n.t("dates.time_short_day") + ), + hidden: true, + }, + { + icon: "briefcase", + id: TIME_SHORTCUT_TYPES.START_OF_NEXT_BUSINESS_WEEK, + label: + now(timezone).day() === MOMENT_MONDAY + ? "time_shortcut.start_of_next_business_week_alt" + : "time_shortcut.start_of_next_business_week", + time: nextBusinessWeekStart(timezone), + timeFormatted: nextBusinessWeekStart(timezone).format( + I18n.t("dates.long_no_year") + ), + }, + { + icon: "far-clock", + id: TIME_SHORTCUT_TYPES.NEXT_WEEK, + label: "time_shortcut.next_week", + time: nextWeek(timezone), + timeFormatted: nextWeek(timezone).format(I18n.t("dates.long_no_year")), + }, + { + icon: "far-calendar-plus", + id: TIME_SHORTCUT_TYPES.NEXT_MONTH, + label: "time_shortcut.next_month", + time: nextMonth(timezone), + timeFormatted: nextMonth(timezone).format(I18n.t("dates.long_no_year")), + }, + { + icon: "calendar-alt", + id: TIME_SHORTCUT_TYPES.CUSTOM, + label: "time_shortcut.custom", + time: null, + timeFormatted: null, + isCustomTimeShortcut: true, + }, + { + icon: "undo", + id: TIME_SHORTCUT_TYPES.LAST_CUSTOM, + label: "time_shortcut.last_custom", + time: null, + timeFormatted: null, + hidden: true, + }, + { + icon: "ban", + id: TIME_SHORTCUT_TYPES.NONE, + label: "time_shortcut.none", + time: null, + timeFormatted: null, + }, + ]; +} diff --git a/app/assets/javascripts/discourse/app/lib/time-utils.js b/app/assets/javascripts/discourse/app/lib/time-utils.js new file mode 100644 index 00000000000..99a7ce504f7 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/time-utils.js @@ -0,0 +1,63 @@ +import { isPresent } from "@ember/utils"; + +export const START_OF_DAY_HOUR = 8; +export const LATER_TODAY_CUTOFF_HOUR = 17; +export const LATER_TODAY_MAX_HOUR = 18; +export const MOMENT_MONDAY = 1; +export const MOMENT_THURSDAY = 4; + +export function now(timezone) { + return moment.tz(timezone); +} + +export function startOfDay(momentDate, startOfDayHour = START_OF_DAY_HOUR) { + return momentDate.hour(startOfDayHour).startOf("hour"); +} + +export function tomorrow(timezone) { + return startOfDay(now(timezone).add(1, "day")); +} + +export function laterToday(timezone) { + let later = now(timezone).add(3, "hours"); + if (later.hour() >= LATER_TODAY_MAX_HOUR) { + return later.hour(LATER_TODAY_MAX_HOUR).startOf("hour"); + } + return later.minutes() < 30 + ? later.startOf("hour") + : later.add(30, "minutes").startOf("hour"); +} + +export function laterThisWeek(timezone) { + return startOfDay(now(timezone).add(2, "days")); +} + +export function nextWeek(timezone) { + return startOfDay(now(timezone).add(7, "days")); +} + +export function nextMonth(timezone) { + return startOfDay(now(timezone).add(1, "month")); +} + +export function nextBusinessWeekStart(timezone) { + return nextWeek(timezone).day(MOMENT_MONDAY); +} + +export function parseCustomDatetime( + date, + time, + currentTimezone, + parseTimezone = null +) { + let dateTime = isPresent(time) ? `${date} ${time}` : date; + parseTimezone = parseTimezone || currentTimezone; + + let parsed = moment.tz(dateTime, parseTimezone); + + if (parseTimezone !== currentTimezone) { + parsed = parsed.tz(currentTimezone); + } + + return parsed; +} diff --git a/app/assets/javascripts/discourse/app/templates/components/bookmark-local-date.hbs b/app/assets/javascripts/discourse/app/templates/components/bookmark-local-date.hbs deleted file mode 100644 index a30363826de..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/bookmark-local-date.hbs +++ /dev/null @@ -1,6 +0,0 @@ -{{#if showPostLocalDate}} - {{#tap-tile icon="globe-americas" tileId=tileId activeTile=activeTile onChange=(action "setReminder")}} -