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:
Martin Brennan 2020-03-12 15:20:56 +10:00 committed by GitHub
parent 849631188f
commit e1eb5fb9b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 597 additions and 61 deletions

View File

@ -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);
}
}
});

View File

@ -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"));
} }

View File

@ -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());
}
}
});

View File

@ -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() {

View File

@ -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);
} }
}); });

View File

@ -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;

View File

@ -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 });

View File

@ -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");
} }

View File

@ -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()}`);
} }
}, },

View File

@ -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;
}
}
});

View File

@ -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'

View File

@ -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>&nbsp;</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}}

View File

@ -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";
} }

View File

@ -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
});
} }
}); });

View File

@ -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 {

View File

@ -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 {

View File

@ -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;
}
}
}

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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 }

32
lib/bookmark_query.rb Normal file
View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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