+ <:body>
+
+
+
+
+
+ {{#if this.showOptions}}
+
+
+
+
+ {{/if}}
+
+ {{#if this.showExistingReminderAt}}
+
+ {{d-icon "far-clock"}}
+ {{i18n
+ "bookmarks.reminders.existing_reminder"
+ at_date_time=this.existingReminderAtFormatted
+ }}
+
+ {{/if}}
+
+
+
+
+ {{#if this.userHasTimezoneSet}}
+
+ {{else}}
+
{{html-safe
+ (i18n "bookmarks.no_timezone" basePath=(base-path))
+ }}
+ {{/if}}
+
+
+
+ <:footer>
+
+
+
+ {{#if this.showDelete}}
+
+ {{/if}}
+
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/modal/bookmark.js b/app/assets/javascripts/discourse/app/components/modal/bookmark.js
new file mode 100644
index 00000000000..62334a58dbe
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/modal/bookmark.js
@@ -0,0 +1,351 @@
+import Component from "@glimmer/component";
+import { extractError } from "discourse/lib/ajax-error";
+import { sanitize } from "discourse/lib/text";
+import { CLOSE_INITIATED_BY_CLICK_OUTSIDE } from "discourse/components/d-modal";
+import { inject as service } from "@ember/service";
+import { tracked } from "@glimmer/tracking";
+import { now, parseCustomDatetime, startOfDay } from "discourse/lib/time-utils";
+import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
+import I18n from "I18n";
+import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
+import ItsATrap from "@discourse/itsatrap";
+import { Promise } from "rsvp";
+import {
+ TIME_SHORTCUT_TYPES,
+ defaultTimeShortcuts,
+} from "discourse/lib/time-shortcut";
+import { action } from "@ember/object";
+import { ajax } from "discourse/lib/ajax";
+import { formattedReminderTime } from "discourse/lib/bookmark";
+import { and, notEmpty } from "@ember/object/computed";
+import discourseLater from "discourse-common/lib/later";
+
+const BOOKMARK_BINDINGS = {
+ enter: { handler: "saveAndClose" },
+ "d d": { handler: "delete" },
+};
+
+export default class BookmarkModal extends Component {
+ @service dialog;
+ @service currentUser;
+ @service site;
+
+ @tracked postDetectedLocalDate = null;
+ @tracked postDetectedLocalTime = null;
+ @tracked postDetectedLocalTimezone = null;
+ @tracked prefilledDatetime = null;
+ @tracked flash = null;
+ @tracked userTimezone = this.currentUser.user_option.timezone;
+ @tracked showOptions = this.args.model.bookmark.id ? true : false;
+
+ @notEmpty("userTimezone")
+ userHasTimezoneSet;
+
+ @notEmpty("bookmark.id")
+ showDelete;
+
+ @notEmpty("bookmark.id")
+ editingExistingBookmark;
+
+ @and("bookmark.id", "bookmark.reminderAt")
+ existingBookmarkHasReminder;
+
+ @tracked _closeWithoutSaving = false;
+ @tracked _savingBookmarkManually = false;
+ @tracked _saving = false;
+ @tracked _deleting = false;
+
+ _itsatrap = new ItsATrap();
+
+ get bookmark() {
+ return this.args.model.bookmark;
+ }
+
+ get modalTitle() {
+ return I18n.t(this.bookmark.id ? "bookmarks.edit" : "bookmarks.create");
+ }
+
+ get autoDeletePreferences() {
+ return Object.keys(AUTO_DELETE_PREFERENCES).map((key) => {
+ return {
+ id: AUTO_DELETE_PREFERENCES[key],
+ name: I18n.t(`bookmarks.auto_delete_preference.${key.toLowerCase()}`),
+ };
+ });
+ }
+
+ get showExistingReminderAt() {
+ return (
+ this.bookmark.reminderAt &&
+ Date.parse(this.bookmark.reminderAt) > new Date().getTime()
+ );
+ }
+
+ get existingReminderAtFormatted() {
+ return formattedReminderTime(this.bookmark.reminderAt, this.userTimezone);
+ }
+
+ get timeOptions() {
+ const options = defaultTimeShortcuts(this.userTimezone);
+
+ if (this.showPostLocalDate) {
+ options.push({
+ icon: "globe-americas",
+ id: TIME_SHORTCUT_TYPES.POST_LOCAL_DATE,
+ label: "time_shortcut.post_local_date",
+ time: this.#parsedPostLocalDateTime(),
+ timeFormatKey: "dates.long_no_year",
+ hidden: false,
+ });
+ }
+
+ return options;
+ }
+
+ get showPostLocalDate() {
+ if (!this.postDetectedLocalTime || !this.postDetectedLocalDate) {
+ return false;
+ }
+
+ if (this.#parsedPostLocalDateTime() < now(this.userTimezone)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ get hiddenTimeShortcutOptions() {
+ if (this.editingExistingBookmark && !this.existingBookmarkHasReminder) {
+ return [TIME_SHORTCUT_TYPES.NONE];
+ }
+
+ return [];
+ }
+
+ get customTimeShortcutLabels() {
+ const labels = {};
+ if (this.existingBookmarkHasReminder) {
+ labels[TIME_SHORTCUT_TYPES.NONE] =
+ "bookmarks.remove_reminder_keep_bookmark";
+ }
+ return labels;
+ }
+
+ willDestroy() {
+ this._itsatrap?.destroy();
+ this._itsatrap = null;
+ KeyboardShortcuts.unpause();
+ }
+
+ @action
+ didInsert() {
+ discourseLater(() => {
+ if (this.site.isMobileDevice) {
+ document.getElementById("bookmark-name").blur();
+ }
+ });
+
+ if (!this.args.model.bookmark.id) {
+ document.getElementById("tap_tile_none").classList.add("active");
+ }
+
+ this.#bindKeyboardShortcuts();
+ this.#initializeExistingBookmarkData();
+ this.#loadPostLocalDates();
+ }
+
+ @action
+ saveAndClose() {
+ this.flash = null;
+ if (this._saving || this._deleting) {
+ return;
+ }
+
+ this._saving = true;
+ this._savingBookmarkManually = true;
+ return this.#saveBookmark()
+ .then(() => this.args.closeModal())
+ .catch((error) => this.#handleSaveError(error))
+ .finally(() => {
+ this._saving = false;
+ });
+ }
+
+ @action
+ toggleShowOptions() {
+ this.showOptions = !this.showOptions;
+ }
+
+ @action
+ onTimeSelected(type, time) {
+ this.bookmark.selectedReminderType = type;
+ this.bookmark.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 (
+ ![TIME_SHORTCUT_TYPES.CUSTOM, TIME_SHORTCUT_TYPES.RELATIVE].includes(type)
+ ) {
+ return this.saveAndClose();
+ }
+ }
+
+ @action
+ closingModal(closeModalArgs) {
+ // If the user clicks outside the modal we save automatically for them,
+ // as long as they are not already saving manually or deleting the bookmark.
+ if (
+ closeModalArgs.initiatedBy === CLOSE_INITIATED_BY_CLICK_OUTSIDE &&
+ !this._closeWithoutSaving &&
+ !this._savingBookmarkManually
+ ) {
+ this.#saveBookmark()
+ .catch((e) => this.#handleSaveError(e))
+ .then(() => {
+ this.args.closeModal(closeModalArgs);
+ });
+ } else {
+ this.args.closeModal(closeModalArgs);
+ }
+ }
+
+ @action
+ closeWithoutSavingBookmark() {
+ this._closeWithoutSaving = true;
+ this.args.closeModal({ closeWithoutSaving: this._closeWithoutSaving });
+ }
+
+ @action
+ delete() {
+ if (!this.bookmark.id) {
+ return;
+ }
+
+ this._deleting = true;
+ const deleteAction = () => {
+ this._closeWithoutSaving = true;
+ this.#deleteBookmark()
+ .then(() => {
+ this._deleting = false;
+ this.args.closeModal({
+ closeWithoutSaving: this._closeWithoutSaving,
+ });
+ })
+ .catch((error) => this.#handleSaveError(error));
+ };
+
+ if (this.existingBookmarkHasReminder) {
+ this.dialog.deleteConfirm({
+ message: I18n.t("bookmarks.confirm_delete"),
+ didConfirm: () => deleteAction(),
+ });
+ } else {
+ deleteAction();
+ }
+ }
+
+ #parsedPostLocalDateTime() {
+ let parsedPostLocalDate = parseCustomDatetime(
+ this.postDetectedLocalDate,
+ this.postDetectedLocalTime,
+ this.userTimezone,
+ this.postDetectedLocalTimezone
+ );
+
+ if (!this.postDetectedLocalTime) {
+ return startOfDay(parsedPostLocalDate);
+ }
+
+ return parsedPostLocalDate;
+ }
+
+ #saveBookmark() {
+ if (this.bookmark.selectedReminderType === TIME_SHORTCUT_TYPES.CUSTOM) {
+ if (!this.bookmark.reminderAtISO) {
+ return Promise.reject(I18n.t("bookmarks.invalid_custom_datetime"));
+ }
+ }
+
+ if (this.editingExistingBookmark) {
+ return ajax(`/bookmarks/${this.bookmark.id}`, {
+ type: "PUT",
+ data: this.bookmark.saveData,
+ }).then(() => {
+ this.args.model.afterSave?.(this.bookmark.saveData);
+ });
+ } else {
+ return ajax("/bookmarks", {
+ type: "POST",
+ data: this.bookmark.saveData,
+ }).then((response) => {
+ this.bookmark.id = response.id;
+ this.args.model.afterSave?.(this.bookmark.saveData);
+ });
+ }
+ }
+
+ #deleteBookmark() {
+ return ajax("/bookmarks/" + this.bookmark.id, {
+ type: "DELETE",
+ }).then((response) => {
+ this.args.model.afterDelete?.(
+ response.topic_bookmarked,
+ this.bookmark.id
+ );
+ });
+ }
+
+ #handleSaveError(error) {
+ this._savingBookmarkManually = false;
+ if (typeof error === "string") {
+ this.flash = sanitize(error);
+ } else {
+ this.flash = sanitize(extractError(error));
+ }
+ }
+
+ #bindKeyboardShortcuts() {
+ KeyboardShortcuts.pause();
+
+ Object.keys(BOOKMARK_BINDINGS).forEach((shortcut) => {
+ this._itsatrap.bind(shortcut, () => {
+ const binding = BOOKMARK_BINDINGS[shortcut];
+ this[binding.handler]();
+ return false;
+ });
+ });
+ }
+
+ #initializeExistingBookmarkData() {
+ if (!this.existingBookmarkHasReminder || !this.editingExistingBookmark) {
+ return;
+ }
+
+ this.prefilledDatetime = this.bookmark.reminderAt;
+ this.bookmark.selectedDatetime = parseCustomDatetime(
+ this.prefilledDatetime,
+ null,
+ this.userTimezone
+ );
+ }
+
+ // If we detect we are bookmarking a post which has local-date data
+ // in it, we can preload that date + time into the form to use as the
+ // bookmark reminder date + time.
+ #loadPostLocalDates() {
+ if (this.bookmark.bookmarkableType !== "Post") {
+ return;
+ }
+
+ const postEl = document.querySelector(
+ `[data-post-id="${this.bookmark.bookmarkableId}"]`
+ );
+ const localDateEl = postEl?.querySelector(".discourse-local-date");
+
+ if (localDateEl) {
+ this.postDetectedLocalDate = localDateEl.dataset.date;
+ this.postDetectedLocalTime = localDateEl.dataset.time;
+ this.postDetectedLocalTimezone = localDateEl.dataset.timezone;
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/controllers/bookmark.js b/app/assets/javascripts/discourse/app/controllers/bookmark.js
deleted file mode 100644
index c05fcda47ad..00000000000
--- a/app/assets/javascripts/discourse/app/controllers/bookmark.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import Controller from "@ember/controller";
-import I18n from "I18n";
-import ModalFunctionality from "discourse/mixins/modal-functionality";
-import { action } from "@ember/object";
-import { Promise } from "rsvp";
-import showModal from "discourse/lib/show-modal";
-
-export function openBookmarkModal(
- bookmark,
- callbacks = {
- onCloseWithoutSaving: null,
- onAfterSave: null,
- onAfterDelete: null,
- }
-) {
- return new Promise((resolve) => {
- const model = {
- id: bookmark.id,
- reminderAt: bookmark.reminder_at,
- autoDeletePreference: bookmark.auto_delete_preference,
- name: bookmark.name,
- };
-
- model.bookmarkableId = bookmark.bookmarkable_id;
- model.bookmarkableType = bookmark.bookmarkable_type;
-
- let modalController = showModal("bookmark", {
- model,
- titleTranslated: I18n.t(
- bookmark.id ? "bookmarks.edit" : "bookmarks.create"
- ),
- modalClass: "bookmark-with-reminder",
- });
- modalController.setProperties({
- onCloseWithoutSaving: () => {
- if (callbacks.onCloseWithoutSaving) {
- callbacks.onCloseWithoutSaving();
- }
- resolve();
- },
- afterSave: (savedData) => {
- let resolveData;
- if (callbacks.onAfterSave) {
- resolveData = callbacks.onAfterSave(savedData);
- }
- resolve(resolveData);
- },
- afterDelete: (topicBookmarked, bookmarkId) => {
- if (callbacks.onAfterDelete) {
- callbacks.onAfterDelete(topicBookmarked, bookmarkId);
- }
- resolve();
- },
- });
- });
-}
-
-export default Controller.extend(ModalFunctionality, {
- onShow() {
- this.setProperties({
- model: this.model || {},
- allowSave: true,
- });
- },
-
- @action
- registerOnCloseHandler(handlerFn) {
- this.set("onCloseHandler", handlerFn);
- },
-
- /**
- * We always want to save the bookmark unless the user specifically
- * clicks the save or cancel button to mimic browser behaviour.
- */
- onClose(opts = {}) {
- if (this.onCloseHandler) {
- this.onCloseHandler(opts);
- }
- },
-});
diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js
index 7b2b6ec9232..a5c62883319 100644
--- a/app/assets/javascripts/discourse/app/controllers/topic.js
+++ b/app/assets/javascripts/discourse/app/controllers/topic.js
@@ -10,6 +10,11 @@ import { resetCachedTopicList } from "discourse/lib/cached-topic-list";
import { isEmpty, isPresent } from "@ember/utils";
import { next, schedule } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later";
+import BookmarkModal from "discourse/components/modal/bookmark";
+import {
+ CLOSE_INITIATED_BY_BUTTON,
+ CLOSE_INITIATED_BY_ESC,
+} from "discourse/components/d-modal";
import Bookmark, { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
import Composer from "discourse/models/composer";
import EmberObject, { action } from "@ember/object";
@@ -29,7 +34,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
import showModal from "discourse/lib/show-modal";
import { spinnerHTML } from "discourse/helpers/loading-spinner";
-import { openBookmarkModal } from "discourse/controllers/bookmark";
+import { BookmarkFormData } from "discourse/lib/bookmark";
let customPostMessageCallbacks = {};
@@ -53,6 +58,7 @@ export default Controller.extend(bufferedProperty("model"), {
dialog: service(),
documentTitle: service(),
screenTrack: service(),
+ modal: service(),
multiSelect: false,
selectedPostIds: null,
@@ -1272,43 +1278,60 @@ export default Controller.extend(bufferedProperty("model"), {
},
_modifyTopicBookmark(bookmark) {
- return openBookmarkModal(bookmark, {
- onAfterSave: (savedData) => {
- this._syncBookmarks(savedData);
- this.model.set("bookmarking", false);
- this.model.set("bookmarked", true);
- this.model.incrementProperty("bookmarksWereChanged");
- this.appEvents.trigger(
- "bookmarks:changed",
- savedData,
- bookmark.attachedTo()
- );
- },
- onAfterDelete: (topicBookmarked, bookmarkId) => {
- this.model.removeBookmark(bookmarkId);
+ this.modal.show(BookmarkModal, {
+ model: {
+ bookmark: new BookmarkFormData(bookmark),
+ afterSave: (savedData) => {
+ this._syncBookmarks(savedData);
+ this.model.set("bookmarking", false);
+ this.model.set("bookmarked", true);
+ this.model.incrementProperty("bookmarksWereChanged");
+ this.appEvents.trigger(
+ "bookmarks:changed",
+ savedData,
+ bookmark.attachedTo()
+ );
+ },
+ afterDelete: (topicBookmarked, bookmarkId) => {
+ this.model.removeBookmark(bookmarkId);
+ },
},
});
},
_modifyPostBookmark(bookmark, post) {
- return openBookmarkModal(bookmark, {
- onCloseWithoutSaving: () => {
- post.appEvents.trigger("post-stream:refresh", {
- id: bookmark.bookmarkable_id,
- });
- },
- onAfterSave: (savedData) => {
- this._syncBookmarks(savedData);
- this.model.set("bookmarking", false);
- post.createBookmark(savedData);
- this.model.afterPostBookmarked(post, savedData);
- return [post.id];
- },
- onAfterDelete: (topicBookmarked, bookmarkId) => {
- this.model.removeBookmark(bookmarkId);
- post.deleteBookmark(topicBookmarked);
- },
- });
+ this.modal
+ .show(BookmarkModal, {
+ model: {
+ bookmark: new BookmarkFormData(bookmark),
+ afterSave: (savedData) => {
+ this._syncBookmarks(savedData);
+ this.model.set("bookmarking", false);
+ post.createBookmark(savedData);
+ this.model.afterPostBookmarked(post, savedData);
+ return [post.id];
+ },
+ afterDelete: (topicBookmarked, bookmarkId) => {
+ this.model.removeBookmark(bookmarkId);
+ post.deleteBookmark(topicBookmarked);
+ },
+ },
+ })
+ .then((closeData) => {
+ if (!closeData) {
+ return;
+ }
+
+ if (
+ closeData.closeWithoutSaving ||
+ closeData.initiatedBy === CLOSE_INITIATED_BY_ESC ||
+ closeData.initiatedBy === CLOSE_INITIATED_BY_BUTTON
+ ) {
+ post.appEvents.trigger("post-stream:refresh", {
+ id: bookmark.bookmarkable_id,
+ });
+ }
+ });
},
_syncBookmarks(data) {
diff --git a/app/assets/javascripts/discourse/app/lib/bookmark.js b/app/assets/javascripts/discourse/app/lib/bookmark.js
index 3ed24cfb111..367ed8f8170 100644
--- a/app/assets/javascripts/discourse/app/lib/bookmark.js
+++ b/app/assets/javascripts/discourse/app/lib/bookmark.js
@@ -1,4 +1,8 @@
import I18n from "I18n";
+import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark";
+import { TIME_SHORTCUT_TYPES } from "discourse/lib/time-shortcut";
+import { tracked } from "@glimmer/tracking";
+
export function formattedReminderTime(reminderAt, timezone) {
let reminderAtDate = moment.tz(reminderAt, timezone);
let formatted = reminderAtDate.format(I18n.t("dates.time"));
@@ -16,3 +20,43 @@ export function formattedReminderTime(reminderAt, timezone) {
date_time: reminderAtDate.format(I18n.t("dates.long_with_year")),
});
}
+
+export class BookmarkFormData {
+ @tracked selectedDatetime;
+ @tracked selectedReminderType = TIME_SHORTCUT_TYPES.NONE;
+ @tracked id;
+ @tracked reminderAt;
+ @tracked autoDeletePreference;
+ @tracked name;
+ @tracked bookmarkableId;
+ @tracked bookmarkableType;
+
+ constructor(bookmark) {
+ this.id = bookmark.id;
+ this.reminderAt = bookmark.reminder_at;
+ this.name = bookmark.name;
+ this.bookmarkableId = bookmark.bookmarkable_id;
+ this.bookmarkableType = bookmark.bookmarkable_type;
+ this.autoDeletePreference =
+ bookmark.auto_delete_preference ?? AUTO_DELETE_PREFERENCES.CLEAR_REMINDER;
+ }
+
+ get reminderAtISO() {
+ if (!this.selectedReminderType || !this.selectedDatetime) {
+ return;
+ }
+
+ return this.selectedDatetime.toISOString();
+ }
+
+ get saveData() {
+ return {
+ reminder_at: this.reminderAtISO,
+ name: this.name,
+ id: this.id,
+ auto_delete_preference: this.autoDeletePreference,
+ bookmarkable_id: this.bookmarkableId,
+ bookmarkable_type: this.bookmarkableType,
+ };
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs b/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs
deleted file mode 100644
index c73b473949d..00000000000
--- a/app/assets/javascripts/discourse/app/templates/modal/bookmark.hbs
+++ /dev/null
@@ -1,10 +0,0 @@
-