FEATURE: MVP Bookmarks with reminders user list changes (#8999)
* This PR changes the user activity bookmarks stream to show a new list of bookmarks based on the Bookmark record. * If a bookmark has a name or reminder it will be shown as metadata above the topic title in the list * The categories, tags, topic status, and assigned show for each bookmarked post based on the post topic * Bookmarks can be deleted from the [...] menu in the list * As well as this, the list of bookmarks from the quick access panel is now drawn from the Bookmarks table for a user: * All of this new functionality is gated behind the enable_bookmarks_with_reminders site setting The /bookmarks/ route now redirects directly to /user/:username/activity/bookmarks-with-reminders * The structure of the Ember for the list of bookmarks is not ideal, this is an MVP PR so we can start testing this functionality internally. There is a little repeated code from topic.js.es6. There is an ongoing effort to start standardizing these lists that will be addressed in future PRs. * This PR also fixes issues with feature detection for at_desktop bookmark reminders
This commit is contained in:
parent
849631188f
commit
e1eb5fb9b3
|
@ -0,0 +1,33 @@
|
||||||
|
import { computed } from "@ember/object";
|
||||||
|
import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
|
||||||
|
export default DropdownSelectBoxComponent.extend({
|
||||||
|
classNames: ["bookmark-actions-dropdown"],
|
||||||
|
pluginApiIdentifiers: ["bookmark-actions-dropdown"],
|
||||||
|
selectKitOptions: {
|
||||||
|
icon: null,
|
||||||
|
translatedNone: "...",
|
||||||
|
showFullTitle: true
|
||||||
|
},
|
||||||
|
|
||||||
|
content: computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "remove",
|
||||||
|
icon: "trash-alt",
|
||||||
|
name: I18n.t("post.bookmarks.actions.delete_bookmark.name"),
|
||||||
|
description: I18n.t(
|
||||||
|
"post.bookmarks.actions.delete_bookmark.description"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
|
||||||
|
@action
|
||||||
|
onChange(selectedAction) {
|
||||||
|
if (selectedAction === "remove") {
|
||||||
|
this.removeBookmark(this.bookmark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -124,7 +124,7 @@ export default Controller.extend(ModalFunctionality, {
|
||||||
const reminderAt = this.reminderAt();
|
const reminderAt = this.reminderAt();
|
||||||
const reminderAtISO = reminderAt ? reminderAt.toISOString() : null;
|
const reminderAtISO = reminderAt ? reminderAt.toISOString() : null;
|
||||||
|
|
||||||
if (!reminderAt) {
|
if (!reminderAt && this.selectedReminderType === REMINDER_TYPES.CUSTOM) {
|
||||||
return Promise.reject(I18n.t("bookmarks.invalid_custom_datetime"));
|
return Promise.reject(I18n.t("bookmarks.invalid_custom_datetime"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import Controller from "@ember/controller";
|
||||||
|
import { inject } from "@ember/controller";
|
||||||
|
import discourseComputed from "discourse-common/utils/decorators";
|
||||||
|
import Bookmark from "discourse/models/bookmark";
|
||||||
|
|
||||||
|
export default Controller.extend({
|
||||||
|
application: inject(),
|
||||||
|
user: inject(),
|
||||||
|
|
||||||
|
content: null,
|
||||||
|
loading: false,
|
||||||
|
noResultsHelp: null,
|
||||||
|
|
||||||
|
loadItems() {
|
||||||
|
this.setProperties({
|
||||||
|
content: [],
|
||||||
|
loading: true,
|
||||||
|
noResultsHelp: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.model
|
||||||
|
.loadItems()
|
||||||
|
.then(response => {
|
||||||
|
if (response && response.no_results_help) {
|
||||||
|
this.set("noResultsHelp", response.no_results_help);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response && response.bookmarks) {
|
||||||
|
let bookmarks = [];
|
||||||
|
response.bookmarks.forEach(bookmark => {
|
||||||
|
bookmarks.push(Bookmark.create(bookmark));
|
||||||
|
});
|
||||||
|
this.content.pushObjects(bookmarks);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() =>
|
||||||
|
this.setProperties({
|
||||||
|
loaded: true,
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
@discourseComputed("loaded", "content.length")
|
||||||
|
noContent(loaded, content) {
|
||||||
|
return loaded && content.length === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
removeBookmark(bookmark) {
|
||||||
|
return bookmark.destroy().then(() => this.loadItems());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -3,6 +3,7 @@ import { inject as service } from "@ember/service";
|
||||||
import Controller, { inject as controller } from "@ember/controller";
|
import Controller, { inject as controller } from "@ember/controller";
|
||||||
import { exportUserArchive } from "discourse/lib/export-csv";
|
import { exportUserArchive } from "discourse/lib/export-csv";
|
||||||
import { observes } from "discourse-common/utils/decorators";
|
import { observes } from "discourse-common/utils/decorators";
|
||||||
|
import { setting } from "discourse/lib/computed";
|
||||||
|
|
||||||
export default Controller.extend({
|
export default Controller.extend({
|
||||||
application: controller(),
|
application: controller(),
|
||||||
|
@ -11,6 +12,7 @@ export default Controller.extend({
|
||||||
userActionType: null,
|
userActionType: null,
|
||||||
|
|
||||||
canDownloadPosts: alias("user.viewingSelf"),
|
canDownloadPosts: alias("user.viewingSelf"),
|
||||||
|
bookmarksWithRemindersEnabled: setting("enable_bookmarks_with_reminders"),
|
||||||
|
|
||||||
@observes("userActionType", "model.stream.itemsLoaded")
|
@observes("userActionType", "model.stream.itemsLoaded")
|
||||||
_showFooter: function() {
|
_showFooter: function() {
|
||||||
|
|
|
@ -1,8 +1,16 @@
|
||||||
|
import Category from "discourse/models/category";
|
||||||
|
import { isRTL } from "discourse/lib/text-direction";
|
||||||
|
import { censor } from "pretty-text/censored-words";
|
||||||
|
import { emojiUnescape } from "discourse/lib/text";
|
||||||
|
import Site from "discourse/models/site";
|
||||||
|
import { longDate } from "discourse/lib/formatter";
|
||||||
|
import PreloadStore from "preload-store";
|
||||||
import { none } from "@ember/object/computed";
|
import { none } from "@ember/object/computed";
|
||||||
import { computed } from "@ember/object";
|
import { computed } from "@ember/object";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import { Promise } from "rsvp";
|
import { Promise } from "rsvp";
|
||||||
import RestModel from "discourse/models/rest";
|
import RestModel from "discourse/models/rest";
|
||||||
|
import discourseComputed from "discourse-common/utils/decorators";
|
||||||
|
|
||||||
const Bookmark = RestModel.extend({
|
const Bookmark = RestModel.extend({
|
||||||
newBookmark: none("id"),
|
newBookmark: none("id"),
|
||||||
|
@ -18,6 +26,107 @@ const Bookmark = RestModel.extend({
|
||||||
return ajax(this.url, {
|
return ajax(this.url, {
|
||||||
type: "DELETE"
|
type: "DELETE"
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
@discourseComputed("highest_post_number", "url")
|
||||||
|
lastPostUrl(highestPostNumber) {
|
||||||
|
return this.urlForPostNumber(highestPostNumber);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helper to build a Url with a post number
|
||||||
|
urlForPostNumber(postNumber) {
|
||||||
|
let url = Discourse.getURL(`/t/${this.topic_id}`);
|
||||||
|
if (postNumber > 0) {
|
||||||
|
url += `/${postNumber}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
},
|
||||||
|
|
||||||
|
// returns createdAt if there's no bumped date
|
||||||
|
@discourseComputed("bumped_at", "createdAt")
|
||||||
|
bumpedAt(bumped_at, createdAt) {
|
||||||
|
if (bumped_at) {
|
||||||
|
return new Date(bumped_at);
|
||||||
|
} else {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
@discourseComputed("bumpedAt", "createdAt")
|
||||||
|
bumpedAtTitle(bumpedAt, createdAt) {
|
||||||
|
const firstPost = I18n.t("first_post");
|
||||||
|
const lastPost = I18n.t("last_post");
|
||||||
|
const createdAtDate = longDate(createdAt);
|
||||||
|
const bumpedAtDate = longDate(bumpedAt);
|
||||||
|
|
||||||
|
return I18n.messageFormat("topic.bumped_at_title_MF", {
|
||||||
|
FIRST_POST: firstPost,
|
||||||
|
CREATED_AT: createdAtDate,
|
||||||
|
LAST_POST: lastPost,
|
||||||
|
BUMPED_AT: bumpedAtDate
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
@discourseComputed("title")
|
||||||
|
fancyTitle(title) {
|
||||||
|
let fancyTitle = censor(
|
||||||
|
emojiUnescape(title) || "",
|
||||||
|
Site.currentProp("censored_regexp")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.siteSettings.support_mixed_text_direction) {
|
||||||
|
const titleDir = isRTL(title) ? "rtl" : "ltr";
|
||||||
|
return `<span dir="${titleDir}">${fancyTitle}</span>`;
|
||||||
|
}
|
||||||
|
return fancyTitle;
|
||||||
|
},
|
||||||
|
|
||||||
|
@discourseComputed("created_at")
|
||||||
|
createdAt(created_at) {
|
||||||
|
return new Date(created_at);
|
||||||
|
},
|
||||||
|
|
||||||
|
@discourseComputed("tags")
|
||||||
|
visibleListTags(tags) {
|
||||||
|
if (!tags || !this.siteSettings.suppress_overlapping_tags_in_list) {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = this.title;
|
||||||
|
const newTags = [];
|
||||||
|
|
||||||
|
tags.forEach(function(tag) {
|
||||||
|
if (title.toLowerCase().indexOf(tag) === -1) {
|
||||||
|
newTags.push(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newTags;
|
||||||
|
},
|
||||||
|
|
||||||
|
@discourseComputed("category_id")
|
||||||
|
category(categoryId) {
|
||||||
|
return Category.findById(categoryId);
|
||||||
|
},
|
||||||
|
|
||||||
|
@discourseComputed("reminder_at")
|
||||||
|
formattedReminder(bookmarkReminderAt) {
|
||||||
|
const currentUser = PreloadStore.get("currentUser");
|
||||||
|
return moment
|
||||||
|
.tz(bookmarkReminderAt, currentUser.timezone || moment.tz.guess())
|
||||||
|
.format(I18n.t("dates.long_with_year"));
|
||||||
|
},
|
||||||
|
|
||||||
|
loadItems() {
|
||||||
|
return ajax(`/u/${this.user.username}/bookmarks.json`, { cache: "false" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Bookmark.reopenClass({
|
||||||
|
create(args) {
|
||||||
|
args = args || {};
|
||||||
|
args.siteSettings = args.siteSettings || Discourse.SiteSettings;
|
||||||
|
return this._super(args);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -136,7 +136,12 @@ const Topic = RestModel.extend({
|
||||||
const createdAtDate = longDate(createdAt);
|
const createdAtDate = longDate(createdAt);
|
||||||
const bumpedAtDate = longDate(bumpedAt);
|
const bumpedAtDate = longDate(bumpedAt);
|
||||||
|
|
||||||
return `${firstPost}: ${createdAtDate}\n${lastPost}: ${bumpedAtDate}`;
|
return I18n.messageFormat("topic.bumped_at_title_MF", {
|
||||||
|
FIRST_POST: firstPost,
|
||||||
|
CREATED_AT: createdAtDate,
|
||||||
|
LAST_POST: lastPost,
|
||||||
|
BUMPED_AT: bumpedAtDate
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@discourseComputed("created_at")
|
@discourseComputed("created_at")
|
||||||
|
@ -259,7 +264,7 @@ const Topic = RestModel.extend({
|
||||||
// Helper to build a Url with a post number
|
// Helper to build a Url with a post number
|
||||||
urlForPostNumber(postNumber) {
|
urlForPostNumber(postNumber) {
|
||||||
let url = this.url;
|
let url = this.url;
|
||||||
if (postNumber && postNumber > 0) {
|
if (postNumber > 0) {
|
||||||
url += `/${postNumber}`;
|
url += `/${postNumber}`;
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import EmberObject, { computed, getProperties } from "@ember/object";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import { url } from "discourse/lib/computed";
|
import { url } from "discourse/lib/computed";
|
||||||
import RestModel from "discourse/models/rest";
|
import RestModel from "discourse/models/rest";
|
||||||
|
import Bookmark from "discourse/models/bookmark";
|
||||||
import UserStream from "discourse/models/user-stream";
|
import UserStream from "discourse/models/user-stream";
|
||||||
import UserPostsStream from "discourse/models/user-posts-stream";
|
import UserPostsStream from "discourse/models/user-posts-stream";
|
||||||
import Singleton from "discourse/mixins/singleton";
|
import Singleton from "discourse/mixins/singleton";
|
||||||
|
@ -52,6 +53,11 @@ const User = RestModel.extend({
|
||||||
return UserStream.create({ user: this });
|
return UserStream.create({ user: this });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@discourseComputed()
|
||||||
|
bookmarks() {
|
||||||
|
return Bookmark.create({ user: this });
|
||||||
|
},
|
||||||
|
|
||||||
@discourseComputed()
|
@discourseComputed()
|
||||||
postsStream() {
|
postsStream() {
|
||||||
return UserPostsStream.create({ user: this });
|
return UserPostsStream.create({ user: this });
|
||||||
|
|
|
@ -122,6 +122,9 @@ export default function() {
|
||||||
this.route("replies");
|
this.route("replies");
|
||||||
this.route("likesGiven", { path: "likes-given" });
|
this.route("likesGiven", { path: "likes-given" });
|
||||||
this.route("bookmarks");
|
this.route("bookmarks");
|
||||||
|
this.route("bookmarksWithReminders", {
|
||||||
|
path: "bookmarks-with-reminders"
|
||||||
|
});
|
||||||
this.route("pending");
|
this.route("pending");
|
||||||
this.route("drafts");
|
this.route("drafts");
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,16 +13,27 @@ export default DiscourseRoute.extend(OpenComposer, {
|
||||||
},
|
},
|
||||||
|
|
||||||
beforeModel(transition) {
|
beforeModel(transition) {
|
||||||
const user = User;
|
// the new bookmark list is radically different to this topic-based one,
|
||||||
|
// including being able to show links to multiple posts to the same topic
|
||||||
|
// and being based on a different model. better to just redirect
|
||||||
const url = transition.intent.url;
|
const url = transition.intent.url;
|
||||||
|
if (
|
||||||
|
this.siteSettings.enable_bookmarks_with_reminders &&
|
||||||
|
url === "/bookmarks"
|
||||||
|
) {
|
||||||
|
this.transitionTo(
|
||||||
|
"userActivity.bookmarksWithReminders",
|
||||||
|
this.currentUser
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(url === "/" || url === "/latest" || url === "/categories") &&
|
(url === "/" || url === "/latest" || url === "/categories") &&
|
||||||
transition.targetName.indexOf("discovery.top") === -1 &&
|
transition.targetName.indexOf("discovery.top") === -1 &&
|
||||||
user.currentProp("should_be_redirected_to_top")
|
User.currentProp("should_be_redirected_to_top")
|
||||||
) {
|
) {
|
||||||
user.currentProp("should_be_redirected_to_top", false);
|
User.currentProp("should_be_redirected_to_top", false);
|
||||||
const period = user.currentProp("redirected_to_top.period") || "all";
|
const period = User.currentProp("redirected_to_top.period") || "all";
|
||||||
this.replaceWith(`discovery.top${period.capitalize()}`);
|
this.replaceWith(`discovery.top${period.capitalize()}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
|
||||||
|
export default DiscourseRoute.extend({
|
||||||
|
noContentHelpKey: "user_activity.no_bookmarks",
|
||||||
|
|
||||||
|
queryParams: {
|
||||||
|
acting_username: { refreshModel: true }
|
||||||
|
},
|
||||||
|
|
||||||
|
model() {
|
||||||
|
return this.modelFor("user").get("bookmarks");
|
||||||
|
},
|
||||||
|
|
||||||
|
renderTemplate() {
|
||||||
|
this.render("user_bookmarks");
|
||||||
|
},
|
||||||
|
|
||||||
|
setupController(controller, model) {
|
||||||
|
controller.set("model", model);
|
||||||
|
controller.loadItems();
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
didTransition() {
|
||||||
|
this.controllerFor("user-activity")._showFooter();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -18,9 +18,15 @@
|
||||||
{{#link-to 'userActivity.likesGiven'}}{{i18n 'user_action_groups.1'}}{{/link-to}}
|
{{#link-to 'userActivity.likesGiven'}}{{i18n 'user_action_groups.1'}}{{/link-to}}
|
||||||
</li>
|
</li>
|
||||||
{{#if user.showBookmarks}}
|
{{#if user.showBookmarks}}
|
||||||
<li>
|
{{#if bookmarksWithRemindersEnabled}}
|
||||||
{{#link-to 'userActivity.bookmarks'}}{{i18n 'user_action_groups.3'}}{{/link-to}}
|
<li>
|
||||||
</li>
|
{{#link-to 'userActivity.bookmarksWithReminders'}}{{i18n 'user_action_groups.3'}}{{/link-to}}
|
||||||
|
</li>
|
||||||
|
{{else}}
|
||||||
|
<li>
|
||||||
|
{{#link-to 'userActivity.bookmarks'}}{{i18n 'user_action_groups.3'}}{{/link-to}}
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{plugin-outlet name="user-activity-bottom"
|
{{plugin-outlet name="user-activity-bottom"
|
||||||
connectorTagName='li'
|
connectorTagName='li'
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
{{#if noContent}}
|
||||||
|
<div class='alert alert-info'>{{noResultsHelp}}</div>
|
||||||
|
{{else}}
|
||||||
|
{{#conditional-loading-spinner condition=loading}}
|
||||||
|
<table class="topic-list">
|
||||||
|
<thead>
|
||||||
|
<th>{{i18n "topic.title"}}</th>
|
||||||
|
<th>{{i18n "post.bookmarks.created"}}</th>
|
||||||
|
<th>{{i18n "activity"}}</th>
|
||||||
|
<th> </th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{#each content as |bookmark|}}
|
||||||
|
<tr class="topic-list-item bookmark-list-item">
|
||||||
|
<td class="main-link">
|
||||||
|
<span class="link-top-line">
|
||||||
|
<div class="bookmark-metadata">
|
||||||
|
{{#if bookmark.name}}
|
||||||
|
<span class="bookmark-metadata-item">
|
||||||
|
{{d-icon "info-circle"}}{{bookmark.name}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
{{#if bookmark.reminder_at}}
|
||||||
|
<span class="bookmark-metadata-item">
|
||||||
|
{{d-icon "far-clock"}}{{bookmark.formattedReminder}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{topic-status topic=bookmark}}
|
||||||
|
{{topic-link bookmark}}
|
||||||
|
</span>
|
||||||
|
{{#if bookmark.excerpt}}
|
||||||
|
<p class="post-excerpt">{{html-safe bookmark.excerpt}}</p>
|
||||||
|
{{/if}}
|
||||||
|
<div class="link-bottom-line">
|
||||||
|
{{category-link bookmark.category}}
|
||||||
|
{{discourse-tags bookmark mode="list" tagsForUser=tagsForUser}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{format-date bookmark.created_at format="tiny"}}</td>
|
||||||
|
{{raw "list/activity-column" topic=bookmark class="num" tagName="td"}}
|
||||||
|
<td>
|
||||||
|
{{bookmark-actions-dropdown bookmark=bookmark removeBookmark=(action "removeBookmark")}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{/conditional-loading-spinner}}
|
||||||
|
{{/if}}
|
|
@ -321,6 +321,8 @@ registerButton("bookmarkWithReminder", (attrs, state, siteSettings) => {
|
||||||
titleOptions = {
|
titleOptions = {
|
||||||
date: reminderAtDate.format(I18n.t("dates.long_with_year"))
|
date: reminderAtDate.format(I18n.t("dates.long_with_year"))
|
||||||
};
|
};
|
||||||
|
} else if (attrs.bookmarkReminderType === "at_desktop") {
|
||||||
|
title = "bookmarks.created_with_at_desktop_reminder";
|
||||||
} else {
|
} else {
|
||||||
title = "bookmarks.created";
|
title = "bookmarks.created";
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,11 @@ createWidgetFrom(QuickAccessPanel, "quick-access-bookmarks", {
|
||||||
},
|
},
|
||||||
|
|
||||||
showAllHref() {
|
showAllHref() {
|
||||||
return `${this.attrs.path}/activity/bookmarks`;
|
if (this.siteSettings.enable_bookmarks_with_reminders) {
|
||||||
|
return `${this.attrs.path}/activity/bookmarks-with-reminders`;
|
||||||
|
} else {
|
||||||
|
return `${this.attrs.path}/activity/bookmarks`;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
emptyStatePlaceholderItem() {
|
emptyStatePlaceholderItem() {
|
||||||
|
@ -24,6 +28,50 @@ createWidgetFrom(QuickAccessPanel, "quick-access-bookmarks", {
|
||||||
},
|
},
|
||||||
|
|
||||||
findNewItems() {
|
findNewItems() {
|
||||||
|
if (this.siteSettings.enable_bookmarks_with_reminders) {
|
||||||
|
return this.loadBookmarksWithReminders();
|
||||||
|
} else {
|
||||||
|
return this.loadUserActivityBookmarks();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
itemHtml(bookmark) {
|
||||||
|
return this.attach("quick-access-item", {
|
||||||
|
icon: this.icon(bookmark),
|
||||||
|
href: postUrl(
|
||||||
|
bookmark.slug,
|
||||||
|
bookmark.topic_id,
|
||||||
|
bookmark.post_number || bookmark.linked_post_number
|
||||||
|
),
|
||||||
|
content: bookmark.title,
|
||||||
|
username: bookmark.username
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
icon(bookmark) {
|
||||||
|
if (bookmark.reminder_at) {
|
||||||
|
return "discourse-bookmark-clock";
|
||||||
|
}
|
||||||
|
return ICON;
|
||||||
|
},
|
||||||
|
|
||||||
|
loadBookmarksWithReminders() {
|
||||||
|
return ajax(`/u/${this.currentUser.username}/bookmarks.json`, {
|
||||||
|
cache: "false",
|
||||||
|
data: {
|
||||||
|
limit: this.estimateItemLimit()
|
||||||
|
}
|
||||||
|
}).then(result => {
|
||||||
|
// The empty state help text for bookmarks page is localized on the
|
||||||
|
// server.
|
||||||
|
if (result.no_results_help) {
|
||||||
|
this.state.emptyStatePlaceholderItemText = result.no_results_help;
|
||||||
|
}
|
||||||
|
return result.bookmarks;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadUserActivityBookmarks() {
|
||||||
return ajax("/user_actions.json", {
|
return ajax("/user_actions.json", {
|
||||||
cache: "false",
|
cache: "false",
|
||||||
data: {
|
data: {
|
||||||
|
@ -38,14 +86,5 @@ createWidgetFrom(QuickAccessPanel, "quick-access-bookmarks", {
|
||||||
this.state.emptyStatePlaceholderItemText = no_results_help;
|
this.state.emptyStatePlaceholderItemText = no_results_help;
|
||||||
return user_actions;
|
return user_actions;
|
||||||
});
|
});
|
||||||
},
|
|
||||||
|
|
||||||
itemHtml(bookmark) {
|
|
||||||
return this.attach("quick-access-item", {
|
|
||||||
icon: ICON,
|
|
||||||
href: postUrl(bookmark.slug, bookmark.topic_id, bookmark.post_number),
|
|
||||||
content: bookmark.title,
|
|
||||||
username: bookmark.username
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -54,6 +54,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topic-list-item {
|
||||||
|
.post-excerpt {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-size: $font-down-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.topic-list-main-link {
|
.topic-list-main-link {
|
||||||
font-size: $font-up-1;
|
font-size: $font-up-1;
|
||||||
a.title {
|
a.title {
|
||||||
|
|
|
@ -153,10 +153,12 @@ $tag-color: $primary-medium;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-list-item .discourse-tags {
|
.topic-list-item {
|
||||||
display: inline-flex;
|
.discourse-tags {
|
||||||
font-weight: normal;
|
display: inline-flex;
|
||||||
font-size: $font-down-1;
|
font-weight: normal;
|
||||||
|
font-size: $font-down-1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.categories-list .topic-list-latest .discourse-tags {
|
.categories-list .topic-list-latest .discourse-tags {
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
.bookmark-list-item {
|
||||||
|
.bookmark-metadata {
|
||||||
|
font-size: $font-down-2;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
margin-right: 0.5em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-icon {
|
||||||
|
margin-right: 0.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,14 +10,15 @@ class UsersController < ApplicationController
|
||||||
:enable_second_factor_totp, :disable_second_factor, :list_second_factors,
|
:enable_second_factor_totp, :disable_second_factor, :list_second_factors,
|
||||||
:update_second_factor, :create_second_factor_backup, :select_avatar,
|
:update_second_factor, :create_second_factor_backup, :select_avatar,
|
||||||
:notification_level, :revoke_auth_token, :register_second_factor_security_key,
|
:notification_level, :revoke_auth_token, :register_second_factor_security_key,
|
||||||
:create_second_factor_security_key, :feature_topic, :clear_featured_topic
|
:create_second_factor_security_key, :feature_topic, :clear_featured_topic,
|
||||||
|
:bookmarks
|
||||||
]
|
]
|
||||||
|
|
||||||
skip_before_action :check_xhr, only: [
|
skip_before_action :check_xhr, only: [
|
||||||
:show, :badges, :password_reset_show, :password_reset_update, :update, :account_created,
|
:show, :badges, :password_reset_show, :password_reset_update, :update, :account_created,
|
||||||
:activate_account, :perform_account_activation, :user_preferences_redirect, :avatar,
|
:activate_account, :perform_account_activation, :user_preferences_redirect, :avatar,
|
||||||
:my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary,
|
:my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary,
|
||||||
:feature_topic, :clear_featured_topic
|
:feature_topic, :clear_featured_topic, :bookmarks
|
||||||
]
|
]
|
||||||
|
|
||||||
before_action :second_factor_check_confirmed_password, only: [
|
before_action :second_factor_check_confirmed_password, only: [
|
||||||
|
@ -1378,6 +1379,20 @@ class UsersController < ApplicationController
|
||||||
render json: success_json
|
render json: success_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bookmarks
|
||||||
|
user = fetch_user_from_params
|
||||||
|
bookmarks = BookmarkQuery.new(user, params).list_all
|
||||||
|
|
||||||
|
if bookmarks.empty?
|
||||||
|
render json: {
|
||||||
|
bookmarks: [],
|
||||||
|
no_results_help: I18n.t("user_activity.no_bookmarks.self")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
render_serialized(bookmarks, UserBookmarkSerializer, root: 'bookmarks')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
HONEYPOT_KEY ||= 'HONEYPOT_KEY'
|
HONEYPOT_KEY ||= 'HONEYPOT_KEY'
|
||||||
CHALLENGE_KEY ||= 'CHALLENGE_KEY'
|
CHALLENGE_KEY ||= 'CHALLENGE_KEY'
|
||||||
|
|
||||||
|
|
|
@ -182,7 +182,11 @@ class TopicViewSerializer < ApplicationSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def bookmarked
|
def bookmarked
|
||||||
object.topic_user&.bookmarked
|
if SiteSetting.enable_bookmarks_with_reminders?
|
||||||
|
object.has_bookmarks?
|
||||||
|
else
|
||||||
|
object.topic_user&.bookmarked
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def topic_timer
|
def topic_timer
|
||||||
|
|
|
@ -0,0 +1,91 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative 'post_item_excerpt'
|
||||||
|
|
||||||
|
class UserBookmarkSerializer < ApplicationSerializer
|
||||||
|
include PostItemExcerpt
|
||||||
|
include TopicTagsMixin
|
||||||
|
|
||||||
|
attributes :id,
|
||||||
|
:created_at,
|
||||||
|
:topic_id,
|
||||||
|
:linked_post_number,
|
||||||
|
:post_id,
|
||||||
|
:name,
|
||||||
|
:reminder_at,
|
||||||
|
:title,
|
||||||
|
:deleted,
|
||||||
|
:hidden,
|
||||||
|
:category_id,
|
||||||
|
:closed,
|
||||||
|
:archived,
|
||||||
|
:archetype,
|
||||||
|
:highest_post_number,
|
||||||
|
:bumped_at,
|
||||||
|
:slug,
|
||||||
|
:username
|
||||||
|
|
||||||
|
def closed
|
||||||
|
object.topic_closed
|
||||||
|
end
|
||||||
|
|
||||||
|
def archived
|
||||||
|
object.topic_archived
|
||||||
|
end
|
||||||
|
|
||||||
|
def linked_post_number
|
||||||
|
object.post.post_number
|
||||||
|
end
|
||||||
|
|
||||||
|
def title
|
||||||
|
object.topic.title
|
||||||
|
end
|
||||||
|
|
||||||
|
def deleted
|
||||||
|
object.topic.deleted_at.present? || object.post.deleted_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def hidden
|
||||||
|
object.post.hidden
|
||||||
|
end
|
||||||
|
|
||||||
|
def category_id
|
||||||
|
object.topic.category_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def archetype
|
||||||
|
object.topic.archetype
|
||||||
|
end
|
||||||
|
|
||||||
|
def archived
|
||||||
|
object.topic.archived
|
||||||
|
end
|
||||||
|
|
||||||
|
def closed
|
||||||
|
object.topic.closed
|
||||||
|
end
|
||||||
|
|
||||||
|
def highest_post_number
|
||||||
|
object.topic.highest_post_number
|
||||||
|
end
|
||||||
|
|
||||||
|
def bumped_at
|
||||||
|
object.topic.bumped_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def raw
|
||||||
|
object.post.raw
|
||||||
|
end
|
||||||
|
|
||||||
|
def cooked
|
||||||
|
object.post.cooked
|
||||||
|
end
|
||||||
|
|
||||||
|
def slug
|
||||||
|
object.topic.slug
|
||||||
|
end
|
||||||
|
|
||||||
|
def username
|
||||||
|
object.post.user.username
|
||||||
|
end
|
||||||
|
end
|
|
@ -307,6 +307,7 @@ en:
|
||||||
created: "you've bookmarked this post"
|
created: "you've bookmarked this post"
|
||||||
not_bookmarked: "bookmark this post"
|
not_bookmarked: "bookmark this post"
|
||||||
created_with_reminder: "you've bookmarked this post with a reminder at %{date}"
|
created_with_reminder: "you've bookmarked this post with a reminder at %{date}"
|
||||||
|
created_with_at_desktop_reminder: "you've bookmarked this post and will be reminded next time you are at your desktop"
|
||||||
remove: "Remove Bookmark"
|
remove: "Remove Bookmark"
|
||||||
confirm_clear: "Are you sure you want to clear all your bookmarks from this topic?"
|
confirm_clear: "Are you sure you want to clear all your bookmarks from this topic?"
|
||||||
save: "Save"
|
save: "Save"
|
||||||
|
@ -2116,6 +2117,8 @@ en:
|
||||||
other { {BOTH, select, true{and } false {are } other{}} <a href='{basePath}/new'># new</a> topics}
|
other { {BOTH, select, true{and } false {are } other{}} <a href='{basePath}/new'># new</a> topics}
|
||||||
} remaining, or {CATEGORY, select, true {browse other topics in {catLink}} false {{latestLink}} other {}}"
|
} remaining, or {CATEGORY, select, true {browse other topics in {catLink}} false {{latestLink}} other {}}"
|
||||||
|
|
||||||
|
bumped_at_title_MF: "{FIRST_POST}: {CREATED_AT}\n{LAST_POST}: {BUMPED_AT}"
|
||||||
|
|
||||||
browse_all_categories: Browse all categories
|
browse_all_categories: Browse all categories
|
||||||
|
|
||||||
view_latest_topics: view latest topics
|
view_latest_topics: view latest topics
|
||||||
|
@ -2667,6 +2670,7 @@ en:
|
||||||
|
|
||||||
bookmarks:
|
bookmarks:
|
||||||
create: "Create bookmark"
|
create: "Create bookmark"
|
||||||
|
created: "Created"
|
||||||
name: "Name"
|
name: "Name"
|
||||||
name_placeholder: "Name the bookmark to help jog your memory"
|
name_placeholder: "Name the bookmark to help jog your memory"
|
||||||
set_reminder: "Set a reminder"
|
set_reminder: "Set a reminder"
|
||||||
|
|
|
@ -469,6 +469,7 @@ Discourse::Application.routes.draw do
|
||||||
get "#{root_path}/:username/activity" => "users#show", constraints: { username: RouteFormat.username }
|
get "#{root_path}/:username/activity" => "users#show", constraints: { username: RouteFormat.username }
|
||||||
get "#{root_path}/:username/activity/:filter" => "users#show", constraints: { username: RouteFormat.username }
|
get "#{root_path}/:username/activity/:filter" => "users#show", constraints: { username: RouteFormat.username }
|
||||||
get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username }
|
get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username }
|
||||||
|
get "#{root_path}/:username/bookmarks" => "users#bookmarks", constraints: { username: RouteFormat.username }
|
||||||
get "#{root_path}/:username/notifications" => "users#show", constraints: { username: RouteFormat.username }
|
get "#{root_path}/:username/notifications" => "users#show", constraints: { username: RouteFormat.username }
|
||||||
get "#{root_path}/:username/notifications/:filter" => "users#show", constraints: { username: RouteFormat.username }
|
get "#{root_path}/:username/notifications/:filter" => "users#show", constraints: { username: RouteFormat.username }
|
||||||
delete "#{root_path}/:username" => "users#destroy", constraints: { username: RouteFormat.username }
|
delete "#{root_path}/:username" => "users#destroy", constraints: { username: RouteFormat.username }
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
##
|
||||||
|
# Allows us to query Bookmark records for lists. Used mainly
|
||||||
|
# in the user/activity/bookmarks page.
|
||||||
|
|
||||||
|
class BookmarkQuery
|
||||||
|
def initialize(user, params)
|
||||||
|
@user = user
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_all
|
||||||
|
results = user_bookmarks
|
||||||
|
.joins('INNER JOIN topics ON topics.id = bookmarks.topic_id')
|
||||||
|
.joins('INNER JOIN posts ON posts.id = bookmarks.post_id')
|
||||||
|
.joins('INNER JOIN users ON users.id = posts.user_id')
|
||||||
|
.order('created_at DESC')
|
||||||
|
|
||||||
|
if @params[:limit]
|
||||||
|
results = results.limit(@params[:limit])
|
||||||
|
end
|
||||||
|
|
||||||
|
results
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_bookmarks
|
||||||
|
Bookmark.where(user: @user).includes(:topic).includes(post: :user)
|
||||||
|
end
|
||||||
|
end
|
|
@ -54,7 +54,7 @@ class BookmarkReminderNotificationHandler
|
||||||
def self.send_at_desktop_reminder(user:, request_user_agent:)
|
def self.send_at_desktop_reminder(user:, request_user_agent:)
|
||||||
return if !SiteSetting.enable_bookmarks_with_reminders
|
return if !SiteSetting.enable_bookmarks_with_reminders
|
||||||
|
|
||||||
return if MobileDetection.mobile_device?(BrowserDetection.device(request_user_agent).to_s)
|
return if MobileDetection.mobile_device?(request_user_agent)
|
||||||
|
|
||||||
return if !user_has_pending_at_desktop_reminders?(user)
|
return if !user_has_pending_at_desktop_reminders?(user)
|
||||||
|
|
||||||
|
|
|
@ -349,6 +349,11 @@ class TopicView
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def has_bookmarks?
|
||||||
|
return false if @user.blank?
|
||||||
|
@topic.bookmarks.exists?(user_id: @user.id)
|
||||||
|
end
|
||||||
|
|
||||||
MAX_PARTICIPANTS = 24
|
MAX_PARTICIPANTS = 24
|
||||||
|
|
||||||
def post_counts_by_user
|
def post_counts_by_user
|
||||||
|
|
|
@ -10,31 +10,36 @@ RSpec.describe BookmarkReminderNotificationHandler do
|
||||||
before do
|
before do
|
||||||
SiteSetting.enable_bookmarks_with_reminders = true
|
SiteSetting.enable_bookmarks_with_reminders = true
|
||||||
end
|
end
|
||||||
|
fab!(:reminder) do
|
||||||
|
Fabricate(
|
||||||
|
:bookmark,
|
||||||
|
user: user,
|
||||||
|
reminder_type: Bookmark.reminder_types[:at_desktop],
|
||||||
|
reminder_at: nil,
|
||||||
|
reminder_set_at: Time.zone.now
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:user_agent) { "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36" }
|
||||||
|
|
||||||
context "when the user agent is for mobile" do
|
before do
|
||||||
let(:user_agent) { "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1" }
|
Discourse.redis.flushall
|
||||||
it "does not attempt to send any reminders" do
|
|
||||||
DistributedMutex.expects(:synchronize).never
|
|
||||||
send_reminder
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when the user agent is for desktop" do
|
context "when there are pending bookmark at desktop reminders" do
|
||||||
let(:user_agent) { "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36" }
|
before do
|
||||||
fab!(:reminder) do
|
described_class.cache_pending_at_desktop_reminder(user)
|
||||||
Fabricate(
|
|
||||||
:bookmark,
|
|
||||||
user: user,
|
|
||||||
reminder_type: Bookmark.reminder_types[:at_desktop],
|
|
||||||
reminder_at: nil,
|
|
||||||
reminder_set_at: Time.zone.now
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when there are pending bookmark at desktop reminders" do
|
context "when the user agent is for mobile" do
|
||||||
before do
|
let(:user_agent) { "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1" }
|
||||||
described_class.cache_pending_at_desktop_reminder(user)
|
it "does not attempt to send any reminders" do
|
||||||
|
DistributedMutex.expects(:synchronize).never
|
||||||
|
send_reminder
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the user agent is for desktop" do
|
||||||
|
let(:user_agent) { "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36" }
|
||||||
|
|
||||||
it "deletes the key in redis" do
|
it "deletes the key in redis" do
|
||||||
send_reminder
|
send_reminder
|
||||||
|
@ -49,23 +54,23 @@ RSpec.describe BookmarkReminderNotificationHandler do
|
||||||
expect(reminder.reload.reminder_set_at).to eq(nil)
|
expect(reminder.reload.reminder_set_at).to eq(nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "when there are no pending bookmark at desktop reminders" do
|
context "when there are no pending bookmark at desktop reminders" do
|
||||||
it "does nothing" do
|
it "does nothing" do
|
||||||
DistributedMutex.expects(:synchronize).never
|
DistributedMutex.expects(:synchronize).never
|
||||||
send_reminder
|
send_reminder
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when enable bookmarks with reminders is disabled" do
|
||||||
|
before do
|
||||||
|
SiteSetting.enable_bookmarks_with_reminders = false
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when enable bookmarks with reminders is disabled" do
|
it "does nothing" do
|
||||||
before do
|
BrowserDetection.expects(:device).never
|
||||||
SiteSetting.enable_bookmarks_with_reminders = false
|
send_reminder
|
||||||
end
|
|
||||||
|
|
||||||
it "does nothing" do
|
|
||||||
BrowserDetection.expects(:device).never
|
|
||||||
send_reminder
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue