FEATURE: Topic-level bookmarks (#14353)

Allows creating a bookmark with the `for_topic` flag introduced in d1d2298a4c 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 41e19adb0d
This commit is contained in:
Martin Brennan 2021-09-21 08:45:47 +10:00 committed by GitHub
parent 02f7035cbe
commit 0c42a1e5f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 446 additions and 172 deletions

View File

@ -167,25 +167,13 @@ export default Component.extend({
localStorage.bookmarkDeleteOption = this.autoDeletePreference; 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 = { const data = {
reminder_type: reminderType,
reminder_at: reminderAtISO, reminder_at: reminderAtISO,
name: this.model.name, name: this.model.name,
post_id: this.model.postId, post_id: this.model.postId,
id: this.model.id, id: this.model.id,
auto_delete_preference: this.autoDeletePreference, auto_delete_preference: this.autoDeletePreference,
for_topic: this.model.forTopic,
}; };
if (this.editingExistingBookmark) { if (this.editingExistingBookmark) {
@ -207,9 +195,10 @@ export default Component.extend({
return; return;
} }
this.afterSave({ this.afterSave({
reminderAt: reminderAtISO, reminder_at: reminderAtISO,
reminderType: this.selectedReminderType, for_topic: this.model.forTopic,
autoDeletePreference: this.autoDeletePreference, auto_delete_preference: this.autoDeletePreference,
post_id: this.model.postId,
id: this.model.id || response.id, id: this.model.id || response.id,
name: this.model.name, name: this.model.name,
}); });
@ -220,7 +209,7 @@ export default Component.extend({
type: "DELETE", type: "DELETE",
}).then((response) => { }).then((response) => {
if (this.afterDelete) { if (this.afterDelete) {
this.afterDelete(response.topic_bookmarked); this.afterDelete(response.topic_bookmarked, this.model.id);
} }
}); });
}, },

View File

@ -215,15 +215,23 @@ export default Controller.extend(bufferedProperty("model"), {
if (posts) { if (posts) {
posts posts
.filter( .filter(
(p) => (post) =>
p.bookmarked && post.bookmarked &&
p.bookmark_auto_delete_preference === post.bookmark_auto_delete_preference ===
AUTO_DELETE_PREFERENCES.ON_OWNER_REPLY AUTO_DELETE_PREFERENCES.ON_OWNER_REPLY
) )
.forEach((p) => { .forEach((post) => {
p.clearBookmark(); 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() { _forceRefreshPostStream() {
@ -723,9 +731,15 @@ export default Controller.extend(bufferedProperty("model"), {
if (!this.currentUser) { if (!this.currentUser) {
return bootbox.alert(I18n.t("bookmarks.not_bookmarked")); return bootbox.alert(I18n.t("bookmarks.not_bookmarked"));
} else if (post) { } 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 { } else {
return this._toggleTopicBookmark(this.model).then((changedIds) => { return this._toggleTopicLevelBookmark().then((changedIds) => {
if (!changedIds) { if (!changedIds) {
return; 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) => { return new Promise((resolve) => {
let modalController = showModal("bookmark", { let modalController = showModal("bookmark", {
model: { model: {
postId: post.id, postId: bookmark.post_id,
id: post.bookmark_id, id: bookmark.id,
reminderAt: post.bookmark_reminder_at, reminderAt: bookmark.reminder_at,
autoDeletePreference: post.bookmark_auto_delete_preference, autoDeletePreference: bookmark.auto_delete_preference,
name: post.bookmark_name, name: bookmark.name,
forTopic: bookmark.for_topic,
}, },
title: post.bookmark_id title,
? "post.bookmarks.edit"
: "post.bookmarks.create",
modalClass: "bookmark-with-reminder", modalClass: "bookmark-with-reminder",
}); });
modalController.setProperties({ modalController.setProperties({
onCloseWithoutSaving: () => { onCloseWithoutSaving: () => {
resolve({ closedWithoutSaving: true }); if (callbacks.onCloseWithoutSaving) {
post.appEvents.trigger("post-stream:refresh", { id: post.id }); callbacks.onCloseWithoutSaving();
}
resolve();
}, },
afterSave: (savedData) => { afterSave: (savedData) => {
this._addOrUpdateBookmarkedPost(post.id, savedData.reminderAt); this._syncBookmarks(savedData);
post.createBookmark(savedData); this.model.set("bookmarking", false);
resolve({ closedWithoutSaving: false }); let resolveData;
if (callbacks.onAfterSave) {
resolveData = callbacks.onAfterSave(savedData);
}
resolve(resolveData);
}, },
afterDelete: (topicBookmarked) => { afterDelete: (topicBookmarked, bookmarkId) => {
this.model.set( this.model.removeBookmark(bookmarkId);
"bookmarked_posts", if (callbacks.onAfterDelete) {
this.model.bookmarked_posts.filter((x) => x.post_id !== post.id) callbacks.onAfterDelete(topicBookmarked);
); }
post.deleteBookmark(topicBookmarked); resolve();
}, },
}); });
}); });
}, },
_addOrUpdateBookmarkedPost(postId, reminderAt) { _syncBookmarks(data) {
if (!this.model.bookmarked_posts) { if (!this.model.bookmarks) {
this.model.set("bookmarked_posts", []); this.model.set("bookmarks", []);
} }
let bookmarkedPost = this.model.bookmarked_posts.findBy("post_id", postId); const bookmark = this.model.bookmarks.findBy("id", data.id);
if (!bookmarkedPost) { if (!bookmark) {
bookmarkedPost = { post_id: postId }; this.model.bookmarks.pushObject(data);
this.model.bookmarked_posts.pushObject(bookmarkedPost); } else {
bookmark.reminder_at = data.reminder_at;
bookmark.name = data.name;
bookmark.auto_delete_preference = data.auto_delete_preference;
} }
bookmarkedPost.reminder_at = reminderAt;
}, },
_toggleTopicBookmark() { async _toggleTopicLevelBookmark() {
if (this.model.bookmarking) { if (this.model.bookmarking) {
return Promise.resolve(); return Promise.resolve();
} }
this.model.set("bookmarking", true);
const bookmarkedPostsCount = this.model.bookmarked_posts
? this.model.bookmarked_posts.length
: 0;
const bookmarkPost = async (post) => { if (this.model.bookmarkCount > 1) {
const opts = await this._togglePostBookmark(post); return this._maybeClearAllBookmarks();
this.model.set("bookmarking", false); }
if (opts.closedWithoutSaving) {
return;
}
this.model.afterPostBookmarked(post);
return [post.id];
};
const toggleBookmarkOnServer = async () => { if (this.model.bookmarkCount === 1) {
if (bookmarkedPostsCount === 0) { const forTopicBookmark = this.model.bookmarks.findBy("for_topic", true);
const firstPost = await this.model.firstPost(); if (forTopicBookmark) {
return bookmarkPost(firstPost); return this._modifyTopicBookmark(forTopicBookmark);
} else if (bookmarkedPostsCount === 1) {
const postId = this.model.bookmarked_posts[0].post_id;
const post = await this.model.postById(postId);
return bookmarkPost(post);
} else { } else {
return this.model const bookmark = this.model.bookmarks[0];
.deleteBookmarks() const post = await this.model.postById(bookmark.post_id);
.then(() => this.model.clearBookmarks()) return this._modifyPostBookmark(bookmark, post);
.catch(popupAjaxError)
.finally(() => this.model.set("bookmarking", false));
} }
}; }
if (this.model.bookmarkCount === 0) {
const firstPost = await this.model.firstPost();
return this._modifyTopicBookmark({
post_id: firstPost.id,
for_topic: true,
});
}
},
_maybeClearAllBookmarks() {
return new Promise((resolve) => { return new Promise((resolve) => {
if (bookmarkedPostsCount > 1) { bootbox.confirm(
bootbox.confirm( I18n.t("bookmarks.confirm_clear"),
I18n.t("bookmarks.confirm_clear"), I18n.t("no_value"),
I18n.t("no_value"), I18n.t("yes_value"),
I18n.t("yes_value"), (confirmed) => {
(confirmed) => { if (confirmed) {
if (confirmed) { return this.model
toggleBookmarkOnServer().then(resolve); .deleteBookmarks()
} else { .then(() => resolve(this.model.clearBookmarks()))
this.model.set("bookmarking", false); .catch(popupAjaxError)
resolve(); .finally(() => {
} this.model.set("bookmarking", false);
});
} else {
this.model.set("bookmarking", false);
resolve();
} }
); }
} else { );
toggleBookmarkOnServer().then(resolve);
}
}); });
}, },

View File

@ -67,8 +67,7 @@ export default {
dependentKeys: ["topic.bookmarked", "topic.bookmarksWereChanged"], dependentKeys: ["topic.bookmarked", "topic.bookmarksWereChanged"],
id: "bookmark", id: "bookmark",
icon() { icon() {
const bookmarkedPosts = this.topic.bookmarked_posts; if (this.topic.bookmarks.some((bookmark) => bookmark.reminder_at)) {
if (bookmarkedPosts && bookmarkedPosts.find((x) => x.reminder_at)) {
return "discourse-bookmark-clock"; return "discourse-bookmark-clock";
} }
return "bookmark"; return "bookmark";
@ -81,14 +80,9 @@ export default {
}, },
label() { label() {
if (!this.topic.isPrivateMessage || this.site.mobileView) { if (!this.topic.isPrivateMessage || this.site.mobileView) {
const bookmarkedPosts = this.topic.bookmarked_posts; if (this.topic.bookmarkCount === 0) {
const bookmarkedPostsCount = bookmarkedPosts
? bookmarkedPosts.length
: 0;
if (bookmarkedPostsCount === 0) {
return "bookmarked.title"; return "bookmarked.title";
} else if (bookmarkedPostsCount === 1) { } else if (this.topic.bookmarkCount === 1) {
return "bookmarked.edit_bookmark"; return "bookmarked.edit_bookmark";
} else { } else {
return "bookmarked.clear_bookmarks"; return "bookmarked.clear_bookmarks";
@ -96,12 +90,19 @@ export default {
} }
}, },
translatedTitle() { translatedTitle() {
const bookmarkedPosts = this.topic.bookmarked_posts; if (this.topic.bookmarkCount === 0) {
if (!bookmarkedPosts || bookmarkedPosts.length === 0) {
return I18n.t("bookmarked.help.bookmark"); return I18n.t("bookmarked.help.bookmark");
} else if (bookmarkedPosts.length === 1) { } else if (this.topic.bookmarkCount === 1) {
return I18n.t("bookmarked.help.edit_bookmark"); if (
} else if (bookmarkedPosts.find((x) => x.reminder_at)) { this.topic.bookmarks.filter((bookmark) => bookmark.for_topic).length
) {
return I18n.t("bookmarked.help.edit_bookmark_for_topic");
} else {
return I18n.t("bookmarked.help.edit_bookmark");
}
} else if (
this.topic.bookmarks.some((bookmark) => bookmark.reminder_at)
) {
return I18n.t("bookmarked.help.unbookmark_with_reminder"); return I18n.t("bookmarked.help.unbookmark_with_reminder");
} else { } else {
return I18n.t("bookmarked.help.unbookmark"); return I18n.t("bookmarked.help.unbookmark");

View File

@ -308,9 +308,8 @@ const Post = RestModel.extend({
this.setProperties({ this.setProperties({
"topic.bookmarked": true, "topic.bookmarked": true,
bookmarked: true, bookmarked: true,
bookmark_reminder_at: data.reminderAt, bookmark_reminder_at: data.reminder_at,
bookmark_reminder_type: data.reminderType, bookmark_auto_delete_preference: data.auto_delete_preference,
bookmark_auto_delete_preference: data.autoDeletePreference,
bookmark_name: data.name, bookmark_name: data.name,
bookmark_id: data.id, bookmark_id: data.id,
}); });
@ -329,7 +328,6 @@ const Post = RestModel.extend({
clearBookmark() { clearBookmark() {
this.setProperties({ this.setProperties({
bookmark_reminder_at: null, bookmark_reminder_at: null,
bookmark_reminder_type: null,
bookmark_name: null, bookmark_name: null,
bookmark_id: null, bookmark_id: null,
bookmarked: false, bookmarked: false,

View File

@ -376,17 +376,31 @@ const Topic = RestModel.extend({
return ajax(`/t/${this.id}/remove_bookmarks`, { type: "PUT" }); return ajax(`/t/${this.id}/remove_bookmarks`, { type: "PUT" });
}, },
bookmarkCount: alias("bookmarks.length"),
removeBookmark(id) {
if (!this.bookmarks) {
this.set("bookmarks", []);
}
this.set(
"bookmarks",
this.bookmarks.filter((bookmark) => bookmark.id !== id)
);
this.set("bookmarked", this.bookmarks.length);
this.incrementProperty("bookmarksWereChanged");
},
clearBookmarks() { clearBookmarks() {
this.toggleProperty("bookmarked"); this.toggleProperty("bookmarked");
const postIds = this.bookmarked_posts.mapBy("post_id"); const postIds = this.bookmarks.mapBy("post_id");
postIds.forEach((postId) => { postIds.forEach((postId) => {
const loadedPost = this.postStream.findLoadedPost(postId); const loadedPost = this.postStream.findLoadedPost(postId);
if (loadedPost) { if (loadedPost) {
loadedPost.clearBookmark(); loadedPost.clearBookmark();
} }
}); });
this.set("bookmarked_posts", []); this.set("bookmarks", []);
return postIds; return postIds;
}, },
@ -609,6 +623,7 @@ Topic.reopenClass({
munge(json) { munge(json) {
// ensure we are not overriding category computed property // ensure we are not overriding category computed property
delete json.category; delete json.category;
json.bookmarks = json.bookmarks || [];
return json; return json;
}, },

View File

@ -57,11 +57,6 @@ async function testTopicLevelBookmarkButtonIcon(assert, postNumber) {
acceptance("Bookmarking", function (needs) { acceptance("Bookmarking", function (needs) {
needs.user(); needs.user();
let steps = [];
needs.hooks.beforeEach(function () {
steps = [];
});
const topicResponse = topicFixtures["/t/280/1.json"]; const topicResponse = topicFixtures["/t/280/1.json"];
topicResponse.post_stream.posts[0].cooked += `<span data-date="2036-01-15" data-time="00:35:00" class="discourse-local-date cooked-date past" data-timezone="Europe/London"> topicResponse.post_stream.posts[0].cooked += `<span data-date="2036-01-15" data-time="00:35:00" class="discourse-local-date cooked-date past" data-timezone="Europe/London">
@ -85,10 +80,13 @@ acceptance("Bookmarking", function (needs) {
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
function handleRequest(request) { function handleRequest(request) {
const data = helper.parsePostData(request.requestBody); const data = helper.parsePostData(request.requestBody);
steps.push(data.reminder_type || "none");
if (data.post_id === "398") { if (data.post_id === "398") {
return helper.response({ id: 1, success: "OK" }); if (data.for_topic === "true") {
return helper.response({ id: 3, success: "OK" });
} else {
return helper.response({ id: 1, success: "OK" });
}
} else if (data.post_id === "419") { } else if (data.post_id === "419") {
return helper.response({ id: 2, success: "OK" }); return helper.response({ id: 2, success: "OK" });
} else { } else {
@ -98,6 +96,7 @@ acceptance("Bookmarking", function (needs) {
server.post("/bookmarks", handleRequest); server.post("/bookmarks", handleRequest);
server.put("/bookmarks/1", handleRequest); server.put("/bookmarks/1", handleRequest);
server.put("/bookmarks/2", handleRequest); server.put("/bookmarks/2", handleRequest);
server.put("/bookmarks/3", handleRequest);
server.delete("/bookmarks/1", () => server.delete("/bookmarks/1", () =>
helper.response({ success: "OK", topic_bookmarked: false }) helper.response({ success: "OK", topic_bookmarked: false })
); );
@ -131,13 +130,6 @@ acceptance("Bookmarking", function (needs) {
assert.ok(exists(".tap-tile-date-input"), "it shows the custom date input"); assert.ok(exists(".tap-tile-date-input"), "it shows the custom date input");
assert.ok(exists(".tap-tile-time-input"), "it shows the custom time input"); assert.ok(exists(".tap-tile-time-input"), "it shows the custom time input");
await click("#save-bookmark"); await click("#save-bookmark");
assert.deepEqual(steps, [
"tomorrow",
"start_of_next_business_week",
"next_month",
"custom",
]);
}); });
test("Saving a bookmark with a reminder", async function (assert) { test("Saving a bookmark with a reminder", async function (assert) {
@ -156,7 +148,6 @@ acceptance("Bookmarking", function (needs) {
), ),
"it shows the bookmark clock icon because of the reminder" "it shows the bookmark clock icon because of the reminder"
); );
assert.deepEqual(steps, ["tomorrow"]);
}); });
test("Opening the options panel and remembering the option", async function (assert) { test("Opening the options panel and remembering the option", async function (assert) {
@ -177,7 +168,6 @@ acceptance("Bookmarking", function (needs) {
"it should reopen the options panel" "it should reopen the options panel"
); );
assert.equal(selectKit(".bookmark-option-selector").header().value(), 1); assert.equal(selectKit(".bookmark-option-selector").header().value(), 1);
assert.deepEqual(steps, ["none"]);
}); });
test("Saving a bookmark with no reminder or name", async function (assert) { test("Saving a bookmark with no reminder or name", async function (assert) {
@ -195,7 +185,6 @@ acceptance("Bookmarking", function (needs) {
), ),
"it shows the regular bookmark active icon" "it shows the regular bookmark active icon"
); );
assert.deepEqual(steps, ["none"]);
}); });
test("Deleting a bookmark with a reminder", async function (assert) { test("Deleting a bookmark with a reminder", async function (assert) {
@ -203,8 +192,6 @@ acceptance("Bookmarking", function (needs) {
await openBookmarkModal(); await openBookmarkModal();
await click("#tap_tile_tomorrow"); await click("#tap_tile_tomorrow");
assert.deepEqual(steps, ["tomorrow"]);
await openEditBookmarkModal(); await openEditBookmarkModal();
assert.ok( assert.ok(
@ -264,7 +251,6 @@ acceptance("Bookmarking", function (needs) {
"08:00", "08:00",
"it should prefill the bookmark time" "it should prefill the bookmark time"
); );
assert.deepEqual(steps, ["tomorrow"]);
}); });
test("Using a post date for the reminder date", async function (assert) { test("Using a post date for the reminder date", async function (assert) {
@ -370,6 +356,78 @@ acceptance("Bookmarking", function (needs) {
); );
}); });
test("Creating and editing a topic level bookmark", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#topic-footer-button-bookmark");
assert.equal(
query("#discourse-modal-title").innerText,
I18n.t("post.bookmarks.create_for_topic"),
"The create modal says creating a topic bookmark"
);
await click("#save-bookmark");
assert.notOk(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"the first post is not marked as being bookmarked"
);
assert.equal(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.edit_bookmark"),
"A topic level bookmark button has a label 'Edit Bookmark'"
);
await click("#topic-footer-button-bookmark");
assert.equal(
query("#discourse-modal-title").innerText,
I18n.t("post.bookmarks.edit_for_topic"),
"The edit modal says editing a topic bookmark"
);
await fillIn("input#bookmark-name", "Test name");
await click("#tap_tile_tomorrow");
await click("#topic-footer-button-bookmark");
assert.equal(
query("input#bookmark-name").value,
"Test name",
"The topic level bookmark editing preserves the values entered"
);
await click(".d-modal-cancel");
await openBookmarkModal(1);
await click("#save-bookmark");
assert.ok(
exists(".topic-post:first-child button.bookmark.bookmarked"),
"the first post is bookmarked independently of the topic level bookmark"
);
// deleting all bookmarks in the topic
assert.equal(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.clear_bookmarks"),
"the footer button says Clear Bookmarks because there is more than one"
);
await click("#topic-footer-button-bookmark");
await click("a.btn-primary");
assert.ok(
!exists(".topic-post:first-child button.bookmark.bookmarked"),
"the first post bookmark is deleted"
);
assert.equal(
query("#topic-footer-button-bookmark").innerText,
I18n.t("bookmarked.title"),
"the topic level bookmark is deleted"
);
});
test("The topic level bookmark button opens the edit modal if only one post in the post stream is bookmarked", async function (assert) { test("The topic level bookmark button opens the edit modal if only one post in the post stream is bookmarked", async function (assert) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await openBookmarkModal(2); await openBookmarkModal(2);

View File

@ -45,6 +45,7 @@ acceptance("Topic Timeline", function (needs) {
read: true, read: true,
user_title: null, user_title: null,
bookmarked: false, bookmarked: false,
bookmarks: [],
actions_summary: [ actions_summary: [
{ {
id: 3, id: 3,
@ -120,6 +121,7 @@ acceptance("Topic Timeline", function (needs) {
read: true, read: true,
user_title: null, user_title: null,
bookmarked: false, bookmarked: false,
bookmarks: [],
actions_summary: [ actions_summary: [
{ {
id: 3, id: 3,
@ -236,6 +238,7 @@ acceptance("Topic Timeline", function (needs) {
current_post_number: 1, current_post_number: 1,
highest_post_number: 2, highest_post_number: 2,
last_read_post_number: 0, last_read_post_number: 0,
bookmarks: [],
last_read_post_id: null, last_read_post_id: null,
deleted_by: { deleted_by: {
id: 7, id: 7,
@ -266,7 +269,7 @@ acceptance("Topic Timeline", function (needs) {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarked_posts: null, bookmarks: [],
topic_timer: null, topic_timer: null,
message_bus_last_id: 5, message_bus_last_id: 5,
participant_count: 1, participant_count: 1,

View File

@ -234,7 +234,8 @@ export default {
{ id: 8, count: 0, hidden: false, can_act: true } { id: 8, count: 0, hidden: false, can_act: true }
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false bookmarked: false,
bookmarks: [],
}, },
"/t/14.json": { "/t/14.json": {
post_stream: { post_stream: {
@ -390,7 +391,8 @@ export default {
{ id: 8, count: 0, hidden: false, can_act: true } { id: 8, count: 0, hidden: false, can_act: true }
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false bookmarked: false,
bookmarks: [],
}, },
"/t/15.json": { "/t/15.json": {
post_stream: { post_stream: {
@ -543,7 +545,8 @@ export default {
{ id: 8, count: 0, hidden: false, can_act: true } { id: 8, count: 0, hidden: false, can_act: true }
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false bookmarked: false,
bookmarks: [],
}, },
"/t/topic_with_pie_chart_poll.json": { "/t/topic_with_pie_chart_poll.json": {
post_stream: { post_stream: {
@ -691,6 +694,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
topic_timer: null, topic_timer: null,
message_bus_last_id: 1, message_bus_last_id: 1,
participant_count: 1, participant_count: 1,

View File

@ -46,6 +46,7 @@ export default {
archived: false, archived: false,
notification_level: 3, notification_level: 3,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
views: 5, views: 5,
like_count: 0, like_count: 0,

View File

@ -136,6 +136,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
message_archived: false, message_archived: false,
topic_timer: null, topic_timer: null,
message_bus_last_id: 5, message_bus_last_id: 5,
@ -3005,6 +3006,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
suggested_topics: [ suggested_topics: [
{ {
id: 27331, id: 27331,
@ -3029,6 +3031,7 @@ export default {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 11, like_count: 11,
@ -3072,6 +3075,7 @@ export default {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 3, like_count: 3,
@ -3152,6 +3156,7 @@ export default {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 4, like_count: 4,
@ -3195,6 +3200,8 @@ export default {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 62, like_count: 62,
@ -3273,6 +3280,7 @@ export default {
archived: false, archived: false,
notification_level: 1, notification_level: 1,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 9, like_count: 9,
@ -3862,6 +3870,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: null, bookmarked: null,
bookmarks: [],
tags: null, tags: null,
}, },
"/t/9/1.json": { "/t/9/1.json": {
@ -4093,6 +4102,7 @@ export default {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 0, like_count: 0,
@ -4122,6 +4132,7 @@ export default {
archived: false, archived: false,
notification_level: 3, notification_level: 3,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 0, like_count: 0,
@ -4152,6 +4163,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
destination_category_id: 3, destination_category_id: 3,
}, },
"/t/12/1.json": { "/t/12/1.json": {
@ -4179,6 +4191,7 @@ export default {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 0, like_count: 0,
@ -4420,6 +4433,7 @@ export default {
archived: false, archived: false,
notification_level: 3, notification_level: 3,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "private_message", archetype: "private_message",
like_count: 0, like_count: 0,
@ -4463,6 +4477,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
message_archived: false, message_archived: false,
featured_link: null, featured_link: null,
}, },
@ -4696,6 +4711,7 @@ export default {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 0, like_count: 0,
@ -4725,6 +4741,7 @@ export default {
archived: false, archived: false,
notification_level: 3, notification_level: 3,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 0, like_count: 0,
@ -4755,6 +4772,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
}, },
"/t/301/1.json": { "/t/301/1.json": {
post_stream: { post_stream: {
@ -4986,6 +5004,7 @@ export default {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 0, like_count: 0,
@ -5015,6 +5034,7 @@ export default {
archived: false, archived: false,
notification_level: 3, notification_level: 3,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
archetype: "regular", archetype: "regular",
like_count: 0, like_count: 0,
@ -5045,6 +5065,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
}, },
"/t/34/1.json": { "/t/34/1.json": {
post_stream: { post_stream: {
@ -5296,6 +5317,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
message_archived: false, message_archived: false,
topic_timer: null, topic_timer: null,
message_bus_last_id: 7, message_bus_last_id: 7,
@ -5341,6 +5363,7 @@ export default {
user_title: "a title", user_title: "a title",
title_is_group: false, title_is_group: false,
bookmarked: false, bookmarked: false,
bookmarks: [],
actions_summary: [ actions_summary: [
{ {
id: 3, id: 3,
@ -5409,6 +5432,7 @@ export default {
read: true, read: true,
user_title: null, user_title: null,
bookmarked: false, bookmarked: false,
bookmarks: [],
actions_summary: [ actions_summary: [
{ {
id: 2, id: 2,
@ -5484,6 +5508,7 @@ export default {
user_title: "a title", user_title: "a title",
title_is_group: false, title_is_group: false,
bookmarked: false, bookmarked: false,
bookmarks: [],
actions_summary: [ actions_summary: [
{ {
id: 3, id: 3,
@ -5579,6 +5604,7 @@ export default {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
topic_timer: null, topic_timer: null,
message_bus_last_id: 4, message_bus_last_id: 4,
participant_count: 2, participant_count: 2,

View File

@ -15,6 +15,7 @@ class BookmarksController < ApplicationController
post_id: params[:post_id], post_id: params[:post_id],
name: params[:name], name: params[:name],
reminder_at: params[:reminder_at], reminder_at: params[:reminder_at],
for_topic: params[:for_topic] == "true",
options: { options: {
auto_delete_preference: params[:auto_delete_preference] || 0 auto_delete_preference: params[:auto_delete_preference] || 0
} }

View File

@ -38,13 +38,16 @@ class Bookmark < ActiveRecord::Base
on: [:create, :update], on: [:create, :update],
if: Proc.new { |b| b.will_save_change_to_post_id? || b.will_save_change_to_user_id? } if: Proc.new { |b| b.will_save_change_to_post_id? || b.will_save_change_to_user_id? }
validate :for_topic_must_use_first_post,
on: [:create, :update],
if: Proc.new { |b| b.will_save_change_to_post_id? || b.will_save_change_to_for_topic? }
validate :ensure_sane_reminder_at_time validate :ensure_sane_reminder_at_time
validate :bookmark_limit_not_reached validate :bookmark_limit_not_reached
validates :name, length: { maximum: 100 } validates :name, length: { maximum: 100 }
def unique_per_post_for_user def unique_per_post_for_user
is_first_post = Post.where(id: post_id).pluck_first(:post_number) == 1 exists = if is_for_first_post?
exists = if is_first_post
Bookmark.exists?(user_id: user_id, post_id: post_id, for_topic: for_topic) Bookmark.exists?(user_id: user_id, post_id: post_id, for_topic: for_topic)
else else
Bookmark.exists?(user_id: user_id, post_id: post_id) Bookmark.exists?(user_id: user_id, post_id: post_id)
@ -55,6 +58,12 @@ class Bookmark < ActiveRecord::Base
end end
end end
def for_topic_must_use_first_post
if !is_for_first_post? && self.for_topic
self.errors.add(:base, I18n.t("bookmarks.errors.for_topic_must_use_first_post"))
end
end
def ensure_sane_reminder_at_time def ensure_sane_reminder_at_time
return if reminder_at.blank? return if reminder_at.blank?
if reminder_at < Time.zone.now if reminder_at < Time.zone.now
@ -79,6 +88,10 @@ class Bookmark < ActiveRecord::Base
) )
end end
def is_for_first_post?
@is_for_first_post ||= new_record? ? Post.exists?(id: post_id, post_number: 1) : post.post_number == 1
end
def no_reminder? def no_reminder?
self.reminder_at.blank? self.reminder_at.blank?
end end

View File

@ -359,9 +359,9 @@ class PostSerializer < BasicPostSerializer
def post_bookmark def post_bookmark
if @topic_view.present? if @topic_view.present?
@post_bookmark ||= @topic_view.user_post_bookmarks.find { |bookmark| bookmark.post_id == object.id } @post_bookmark ||= @topic_view.user_post_bookmarks.find { |bookmark| bookmark.post_id == object.id && !bookmark.for_topic }
else else
@post_bookmark ||= object.bookmarks.find_by(user: scope.user) @post_bookmark ||= object.bookmarks.find_by(user: scope.user, for_topic: false)
end end
end end

View File

@ -62,7 +62,7 @@ class TopicViewSerializer < ApplicationSerializer
:is_warning, :is_warning,
:chunk_size, :chunk_size,
:bookmarked, :bookmarked,
:bookmarked_posts, :bookmarks,
:message_archived, :message_archived,
:topic_timer, :topic_timer,
:unicode_title, :unicode_title,
@ -193,8 +193,8 @@ class TopicViewSerializer < ApplicationSerializer
object.has_bookmarks? object.has_bookmarks?
end end
def bookmarked_posts def bookmarks
object.bookmarked_posts object.bookmarks
end end
def topic_timer def topic_timer

View File

@ -28,7 +28,8 @@ class UserBookmarkSerializer < ApplicationSerializer
:slug, :slug,
:post_user_username, :post_user_username,
:post_user_avatar_template, :post_user_avatar_template,
:post_user_name :post_user_name,
:for_topic
def topic_id def topic_id
post.topic_id post.topic_id

View File

@ -22,7 +22,7 @@ class WebHookTopicViewSerializer < TopicViewSerializer
image_url image_url
slow_mode_seconds slow_mode_seconds
slow_mode_enabled_until slow_mode_enabled_until
bookmarked_posts bookmarks
}.each do |attr| }.each do |attr|
define_method("include_#{attr}?") do define_method("include_#{attr}?") do
false false

View File

@ -303,6 +303,7 @@ en:
help: help:
bookmark: "Click to bookmark the first post on this topic" bookmark: "Click to bookmark the first post on this topic"
edit_bookmark: "Click to edit the bookmark on this topic" edit_bookmark: "Click to edit the bookmark on this topic"
edit_bookmark_for_topic: "Click to edit the bookmark for this topic"
unbookmark: "Click to remove all bookmarks in this topic" unbookmark: "Click to remove all bookmarks in this topic"
unbookmark_with_reminder: "Click to remove all bookmarks and reminders in this topic." unbookmark_with_reminder: "Click to remove all bookmarks and reminders in this topic."
@ -3187,7 +3188,9 @@ en:
bookmarks: bookmarks:
create: "Create bookmark" create: "Create bookmark"
create_for_topic: "Create bookmark for topic"
edit: "Edit bookmark" edit: "Edit bookmark"
edit_for_topic: "Edit bookmark for topic"
created: "Created" created: "Created"
updated: "Updated" updated: "Updated"
name: "Name" name: "Name"

View File

@ -431,6 +431,7 @@ en:
cannot_set_past_reminder: "You cannot set a bookmark reminder in the past." cannot_set_past_reminder: "You cannot set a bookmark reminder in the past."
cannot_set_reminder_in_distant_future: "You cannot set a bookmark reminder more than 10 years in the future." cannot_set_reminder_in_distant_future: "You cannot set a bookmark reminder more than 10 years in the future."
time_must_be_provided: "time must be provided for all reminders" time_must_be_provided: "time must be provided for all reminders"
for_topic_must_use_first_post: "You can only use the first post to bookmark the topic."
reminders: reminders:
at_desktop: "Next time I'm at my desktop" at_desktop: "Next time I'm at my desktop"

View File

@ -7,8 +7,41 @@ class BookmarkManager
@user = user @user = user
end end
##
# Creates a bookmark for a post where both the post and the topic are
# not deleted. Only allows creation of bookmarks for posts the user
# can access via Guardian.
#
# Any ActiveModel validation errors raised by the Bookmark model are
# hoisted to the instance of this class for further reporting.
#
# Also handles setting the associated TopicUser.bookmarked value for
# the post's topic for the user that is creating the bookmark.
#
# @param post_id A post ID for a post that is not deleted.
# @param name A short note for the bookmark, shown on the user bookmark list
# and on hover of reminder notifications.
# @param reminder_at The datetime when a bookmark reminder should be sent after.
# Note this is not the exact time a reminder will be sent, as
# we send reminders on a rolling schedule.
# See Jobs::BookmarkReminderNotifications
# @param for_topic Whether we are creating a topic-level bookmark which
# has different behaviour in the UI. Only bookmarks for
# posts with post_number 1 can be marked as for_topic.
# @params options Additional options when creating a bookmark
# - auto_delete_preference:
# See Bookmark.auto_delete_preferences,
# this is used to determine when to delete a bookmark
# automatically.
# TODO (martin) (2021-12-01) Remove reminder_type keyword argument once plugins are not using it. # TODO (martin) (2021-12-01) Remove reminder_type keyword argument once plugins are not using it.
def create(post_id:, name: nil, reminder_at: nil, reminder_type: nil, options: {}) def create(
post_id:,
name: nil,
reminder_type: nil,
reminder_at: nil,
for_topic: false,
options: {}
)
post = Post.find_by(id: post_id) post = Post.find_by(id: post_id)
# no bookmarking deleted posts or topics # no bookmarking deleted posts or topics
@ -24,7 +57,8 @@ class BookmarkManager
post: post, post: post,
name: name, name: name,
reminder_at: reminder_at, reminder_at: reminder_at,
reminder_set_at: Time.zone.now reminder_set_at: Time.zone.now,
for_topic: for_topic
}.merge(options) }.merge(options)
) )

View File

@ -393,17 +393,13 @@ class TopicView
def has_bookmarks? def has_bookmarks?
return false if @user.blank? return false if @user.blank?
return false if @topic.trashed? return false if @topic.trashed?
@topic.bookmarks.exists?(user_id: @user.id) bookmarks.any?
end end
def bookmarked_posts def bookmarks
return nil unless has_bookmarks? @bookmarks ||= @topic.bookmarks.where(user: @user).joins(:topic).select(
@topic.bookmarks.where(user: @user).pluck(:post_id, :reminder_at).map do |post_id, reminder_at| :id, :post_id, :for_topic, :reminder_at, :name, :auto_delete_preference
{ )
post_id: post_id,
reminder_at: reminder_at
}
end
end end
MAX_PARTICIPANTS = 24 MAX_PARTICIPANTS = 24

View File

@ -19,12 +19,12 @@ function initialize(api) {
api.modifyClass("controller:topic", { api.modifyClass("controller:topic", {
pluginId: PLUGIN_ID, pluginId: PLUGIN_ID,
_togglePostBookmark(post) { _modifyBookmark(bookmark, post) {
// if we are talking to discobot then any bookmarks should just // if we are talking to discobot then any bookmarks should just
// be created without reminder options, to streamline the new user // be created without reminder options, to streamline the new user
// narrative. // narrative.
const discobotUserId = -2; const discobotUserId = -2;
if (post.user_id === discobotUserId && !post.bookmarked) { if (post && post.user_id === discobotUserId && !post.bookmarked) {
return ajax("/bookmarks", { return ajax("/bookmarks", {
type: "POST", type: "POST",
data: { post_id: post.id }, data: { post_id: post.id },
@ -37,7 +37,7 @@ function initialize(api) {
post.appEvents.trigger("post-stream:refresh", { id: this.id }); post.appEvents.trigger("post-stream:refresh", { id: this.id });
}); });
} }
return this._super(post); return this._super(bookmark, post);
}, },
}); });

View File

@ -54,6 +54,7 @@ acceptance("Poll in a post reply history", function (needs) {
"/letter_avatar_proxy/v4/letter/a/bbce88/{size}.png", "/letter_avatar_proxy/v4/letter/a/bbce88/{size}.png",
}, },
bookmarked: false, bookmarked: false,
bookmarks: [],
actions_summary: [ actions_summary: [
{ {
id: 3, id: 3,
@ -158,6 +159,7 @@ acceptance("Poll in a post reply history", function (needs) {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
like_count: 0, like_count: 0,
views: 3, views: 3,
@ -212,6 +214,7 @@ acceptance("Poll in a post reply history", function (needs) {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
like_count: 0, like_count: 0,
views: 4, views: 4,
@ -302,6 +305,7 @@ acceptance("Poll in a post reply history", function (needs) {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
topic_timer: null, topic_timer: null,
message_bus_last_id: 4, message_bus_last_id: 4,
participant_count: 1, participant_count: 1,
@ -395,6 +399,7 @@ acceptance("Poll in a post reply history", function (needs) {
can_wiki: false, can_wiki: false,
user_title: null, user_title: null,
bookmarked: false, bookmarked: false,
bookmarks: [],
actions_summary: [], actions_summary: [],
moderator: false, moderator: false,
admin: true, admin: true,

View File

@ -46,6 +46,7 @@ acceptance("Poll quote", function (needs) {
user_title: "Tester", user_title: "Tester",
title_is_group: false, title_is_group: false,
bookmarked: false, bookmarked: false,
bookmarks: [],
raw: raw:
"[poll name=poll1 type=regular results=always chartType=bar]\n* Alpha\n* Beta\n[/poll]\n\n[poll name=poll2 type=regular results=always chartType=bar]\n* First\n* Second\n[/poll]", "[poll name=poll1 type=regular results=always chartType=bar]\n* Alpha\n* Beta\n[/poll]\n\n[poll name=poll2 type=regular results=always chartType=bar]\n* First\n* Second\n[/poll]",
actions_summary: [ actions_summary: [
@ -175,6 +176,7 @@ acceptance("Poll quote", function (needs) {
user_title: "Tester", user_title: "Tester",
title_is_group: false, title_is_group: false,
bookmarked: false, bookmarked: false,
bookmarks: [],
actions_summary: [ actions_summary: [
{ {
id: 3, id: 3,
@ -238,6 +240,7 @@ acceptance("Poll quote", function (needs) {
archived: false, archived: false,
notification_level: 1, notification_level: 1,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
tags: [], tags: [],
like_count: 0, like_count: 0,
@ -282,6 +285,7 @@ acceptance("Poll quote", function (needs) {
archived: false, archived: false,
notification_level: 3, notification_level: 3,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
tags: [], tags: [],
like_count: 0, like_count: 0,
@ -362,6 +366,7 @@ acceptance("Poll quote", function (needs) {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
topic_timer: null, topic_timer: null,
message_bus_last_id: 2, message_bus_last_id: 2,
participant_count: 1, participant_count: 1,

View File

@ -48,6 +48,7 @@ acceptance("Poll results", function (needs) {
can_wiki: true, can_wiki: true,
title_is_group: false, title_is_group: false,
bookmarked: false, bookmarked: false,
bookmarks: [],
raw: raw:
"[poll type=regular results=always public=true chartType=bar]\n* Option #1\n* Option #2\n[/poll]", "[poll type=regular results=always public=true chartType=bar]\n* Option #1\n* Option #2\n[/poll]",
actions_summary: [ actions_summary: [
@ -154,6 +155,7 @@ acceptance("Poll results", function (needs) {
read: true, read: true,
title_is_group: false, title_is_group: false,
bookmarked: false, bookmarked: false,
bookmarks: [],
actions_summary: [ actions_summary: [
{ id: 3, can_act: true }, { id: 3, can_act: true },
{ id: 4, can_act: true }, { id: 4, can_act: true },
@ -247,6 +249,7 @@ acceptance("Poll results", function (needs) {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
tags: [], tags: [],
like_count: 0, like_count: 0,
@ -302,6 +305,7 @@ acceptance("Poll results", function (needs) {
archived: false, archived: false,
notification_level: 2, notification_level: 2,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
tags: [], tags: [],
like_count: 0, like_count: 0,
@ -349,6 +353,7 @@ acceptance("Poll results", function (needs) {
archived: false, archived: false,
notification_level: 3, notification_level: 3,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
tags: ["abc", "e", "b"], tags: ["abc", "e", "b"],
like_count: 0, like_count: 0,
@ -394,6 +399,7 @@ acceptance("Poll results", function (needs) {
archived: false, archived: false,
notification_level: 3, notification_level: 3,
bookmarked: false, bookmarked: false,
bookmarks: [],
liked: false, liked: false,
tags: [], tags: [],
like_count: 0, like_count: 0,
@ -461,6 +467,7 @@ acceptance("Poll results", function (needs) {
], ],
chunk_size: 20, chunk_size: 20,
bookmarked: false, bookmarked: false,
bookmarks: [],
topic_timer: null, topic_timer: null,
message_bus_last_id: 5, message_bus_last_id: 5,
participant_count: 1, participant_count: 1,

View File

@ -408,7 +408,7 @@ describe TopicView do
end end
end end
context "#bookmarked_posts" do context "#bookmarks" do
let!(:user) { Fabricate(:user) } let!(:user) { Fabricate(:user) }
let!(:bookmark1) { Fabricate(:bookmark_next_business_day_reminder, post: topic.first_post, user: user) } let!(:bookmark1) { Fabricate(:bookmark_next_business_day_reminder, post: topic.first_post, user: user) }
let!(:bookmark2) { Fabricate(:bookmark_next_business_day_reminder, post: topic.posts[1], user: user) } let!(:bookmark2) { Fabricate(:bookmark_next_business_day_reminder, post: topic.posts[1], user: user) }
@ -416,7 +416,7 @@ describe TopicView do
it "gets the first post bookmark reminder at for the user" do it "gets the first post bookmark reminder at for the user" do
topic_view = TopicView.new(topic.id, user) topic_view = TopicView.new(topic.id, user)
first, second = topic_view.bookmarked_posts first, second = topic_view.bookmarks
expect(first[:post_id]).to eq(bookmark1.post_id) expect(first[:post_id]).to eq(bookmark1.post_id)
expect(first[:reminder_at]).to eq_time(bookmark1.reminder_at) expect(first[:reminder_at]).to eq_time(bookmark1.reminder_at)
expect(second[:post_id]).to eq(bookmark2.post_id) expect(second[:post_id]).to eq(bookmark2.post_id)
@ -424,12 +424,12 @@ describe TopicView do
end end
context "when the topic is deleted" do context "when the topic is deleted" do
it "returns nil" do it "returns []" do
topic_view = TopicView.new(topic, user) topic_view = TopicView.new(topic, user)
PostDestroyer.new(Fabricate(:admin), topic.first_post).destroy PostDestroyer.new(Fabricate(:admin), topic.first_post).destroy
topic.reload topic.reload
expect(topic_view.bookmarked_posts).to eq(nil) expect(topic_view.bookmarks).to eq([])
end end
end end
@ -439,8 +439,8 @@ describe TopicView do
PostDestroyer.new(Fabricate(:admin), topic.posts.second).destroy PostDestroyer.new(Fabricate(:admin), topic.posts.second).destroy
topic.reload topic.reload
expect(topic_view.bookmarked_posts.length).to eq(1) expect(topic_view.bookmarks.length).to eq(1)
first = topic_view.bookmarked_posts.first first = topic_view.bookmarks.first
expect(first[:post_id]).to eq(bookmark1.post_id) expect(first[:post_id]).to eq(bookmark1.post_id)
expect(first[:reminder_at]).to eq_time(bookmark1.reminder_at) expect(first[:reminder_at]).to eq_time(bookmark1.reminder_at)
end end

View File

@ -17,7 +17,28 @@ RSpec.describe BookmarkManager do
bookmark = Bookmark.find_by(user: user) bookmark = Bookmark.find_by(user: user)
expect(bookmark.post_id).to eq(post.id) expect(bookmark.post_id).to eq(post.id)
expect(bookmark.topic.id).to eq(post.topic_id) expect(bookmark.topic_id).to eq(post.topic_id)
end
it "allows creating a bookmark for the topic and for the first post" do
subject.create(post_id: post.id, name: name, for_topic: true)
bookmark = Bookmark.find_by(user: user, post_id: post.id, for_topic: true)
expect(bookmark.post_id).to eq(post.id)
expect(bookmark.topic_id).to eq(post.topic_id)
expect(bookmark.for_topic).to eq(true)
subject.create(post_id: post.id, name: name)
bookmark = Bookmark.find_by(user: user, post_id: post.id, for_topic: false)
expect(bookmark.post_id).to eq(post.id)
expect(bookmark.topic_id).to eq(post.topic_id)
expect(bookmark.for_topic).to eq(false)
end
it "errors when creating a for_topic bookmark for a post that is not the first one" do
subject.create(post_id: Fabricate(:post, topic: post.topic).id, name: name, for_topic: true)
expect(subject.errors.full_messages).to include(I18n.t("bookmarks.errors.for_topic_must_use_first_post"))
end end
it "when topic is deleted it raises invalid access from guardian check" do it "when topic is deleted it raises invalid access from guardian check" do

View File

@ -30,6 +30,31 @@ describe BookmarksController do
expect(response.status).to eq(429) expect(response.status).to eq(429)
end end
it "creates a for_topic bookmark" do
post "/bookmarks.json", params: {
post_id: bookmark_post.id,
reminder_type: "tomorrow",
reminder_at: (Time.zone.now + 1.day).iso8601,
for_topic: true
}
expect(response.status).to eq(200)
bookmark = Bookmark.find(response.parsed_body["id"])
expect(bookmark.for_topic).to eq(true)
end
it "errors when trying to create a for_topic bookmark for post_number > 1" do
post "/bookmarks.json", params: {
post_id: Fabricate(:post, topic: bookmark_post.topic).id,
reminder_type: "tomorrow",
reminder_at: (Time.zone.now + 1.day).iso8601,
for_topic: true
}
expect(response.status).to eq(400)
expect(response.parsed_body['errors']).to include(
I18n.t("bookmarks.errors.for_topic_must_use_first_post")
)
end
context "if the user reached the max bookmark limit" do context "if the user reached the max bookmark limit" do
before do before do
SiteSetting.max_bookmarks_per_user = 1 SiteSetting.max_bookmarks_per_user = 1

View File

@ -45,7 +45,8 @@ describe PostSerializer do
it "should not allow user to flag post and notify non human user" do it "should not allow user to flag post and notify non human user" do
post.update!(user: Discourse.system_user) post.update!(user: Discourse.system_user)
serializer = PostSerializer.new(post, serializer = PostSerializer.new(
post,
scope: Guardian.new(actor), scope: Guardian.new(actor),
root: false root: false
) )
@ -247,6 +248,17 @@ describe PostSerializer do
end end
end end
end end
context "when the post bookmark is for_topic" do
let!(:bookmark) { Fabricate(:bookmark_next_business_day_reminder, user: current_user, post: post, for_topic: true) }
context "bookmarks with reminders" do
it "returns false, because we do not want to mark the post as bookmarked, because the bookmark is for the topic" do
expect(serialized.as_json[:bookmarked]).to eq(false)
expect(serialized.as_json[:bookmark_reminder_at]).to eq(nil)
end
end
end
end end
context "posts when group moderation is enabled" do context "posts when group moderation is enabled" do