Note: All of this functionality is hidden behind a hidden, default false, site setting called `enable_bookmarks_with_reminders`. Also, any feedback on Ember code would be greatly appreciated! This is part 1 of the bookmark improvements. The next PR will address the backend logic to send reminder notifications for bookmarked posts to users. This PR adds the following functionality: * We are adding a new `bookmarks` table and `Bookmark` model to make the bookmarks a first-class citizen and to allow attaching reminders to them. * Posts now have a new button in their actions menu that has the icon of an actual book * Clicking the button opens the new bookmark modal. * Both name and the reminder type are optional. * If you close the modal without doing anything, the bookmark is saved with no reminder. * If you click the Cancel button, no bookmark is saved at all. * All of the reminder type tiles are dynamic and the times they show will be based on your user timezone set in your profile (this should already be set for you). * If for some reason a user does not have their timezone set they will not be able to set a reminder, but they will still be able to create a bookmark. * A bookmark can be deleted by clicking on the book icon again which will be red if the post is bookmarked. This PR does NOT do anything to migrate or change existing bookmarks in the form of `PostActions`, the two features live side-by-side here. Also this does nothing to the topic bookmarking.
706 lines
17 KiB
JavaScript
706 lines
17 KiB
JavaScript
import { run } from "@ember/runloop";
|
|
import { next } from "@ember/runloop";
|
|
import { applyDecorators, createWidget } from "discourse/widgets/widget";
|
|
import { avatarAtts } from "discourse/widgets/actions-summary";
|
|
import { h } from "virtual-dom";
|
|
import showModal from "discourse/lib/show-modal";
|
|
import { Promise } from "rsvp";
|
|
import ENV from "discourse-common/config/environment";
|
|
|
|
const LIKE_ACTION = 2;
|
|
|
|
function animateHeart($elem, start, end, complete) {
|
|
if (ENV.environment === "test") {
|
|
return run(this, complete);
|
|
}
|
|
|
|
$elem
|
|
.stop()
|
|
.css("textIndent", start)
|
|
.animate(
|
|
{ textIndent: end },
|
|
{
|
|
complete,
|
|
step(now) {
|
|
$(this)
|
|
.css("transform", "scale(" + now + ")")
|
|
.addClass("d-liked")
|
|
.removeClass("d-unliked");
|
|
},
|
|
duration: 150
|
|
},
|
|
"linear"
|
|
);
|
|
}
|
|
|
|
const _builders = {};
|
|
const _extraButtons = {};
|
|
|
|
export function addButton(name, builder) {
|
|
_extraButtons[name] = builder;
|
|
}
|
|
|
|
function registerButton(name, builder) {
|
|
_builders[name] = builder;
|
|
}
|
|
|
|
export function buildButton(name, widget) {
|
|
let { attrs, state, siteSettings, settings } = widget;
|
|
let builder = _builders[name];
|
|
if (builder) {
|
|
let button = builder(attrs, state, siteSettings, settings);
|
|
if (button && !button.id) {
|
|
button.id = name;
|
|
}
|
|
return button;
|
|
}
|
|
}
|
|
|
|
registerButton("read-count", attrs => {
|
|
if (attrs.showReadIndicator) {
|
|
const count = attrs.readCount;
|
|
if (count > 0) {
|
|
return {
|
|
action: "toggleWhoRead",
|
|
title: "post.controls.read_indicator",
|
|
className: "button-count read-indicator",
|
|
contents: count,
|
|
iconRight: true,
|
|
addContainer: false
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
registerButton("read", attrs => {
|
|
const readBySomeone = attrs.readCount > 0;
|
|
if (attrs.showReadIndicator && readBySomeone) {
|
|
return {
|
|
action: "toggleWhoRead",
|
|
title: "post.controls.read_indicator",
|
|
icon: "book-reader",
|
|
before: "read-count",
|
|
addContainer: false
|
|
};
|
|
}
|
|
});
|
|
|
|
function likeCount(attrs) {
|
|
const count = attrs.likeCount;
|
|
|
|
if (count > 0) {
|
|
const title = attrs.liked
|
|
? count === 1
|
|
? "post.has_likes_title_only_you"
|
|
: "post.has_likes_title_you"
|
|
: "post.has_likes_title";
|
|
let icon = attrs.yours ? "d-liked" : "";
|
|
let addContainer = attrs.yours;
|
|
const additionalClass = attrs.yours ? "my-likes" : "regular-likes";
|
|
|
|
if (!attrs.showLike) {
|
|
icon = attrs.yours ? "d-liked" : "d-unliked";
|
|
addContainer = true;
|
|
}
|
|
|
|
return {
|
|
action: "toggleWhoLiked",
|
|
title,
|
|
className: `button-count like-count highlight-action ${additionalClass}`,
|
|
contents: count,
|
|
icon,
|
|
iconRight: true,
|
|
addContainer,
|
|
titleOptions: { count: attrs.liked ? count - 1 : count }
|
|
};
|
|
}
|
|
}
|
|
|
|
registerButton("like-count", likeCount);
|
|
|
|
registerButton("like", attrs => {
|
|
if (!attrs.showLike) {
|
|
return likeCount(attrs);
|
|
}
|
|
|
|
const className = attrs.liked
|
|
? "toggle-like has-like fade-out"
|
|
: "toggle-like like";
|
|
|
|
const button = {
|
|
action: "like",
|
|
icon: attrs.liked ? "d-liked" : "d-unliked",
|
|
className,
|
|
before: "like-count"
|
|
};
|
|
|
|
// If the user has already liked the post and doesn't have permission
|
|
// to undo that operation, then indicate via the title that they've liked it
|
|
// and disable the button. Otherwise, set the title even if the user
|
|
// is anonymous (meaning they don't currently have permission to like);
|
|
// this is important for accessibility.
|
|
if (attrs.liked && !attrs.canToggleLike) {
|
|
button.title = "post.controls.has_liked";
|
|
button.disabled = true;
|
|
} else {
|
|
button.title = attrs.liked
|
|
? "post.controls.undo_like"
|
|
: "post.controls.like";
|
|
}
|
|
|
|
return button;
|
|
});
|
|
|
|
registerButton("flag-count", attrs => {
|
|
let className = "button-count";
|
|
if (attrs.reviewableScorePendingCount > 0) {
|
|
className += " has-pending";
|
|
}
|
|
return {
|
|
className,
|
|
contents: h("span", attrs.reviewableScoreCount.toString()),
|
|
url: `/review/${attrs.reviewableId}`
|
|
};
|
|
});
|
|
|
|
registerButton("flag", attrs => {
|
|
if (attrs.reviewableId || (attrs.canFlag && !attrs.hidden)) {
|
|
let button = {
|
|
action: "showFlags",
|
|
title: "post.controls.flag",
|
|
icon: "flag",
|
|
className: "create-flag"
|
|
};
|
|
if (attrs.reviewableId) {
|
|
button.before = "flag-count";
|
|
}
|
|
return button;
|
|
}
|
|
});
|
|
|
|
registerButton("edit", attrs => {
|
|
if (attrs.canEdit) {
|
|
return {
|
|
action: "editPost",
|
|
className: "edit",
|
|
title: "post.controls.edit",
|
|
icon: "pencil-alt",
|
|
alwaysShowYours: true
|
|
};
|
|
}
|
|
});
|
|
|
|
registerButton("reply-small", attrs => {
|
|
if (!attrs.canCreatePost) {
|
|
return;
|
|
}
|
|
|
|
const args = {
|
|
action: "replyToPost",
|
|
title: "post.controls.reply",
|
|
icon: "reply",
|
|
className: "reply"
|
|
};
|
|
|
|
return args;
|
|
});
|
|
|
|
registerButton("wiki-edit", attrs => {
|
|
if (attrs.canEdit) {
|
|
const args = {
|
|
action: "editPost",
|
|
className: "edit create",
|
|
title: "post.controls.edit",
|
|
icon: "far-edit",
|
|
alwaysShowYours: true
|
|
};
|
|
if (!attrs.mobileView) {
|
|
args.label = "post.controls.edit_action";
|
|
}
|
|
return args;
|
|
}
|
|
});
|
|
|
|
registerButton("replies", (attrs, state, siteSettings) => {
|
|
const replyCount = attrs.replyCount;
|
|
|
|
if (!replyCount) {
|
|
return;
|
|
}
|
|
|
|
// Omit replies if the setting `suppress_reply_directly_below` is enabled
|
|
if (
|
|
replyCount === 1 &&
|
|
attrs.replyDirectlyBelow &&
|
|
siteSettings.suppress_reply_directly_below
|
|
) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
action: "toggleRepliesBelow",
|
|
className: "show-replies",
|
|
icon: state.repliesShown ? "chevron-up" : "chevron-down",
|
|
titleOptions: { count: replyCount },
|
|
title: "post.has_replies",
|
|
labelOptions: { count: replyCount },
|
|
label: "post.has_replies",
|
|
iconRight: true
|
|
};
|
|
});
|
|
|
|
registerButton("share", attrs => {
|
|
return {
|
|
action: "share",
|
|
className: "share",
|
|
title: "post.controls.share",
|
|
icon: "link",
|
|
data: {
|
|
"share-url": attrs.shareUrl,
|
|
"post-number": attrs.post_number
|
|
}
|
|
};
|
|
});
|
|
|
|
registerButton("reply", (attrs, state, siteSettings, postMenuSettings) => {
|
|
const args = {
|
|
action: "replyToPost",
|
|
title: "post.controls.reply",
|
|
icon: "reply",
|
|
className: "reply create fade-out"
|
|
};
|
|
|
|
if (!attrs.canCreatePost) {
|
|
return;
|
|
}
|
|
|
|
if (postMenuSettings.showReplyTitleOnMobile || !attrs.mobileView) {
|
|
args.label = "topic.reply.title";
|
|
}
|
|
|
|
return args;
|
|
});
|
|
|
|
registerButton("bookmark", attrs => {
|
|
if (!attrs.canBookmark) {
|
|
return;
|
|
}
|
|
|
|
let className = "bookmark";
|
|
|
|
if (attrs.bookmarked) {
|
|
className += " bookmarked";
|
|
}
|
|
|
|
return {
|
|
id: attrs.bookmarked ? "unbookmark" : "bookmark",
|
|
action: "toggleBookmark",
|
|
title: attrs.bookmarked ? "bookmarks.created" : "bookmarks.not_bookmarked",
|
|
className,
|
|
icon: "bookmark"
|
|
};
|
|
});
|
|
|
|
registerButton("bookmarkWithReminder", (attrs, state, siteSettings) => {
|
|
if (!attrs.canBookmark || !siteSettings.enable_bookmarks_with_reminders) {
|
|
return;
|
|
}
|
|
|
|
let classNames = ["bookmark", "with-reminder"];
|
|
let title = "bookmarks.not_bookmarked";
|
|
let titleOptions = {};
|
|
|
|
if (attrs.bookmarkedWithReminder) {
|
|
classNames.push("bookmarked");
|
|
|
|
if (attrs.bookmarkReminderAt) {
|
|
let reminderAtDate = moment(attrs.bookmarkReminderAt).tz(
|
|
Discourse.currentUser.timezone
|
|
);
|
|
title = "bookmarks.created_with_reminder";
|
|
titleOptions = {
|
|
date: reminderAtDate.format(I18n.t("dates.long_with_year"))
|
|
};
|
|
} else {
|
|
title = "bookmarks.created";
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: attrs.bookmarkedWithReminder ? "unbookmark" : "bookmark",
|
|
action: "toggleBookmarkWithReminder",
|
|
title,
|
|
titleOptions,
|
|
className: classNames.join(" "),
|
|
icon: "book"
|
|
};
|
|
});
|
|
|
|
registerButton("admin", attrs => {
|
|
if (!attrs.canManage && !attrs.canWiki) {
|
|
return;
|
|
}
|
|
return {
|
|
action: "openAdminMenu",
|
|
title: "post.controls.admin",
|
|
className: "show-post-admin-menu",
|
|
icon: "wrench"
|
|
};
|
|
});
|
|
|
|
registerButton("delete", attrs => {
|
|
if (attrs.canRecoverTopic) {
|
|
return {
|
|
id: "recover_topic",
|
|
action: "recoverPost",
|
|
title: "topic.actions.recover",
|
|
icon: "undo",
|
|
className: "recover"
|
|
};
|
|
} else if (attrs.canDeleteTopic) {
|
|
return {
|
|
id: "delete_topic",
|
|
action: "deletePost",
|
|
title: "post.controls.delete_topic",
|
|
icon: "far-trash-alt",
|
|
className: "delete"
|
|
};
|
|
} else if (attrs.canRecover) {
|
|
return {
|
|
id: "recover",
|
|
action: "recoverPost",
|
|
title: "post.controls.undelete",
|
|
icon: "undo",
|
|
className: "recover"
|
|
};
|
|
} else if (attrs.canDelete) {
|
|
return {
|
|
id: "delete",
|
|
action: "deletePost",
|
|
title: "post.controls.delete",
|
|
icon: "far-trash-alt",
|
|
className: "delete"
|
|
};
|
|
} else if (attrs.showFlagDelete) {
|
|
return {
|
|
id: "delete_topic",
|
|
action: "showDeleteTopicModal",
|
|
title: "post.controls.delete_topic_disallowed",
|
|
icon: "far-trash-alt",
|
|
className: "delete"
|
|
};
|
|
}
|
|
});
|
|
|
|
function replaceButton(buttons, find, replace) {
|
|
const idx = buttons.indexOf(find);
|
|
if (idx !== -1) {
|
|
buttons[idx] = replace;
|
|
}
|
|
}
|
|
|
|
export default createWidget("post-menu", {
|
|
tagName: "section.post-menu-area.clearfix",
|
|
|
|
settings: {
|
|
collapseButtons: true,
|
|
buttonType: "flat-button",
|
|
showReplyTitleOnMobile: false
|
|
},
|
|
|
|
defaultState() {
|
|
return {
|
|
collapsed: true,
|
|
likedUsers: [],
|
|
readers: [],
|
|
adminVisible: false
|
|
};
|
|
},
|
|
|
|
buildKey: attrs => `post-menu-${attrs.id}`,
|
|
|
|
attachButton(name) {
|
|
let buttonAtts = buildButton(name, this);
|
|
if (buttonAtts) {
|
|
let button = this.attach(this.settings.buttonType, buttonAtts);
|
|
if (buttonAtts.before) {
|
|
let before = this.attachButton(buttonAtts.before);
|
|
return h("div.double-button", [before, button]);
|
|
} else if (buttonAtts.addContainer) {
|
|
return h("div.double-button", [button]);
|
|
}
|
|
|
|
return button;
|
|
}
|
|
},
|
|
|
|
menuItems() {
|
|
return this.siteSettings.post_menu.split("|").filter(Boolean);
|
|
},
|
|
|
|
html(attrs, state) {
|
|
const { currentUser, keyValueStore, siteSettings } = this;
|
|
|
|
const hiddenSetting = siteSettings.post_menu_hidden_items || "";
|
|
const hiddenButtons = hiddenSetting
|
|
.split("|")
|
|
.filter(s => !attrs.bookmarked || s !== "bookmark")
|
|
.filter(
|
|
s => !attrs.bookmarkedWithReminder || s !== "bookmarkWithReminder"
|
|
);
|
|
|
|
if (currentUser && keyValueStore) {
|
|
const likedPostId = keyValueStore.getInt("likedPostId");
|
|
if (likedPostId === attrs.id) {
|
|
keyValueStore.remove("likedPostId");
|
|
next(() => this.sendWidgetAction("toggleLike"));
|
|
}
|
|
}
|
|
|
|
const allButtons = [];
|
|
let visibleButtons = [];
|
|
|
|
const orderedButtons = this.menuItems();
|
|
|
|
// If the post is a wiki, make Edit more prominent
|
|
if (attrs.wiki && attrs.canEdit) {
|
|
replaceButton(orderedButtons, "edit", "reply-small");
|
|
replaceButton(orderedButtons, "reply", "wiki-edit");
|
|
}
|
|
|
|
orderedButtons.forEach(i => {
|
|
const button = this.attachButton(i, attrs);
|
|
|
|
if (button) {
|
|
allButtons.push(button);
|
|
|
|
if (
|
|
(attrs.yours && button.attrs && button.attrs.alwaysShowYours) ||
|
|
(attrs.reviewableId && i === "flag") ||
|
|
hiddenButtons.indexOf(i) === -1
|
|
) {
|
|
visibleButtons.push(button);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!this.settings.collapseButtons) {
|
|
visibleButtons = allButtons;
|
|
}
|
|
|
|
// Only show ellipsis if there is more than one button hidden
|
|
// if there are no more buttons, we are not collapsed
|
|
if (!state.collapsed || allButtons.length <= visibleButtons.length + 1) {
|
|
visibleButtons = allButtons;
|
|
if (state.collapsed) {
|
|
state.collapsed = false;
|
|
}
|
|
} else {
|
|
const showMore = this.attach("flat-button", {
|
|
action: "showMoreActions",
|
|
title: "show_more",
|
|
className: "show-more-actions",
|
|
icon: "ellipsis-h"
|
|
});
|
|
visibleButtons.splice(visibleButtons.length - 1, 0, showMore);
|
|
}
|
|
|
|
Object.values(_extraButtons).forEach(builder => {
|
|
if (builder) {
|
|
const buttonAtts = builder(attrs, this.state, this.siteSettings);
|
|
if (buttonAtts) {
|
|
const { position, beforeButton, afterButton } = buttonAtts;
|
|
delete buttonAtts.position;
|
|
|
|
let button = this.attach(this.settings.buttonType, buttonAtts);
|
|
|
|
const content = [];
|
|
if (beforeButton) {
|
|
content.push(beforeButton(h));
|
|
}
|
|
content.push(button);
|
|
if (afterButton) {
|
|
content.push(afterButton(h));
|
|
}
|
|
button = h("span.extra-buttons", content);
|
|
|
|
if (button) {
|
|
switch (position) {
|
|
case "first":
|
|
visibleButtons.unshift(button);
|
|
break;
|
|
case "second":
|
|
visibleButtons.splice(1, 0, button);
|
|
break;
|
|
case "second-last-hidden":
|
|
if (!state.collapsed) {
|
|
visibleButtons.splice(visibleButtons.length - 2, 0, button);
|
|
}
|
|
break;
|
|
default:
|
|
visibleButtons.push(button);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const postControls = [];
|
|
|
|
const repliesButton = this.attachButton("replies", attrs);
|
|
if (repliesButton) {
|
|
postControls.push(repliesButton);
|
|
}
|
|
|
|
let extraControls = applyDecorators(this, "extra-controls", attrs, state);
|
|
postControls.push(h("div.actions", visibleButtons.concat(extraControls)));
|
|
if (state.adminVisible) {
|
|
postControls.push(this.attach("post-admin-menu", attrs));
|
|
}
|
|
|
|
const contents = [
|
|
h(
|
|
"nav.post-controls.clearfix" +
|
|
(this.state.collapsed ? ".collapsed" : ".expanded"),
|
|
postControls
|
|
)
|
|
];
|
|
|
|
if (state.readers.length) {
|
|
const remaining = state.totalReaders - state.readers.length;
|
|
contents.push(
|
|
this.attach("small-user-list", {
|
|
users: state.readers,
|
|
addSelf: false,
|
|
listClassName: "who-read",
|
|
description: "post.actions.people.read",
|
|
count: remaining
|
|
})
|
|
);
|
|
}
|
|
|
|
if (state.likedUsers.length) {
|
|
const remaining = state.total - state.likedUsers.length;
|
|
contents.push(
|
|
this.attach("small-user-list", {
|
|
users: state.likedUsers,
|
|
addSelf: attrs.liked && remaining === 0,
|
|
listClassName: "who-liked",
|
|
description:
|
|
remaining > 0
|
|
? "post.actions.people.like_capped"
|
|
: "post.actions.people.like",
|
|
count: remaining
|
|
})
|
|
);
|
|
}
|
|
|
|
return contents;
|
|
},
|
|
|
|
openAdminMenu() {
|
|
this.state.adminVisible = true;
|
|
},
|
|
|
|
closeAdminMenu() {
|
|
this.state.adminVisible = false;
|
|
},
|
|
|
|
showDeleteTopicModal() {
|
|
showModal("delete-topic-disallowed");
|
|
},
|
|
|
|
showMoreActions() {
|
|
this.state.collapsed = false;
|
|
const likesPromise = !this.state.likedUsers.length
|
|
? this.getWhoLiked()
|
|
: Promise.resolve();
|
|
|
|
return likesPromise.then(() => {
|
|
if (!this.state.readers.length && this.attrs.showReadIndicator) {
|
|
return this.getWhoRead();
|
|
}
|
|
});
|
|
},
|
|
|
|
like() {
|
|
const { attrs, currentUser, keyValueStore } = this;
|
|
|
|
if (!currentUser) {
|
|
keyValueStore &&
|
|
keyValueStore.set({ key: "likedPostId", value: attrs.id });
|
|
return this.sendWidgetAction("showLogin");
|
|
}
|
|
|
|
if (attrs.liked) {
|
|
return this.sendWidgetAction("toggleLike");
|
|
}
|
|
|
|
const $heart = $(`[data-post-id=${attrs.id}] .toggle-like .d-icon`);
|
|
$heart.closest("button").addClass("has-like");
|
|
|
|
const scale = [1.0, 1.5];
|
|
return new Promise(resolve => {
|
|
animateHeart($heart, scale[0], scale[1], () => {
|
|
animateHeart($heart, scale[1], scale[0], () => {
|
|
this.sendWidgetAction("toggleLike").then(() => resolve());
|
|
});
|
|
});
|
|
});
|
|
},
|
|
|
|
refreshLikes() {
|
|
if (this.state.likedUsers.length) {
|
|
return this.getWhoLiked();
|
|
}
|
|
},
|
|
|
|
refreshReaders() {
|
|
if (this.state.readers.length) {
|
|
return this.getWhoRead();
|
|
}
|
|
},
|
|
|
|
getWhoLiked() {
|
|
const { attrs, state } = this;
|
|
|
|
return this.store
|
|
.find("post-action-user", {
|
|
id: attrs.id,
|
|
post_action_type_id: LIKE_ACTION
|
|
})
|
|
.then(users => {
|
|
state.likedUsers = users.map(avatarAtts);
|
|
state.total = users.totalRows;
|
|
});
|
|
},
|
|
|
|
getWhoRead() {
|
|
const { attrs, state } = this;
|
|
|
|
return this.store.find("post-reader", { id: attrs.id }).then(users => {
|
|
state.readers = users.map(avatarAtts);
|
|
state.totalReaders = users.totalRows;
|
|
});
|
|
},
|
|
|
|
toggleWhoLiked() {
|
|
const state = this.state;
|
|
if (state.likedUsers.length) {
|
|
state.likedUsers = [];
|
|
} else {
|
|
return this.getWhoLiked();
|
|
}
|
|
},
|
|
|
|
toggleWhoRead() {
|
|
const state = this.state;
|
|
if (this.state.readers.length) {
|
|
state.readers = [];
|
|
} else {
|
|
return this.getWhoRead();
|
|
}
|
|
}
|
|
});
|