FEATURE: Topic-level bookmarks (#14353)

FEATURE: Topic-level bookmarks (#14353)

Allows creating a bookmark with the for_topic flag introduced in https://github.com/discourse/discourse/commit/d1d2298a4cee0b021db66ff116a665ee8ae27111 set to true. This happens when clicking on the Bookmark button in the topic footer when no other posts are bookmarked. In a later PR, when clicking on these topic-level bookmarks the user will be taken to the last unread post in the topic, not the OP. Only the OP can have a topic level bookmark, and users can also make a post-level bookmark on the OP of the topic.

I had to do some pretty heavy refactors because most of the bookmark code in the JS topics controller was centred around instances of Post JS models, but the topic level bookmark is not centred around a post. Some refactors were just for readability as well.

Also removes some missed reminderType code from the purge in https://github.com/discourse/discourse/commit/41e19adb0d7685303009650efec3ebec0319c577

diff --git a/app/assets/javascripts/discourse/app/components/bookmark.js b/app/assets/javascripts/discourse/app/components/bookmark.js
index 25157f2..b635441 100644
--- a/app/assets/javascripts/discourse/app/components/bookmark.js
+++ b/app/assets/javascripts/discourse/app/components/bookmark.js
@@ -167,25 +167,13 @@ export default Component.extend({
 
     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,
+      for_topic: this.model.forTopic,
     };
 
     if (this.editingExistingBookmark) {
@@ -207,9 +195,10 @@ export default Component.extend({
       return;
     }
     this.afterSave({
-      reminderAt: reminderAtISO,
-      reminderType: this.selectedReminderType,
-      autoDeletePreference: this.autoDeletePreference,
+      reminder_at: reminderAtISO,
+      for_topic: this.model.forTopic,
+      auto_delete_preference: this.autoDeletePreference,
+      post_id: this.model.postId,
       id: this.model.id || response.id,
       name: this.model.name,
     });
@@ -220,7 +209,7 @@ export default Component.extend({
       type: "DELETE",
     }).then((response) => {
       if (this.afterDelete) {
-        this.afterDelete(response.topic_bookmarked);
+        this.afterDelete(response.topic_bookmarked, this.model.id);
       }
     });
   },
diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js
index d41257b..42db37f 100644
--- a/app/assets/javascripts/discourse/app/controllers/topic.js
+++ b/app/assets/javascripts/discourse/app/controllers/topic.js
@@ -215,15 +215,23 @@ export default Controller.extend(bufferedProperty("model"), {
     if (posts) {
       posts
         .filter(
-          (p) =>
-            p.bookmarked &&
-            p.bookmark_auto_delete_preference ===
+          (post) =>
+            post.bookmarked &&
+            post.bookmark_auto_delete_preference ===
               AUTO_DELETE_PREFERENCES.ON_OWNER_REPLY
         )
-        .forEach((p) => {
-          p.clearBookmark();
+        .forEach((post) => {
+          post.clearBookmark();
+          this.model.removeBookmark(post.bookmark_id);
         });
     }
+    const forTopicBookmark = this.model.bookmarks.findBy("for_topic", true);
+    if (
+      forTopicBookmark?.auto_delete_preference ===
+      AUTO_DELETE_PREFERENCES.ON_OWNER_REPLY
+    ) {
+      this.model.removeBookmark(forTopicBookmark.id);
+    }
   },
 
   _forceRefreshPostStream() {
@@ -723,9 +731,15 @@ export default Controller.extend(bufferedProperty("model"), {
       if (!this.currentUser) {
         return bootbox.alert(I18n.t("bookmarks.not_bookmarked"));
       } else if (post) {
-        return this._togglePostBookmark(post);
+        const bookmarkForPost = this.model.bookmarks.find(
+          (bookmark) => bookmark.post_id === post.id && !bookmark.for_topic
+        );
+        return this._modifyPostBookmark(
+          bookmarkForPost || { post_id: post.id, for_topic: false },
+          post
+        );
       } else {
-        return this._toggleTopicBookmark(this.model).then((changedIds) => {
+        return this._toggleTopicLevelBookmark().then((changedIds) => {
           if (!changedIds) {
             return;
           }
@@ -1189,110 +1203,151 @@ export default Controller.extend(bufferedProperty("model"), {
     }
   },
 
-  _togglePostBookmark(post) {
+  _modifyTopicBookmark(bookmark) {
+    const title = bookmark.id
+      ? "post.bookmarks.edit_for_topic"
+      : "post.bookmarks.create_for_topic";
+    return this._openBookmarkModal(bookmark, title, {
+      onAfterSave: () => {
+        this.model.set("bookmarked", true);
+        this.model.incrementProperty("bookmarksWereChanged");
+      },
+    });
+  },
+
+  _modifyPostBookmark(bookmark, post) {
+    const title = bookmark.id ? "post.bookmarks.edit" : "post.bookmarks.create";
+    return this._openBookmarkModal(bookmark, title, {
+      onCloseWithoutSaving: () => {
+        post.appEvents.trigger("post-stream:refresh", {
+          id: bookmark.post_id,
+        });
+      },
+      onAfterSave: (savedData) => {
+        post.createBookmark(savedData);
+        this.model.afterPostBookmarked(post, savedData);
+        return [post.id];
+      },
+      onAfterDelete: (topicBookmarked) => {
+        post.deleteBookmark(topicBookmarked);
+      },
+    });
+  },
+
+  _openBookmarkModal(
+    bookmark,
+    title,
+    callbacks = {
+      onCloseWithoutSaving: null,
+      onAfterSave: null,
+      onAfterDelete: null,
+    }
+  ) {
     return new Promise((resolve) => {
       let modalController = showModal("bookmark", {
         model: {
-          postId: post.id,
-          id: post.bookmark_id,
-          reminderAt: post.bookmark_reminder_at,
-          autoDeletePreference: post.bookmark_auto_delete_preference,
-          name: post.bookmark_name,
+          postId: bookmark.post_id,
+          id: bookmark.id,
+          reminderAt: bookmark.reminder_at,
+          autoDeletePreference: bookmark.auto_delete_preference,
+          name: bookmark.name,
+          forTopic: bookmark.for_topic,
         },
-        title: post.bookmark_id
-          ? "post.bookmarks.edit"
-          : "post.bookmarks.create",
+        title,
         modalClass: "bookmark-with-reminder",
       });
       modalController.setProperties({
         onCloseWithoutSaving: () => {
-          resolve({ closedWithoutSaving: true });
-          post.appEvents.trigger("post-stream:refresh", { id: post.id });
+          if (callbacks.onCloseWithoutSaving) {
+            callbacks.onCloseWithoutSaving();
+          }
+          resolve();
         },
         afterSave: (savedData) => {
-          this._addOrUpdateBookmarkedPost(post.id, savedData.reminderAt);
-          post.createBookmark(savedData);
-          resolve({ closedWithoutSaving: false });
+          this._syncBookmarks(savedData);
+          this.model.set("bookmarking", false);
+          let resolveData;
+          if (callbacks.onAfterSave) {
+            resolveData = callbacks.onAfterSave(savedData);
+          }
+          resolve(resolveData);
         },
-        afterDelete: (topicBookmarked) => {
-          this.model.set(
-            "bookmarked_posts",
-            this.model.bookmarked_posts.filter((x) => x.post_id !== post.id)
-          );
-          post.deleteBookmark(topicBookmarked);
+        afterDelete: (topicBookmarked, bookmarkId) => {
+          this.model.removeBookmark(bookmarkId);
+          if (callbacks.onAfterDelete) {
+            callbacks.onAfterDelete(topicBookmarked);
+          }
+          resolve();
         },
       });
     });
   },
 
-  _addOrUpdateBookmarkedPost(postId, reminderAt) {
-    if (!this.model.bookmarked_posts) {
-      this.model.set("bookmarked_posts", []);
+  _syncBookmarks(data) {
+    if (!this.model.bookmarks) {
+      this.model.set("bookmarks", []);
     }
 
-    let bookmarkedPost = this.model.bookmarked_posts.findBy("post_id", postId);
-    if (!bookmarkedPost) {
-      bookmarkedPost = { post_id: postId };
-      this.model.bookmarked_posts.pushObject(bookmarkedPost);
+    const bookmark = this.model.bookmarks.findBy("id", data.id);
+    if (!bookmark) {
+      this.model.bookmarks.pushObject(data);
+    } else {
+      bookmark.reminder_at = data.reminder_at;

[... diff too long, it was truncated ...]

GitHub sha: 0c42a1e5f3ea55090936551329cac4af111ca7f8

This commit appears in #14353 which was approved by eviltrout. It was merged by martin.