Improving bookmarks part 1 (#8466)
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.
This commit is contained in:
parent
b73a133bb5
commit
6261339da9
|
@ -0,0 +1 @@
|
|||
{{ yield (hash activeTile=this.activeTile) }}
|
|
@ -0,0 +1,2 @@
|
|||
{{d-icon icon}}
|
||||
{{text}}
|
|
@ -0,0 +1,6 @@
|
|||
import Component from "@ember/component";
|
||||
|
||||
export default Component.extend({
|
||||
classNames: ["tap-tile-grid"],
|
||||
activeTile: null
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
import Component from "@ember/component";
|
||||
import { propertyEqual } from "discourse/lib/computed";
|
||||
|
||||
export default Component.extend({
|
||||
classNames: ["tap-tile"],
|
||||
classNameBindings: ["active"],
|
||||
click() {
|
||||
this.onSelect(this.tileId);
|
||||
},
|
||||
|
||||
active: propertyEqual("activeTile", "tileId")
|
||||
});
|
|
@ -0,0 +1,217 @@
|
|||
import Controller from "@ember/controller";
|
||||
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { reads } from "@ember/object/computed";
|
||||
|
||||
const START_OF_DAY_HOUR = 8;
|
||||
const REMINDER_TYPES = {
|
||||
AT_DESKTOP: "at_desktop",
|
||||
LATER_TODAY: "later_today",
|
||||
NEXT_BUSINESS_DAY: "next_business_day",
|
||||
TOMORROW: "tomorrow",
|
||||
NEXT_WEEK: "next_week",
|
||||
NEXT_MONTH: "next_month",
|
||||
CUSTOM: "custom"
|
||||
};
|
||||
|
||||
export default Controller.extend(ModalFunctionality, {
|
||||
loading: false,
|
||||
errorMessage: null,
|
||||
name: null,
|
||||
selectedReminderType: null,
|
||||
closeWithoutSaving: false,
|
||||
isSavingBookmarkManually: false,
|
||||
onCloseWithoutSaving: null,
|
||||
|
||||
onShow() {
|
||||
this.setProperties({
|
||||
errorMessage: null,
|
||||
name: null,
|
||||
selectedReminderType: null,
|
||||
closeWithoutSaving: false,
|
||||
isSavingBookmarkManually: false
|
||||
});
|
||||
},
|
||||
|
||||
// we always want to save the bookmark unless the user specifically
|
||||
// clicks the save or cancel button to mimic browser behaviour
|
||||
onClose() {
|
||||
if (!this.closeWithoutSaving && !this.isSavingBookmarkManually) {
|
||||
this.saveBookmark();
|
||||
}
|
||||
if (this.onCloseWithoutSaving && this.closeWithoutSaving) {
|
||||
this.onCloseWithoutSaving();
|
||||
}
|
||||
},
|
||||
|
||||
usingMobileDevice: reads("site.mobileView"),
|
||||
|
||||
@discourseComputed()
|
||||
reminderTypes: () => {
|
||||
return REMINDER_TYPES;
|
||||
},
|
||||
|
||||
@discourseComputed()
|
||||
showLaterToday() {
|
||||
return !this.laterToday().isSame(this.tomorrow(), "date");
|
||||
},
|
||||
|
||||
@discourseComputed()
|
||||
laterTodayFormatted() {
|
||||
return htmlSafe(
|
||||
I18n.t("bookmarks.reminders.later_today", {
|
||||
date: this.laterToday().format(I18n.t("dates.time"))
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed()
|
||||
tomorrowFormatted() {
|
||||
return htmlSafe(
|
||||
I18n.t("bookmarks.reminders.tomorrow", {
|
||||
date: this.tomorrow().format(I18n.t("dates.time_short_day"))
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed()
|
||||
nextBusinessDayFormatted() {
|
||||
return htmlSafe(
|
||||
I18n.t("bookmarks.reminders.next_business_day", {
|
||||
date: this.nextBusinessDay().format(I18n.t("dates.time_short_day"))
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed()
|
||||
nextWeekFormatted() {
|
||||
return htmlSafe(
|
||||
I18n.t("bookmarks.reminders.next_week", {
|
||||
date: this.nextWeek().format(I18n.t("dates.month_day_time"))
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed()
|
||||
nextMonthFormatted() {
|
||||
return htmlSafe(
|
||||
I18n.t("bookmarks.reminders.next_month", {
|
||||
date: this.nextMonth().format(I18n.t("dates.month_day_time"))
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed()
|
||||
userHasTimezoneSet() {
|
||||
return !_.isEmpty(this.userTimezone());
|
||||
},
|
||||
|
||||
saveBookmark() {
|
||||
const reminderAt = this.reminderAt();
|
||||
const data = {
|
||||
reminder_type: this.selectedReminderType,
|
||||
reminder_at: reminderAt ? reminderAt.toISOString() : null,
|
||||
name: this.name,
|
||||
post_id: this.model.postId
|
||||
};
|
||||
|
||||
return ajax("/bookmarks", { type: "POST", data });
|
||||
},
|
||||
|
||||
reminderAt() {
|
||||
if (!this.selectedReminderType) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this.selectedReminderType) {
|
||||
case REMINDER_TYPES.AT_DESKTOP:
|
||||
// TODO: Implement at desktop bookmark reminder functionality
|
||||
return "";
|
||||
case REMINDER_TYPES.LATER_TODAY:
|
||||
return this.laterToday();
|
||||
case REMINDER_TYPES.NEXT_BUSINESS_DAY:
|
||||
return this.nextBusinessDay();
|
||||
case REMINDER_TYPES.TOMORROW:
|
||||
return this.tomorrow();
|
||||
case REMINDER_TYPES.NEXT_WEEK:
|
||||
return this.nextWeek();
|
||||
case REMINDER_TYPES.NEXT_MONTH:
|
||||
return this.nextMonth();
|
||||
case REMINDER_TYPES.CUSTOM:
|
||||
// TODO: Implement custom bookmark reminder times
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
nextWeek() {
|
||||
return this.startOfDay(this.now().add(7, "days"));
|
||||
},
|
||||
|
||||
nextMonth() {
|
||||
return this.startOfDay(this.now().add(1, "month"));
|
||||
},
|
||||
|
||||
nextBusinessDay() {
|
||||
const currentDay = this.now().isoWeekday(); // 1=Mon, 7=Sun
|
||||
let next = null;
|
||||
|
||||
// friday
|
||||
if (currentDay === 5) {
|
||||
next = this.now().add(3, "days");
|
||||
// saturday
|
||||
} else if (currentDay === 6) {
|
||||
next = this.now().add(2, "days");
|
||||
} else {
|
||||
next = this.now().add(1, "day");
|
||||
}
|
||||
|
||||
return this.startOfDay(next);
|
||||
},
|
||||
|
||||
tomorrow() {
|
||||
return this.startOfDay(this.now().add(1, "day"));
|
||||
},
|
||||
|
||||
startOfDay(momentDate) {
|
||||
return momentDate.hour(START_OF_DAY_HOUR).startOf("hour");
|
||||
},
|
||||
|
||||
userTimezone() {
|
||||
return this.currentUser.timezone;
|
||||
},
|
||||
|
||||
now() {
|
||||
return moment.tz(this.userTimezone());
|
||||
},
|
||||
|
||||
laterToday() {
|
||||
let later = this.now().add(3, "hours");
|
||||
return later.minutes() < 30
|
||||
? later.minutes(30)
|
||||
: later.add(30, "minutes").startOf("hour");
|
||||
},
|
||||
|
||||
actions: {
|
||||
saveAndClose() {
|
||||
this.isSavingBookmarkManually = true;
|
||||
this.saveBookmark()
|
||||
.then(() => this.send("closeModal"))
|
||||
.catch(e => {
|
||||
this.isSavingBookmarkManually = false;
|
||||
popupAjaxError(e);
|
||||
});
|
||||
},
|
||||
|
||||
closeWithoutSavingBookmark() {
|
||||
this.closeWithoutSaving = true;
|
||||
this.send("closeModal");
|
||||
},
|
||||
|
||||
selectReminderType(type) {
|
||||
this.set("selectedReminderType", type);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -679,6 +679,16 @@ export default Controller.extend(bufferedProperty("model"), {
|
|||
}
|
||||
},
|
||||
|
||||
toggleBookmarkWithReminder(post) {
|
||||
if (!this.currentUser) {
|
||||
return bootbox.alert(I18n.t("bookmarks.not_bookmarked"));
|
||||
} else if (post) {
|
||||
return post.toggleBookmarkWithReminder();
|
||||
} else {
|
||||
return this.model.toggleBookmarkWithReminder();
|
||||
}
|
||||
},
|
||||
|
||||
toggleFeaturedOnProfile() {
|
||||
if (!this.currentUser) return;
|
||||
|
||||
|
|
|
@ -35,6 +35,8 @@ export function transformBasicPost(post) {
|
|||
username: post.username,
|
||||
avatar_template: post.avatar_template,
|
||||
bookmarked: post.bookmarked,
|
||||
bookmarkedWithReminder: post.bookmarked_with_reminder,
|
||||
bookmarkReminderAt: post.bookmark_reminder_at,
|
||||
yours: post.yours,
|
||||
shareUrl: post.get("shareUrl"),
|
||||
staff: post.staff,
|
||||
|
|
|
@ -16,6 +16,7 @@ import Composer from "discourse/models/composer";
|
|||
import { Promise } from "rsvp";
|
||||
import Site from "discourse/models/site";
|
||||
import User from "discourse/models/user";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
|
||||
const Post = RestModel.extend({
|
||||
// TODO: Remove this once one instantiate all `Discourse.Post` models via the store.
|
||||
|
@ -336,6 +337,32 @@ const Post = RestModel.extend({
|
|||
});
|
||||
},
|
||||
|
||||
toggleBookmarkWithReminder() {
|
||||
this.toggleProperty("bookmarkedWithReminder");
|
||||
if (this.bookmarkedWithReminder) {
|
||||
let controller = showModal("bookmark", {
|
||||
model: {
|
||||
postId: this.id
|
||||
},
|
||||
title: "post.bookmarks.create",
|
||||
modalClass: "bookmark-with-reminder"
|
||||
});
|
||||
controller.setProperties({
|
||||
onCloseWithoutSaving: () => {
|
||||
this.toggleProperty("bookmarkedWithReminder");
|
||||
this.appEvents.trigger("post-stream:refresh", { id: this.id });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return Post.destroyBookmark(this.id)
|
||||
.then(() => this.appEvents.trigger("page:bookmark-post-toggled", this))
|
||||
.catch(error => {
|
||||
this.toggleProperty("bookmarkedWithReminder");
|
||||
throw new Error(error);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateActionsSummary(json) {
|
||||
if (json && json.id === this.id) {
|
||||
json = Post.munge(json);
|
||||
|
@ -385,6 +412,12 @@ Post.reopenClass({
|
|||
});
|
||||
},
|
||||
|
||||
destroyBookmark(postId) {
|
||||
return ajax(`/posts/${postId}/bookmark`, {
|
||||
type: "DELETE"
|
||||
});
|
||||
},
|
||||
|
||||
deleteMany(post_ids, { agreeWithFirstReplyFlag = true } = {}) {
|
||||
return ajax("/posts/destroy_many", {
|
||||
type: "DELETE",
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
{{#d-modal-body}}
|
||||
{{#conditional-loading-spinner condition=loading}}
|
||||
{{#if errorMessage}}
|
||||
<div class="control-group">
|
||||
<div class="controls">
|
||||
<div class='alert alert-error'>{{errorMessage}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label" for="name">
|
||||
{{i18n 'post.bookmarks.name'}}
|
||||
</label>
|
||||
|
||||
{{input value=name name="name" class="bookmark-name" placeholder=(i18n "post.bookmarks.name_placeholder")}}
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label" for="set_reminder">
|
||||
{{i18n 'post.bookmarks.set_reminder'}}
|
||||
</label>
|
||||
|
||||
{{#if userHasTimezoneSet}}
|
||||
{{#tap-tile-grid activeTile=selectedReminderType as |grid|}}
|
||||
{{#if usingMobileDevice}}
|
||||
<!-- {{tap-tile icon="desktop" text=(i18n "bookmarks.reminders.at_desktop") tileId=reminderTypes.AT_DESKTOP activeTile=grid.activeTile onSelect=(action "selectReminderType")}} -->
|
||||
{{/if}}
|
||||
|
||||
{{#if showLaterToday}}
|
||||
{{tap-tile icon="far-moon" text=laterTodayFormatted tileId=reminderTypes.LATER_TODAY activeTile=grid.activeTile onSelect=(action "selectReminderType")}}
|
||||
{{/if}}
|
||||
{{tap-tile icon="briefcase" text=nextBusinessDayFormatted tileId=reminderTypes.NEXT_BUSINESS_DAY activeTile=grid.activeTile onSelect=(action "selectReminderType")}}
|
||||
{{tap-tile icon="far-sun" text=tomorrowFormatted tileId=reminderTypes.TOMORROW activeTile=grid.activeTile onSelect=(action "selectReminderType")}}
|
||||
{{tap-tile icon="far-clock" text=nextWeekFormatted tileId=reminderTypes.NEXT_WEEK activeTile=grid.activeTile onSelect=(action "selectReminderType")}}
|
||||
{{tap-tile icon="far-calendar-plus" text=nextMonthFormatted tileId=reminderTypes.NEXT_MONTH activeTile=grid.activeTile onSelect=(action "selectReminderType")}}
|
||||
<!-- {{tap-tile icon="calendar-alt" text=(I18n "bookmarks.reminders.custom") tileId=reminderTypes.CUSTOM activeTile=grid.activeTile onSelect=(action "selectReminderType")}} -->
|
||||
{{/tap-tile-grid}}
|
||||
{{else}}
|
||||
<div class="alert alert-info">{{{i18n "bookmarks.no_timezone" basePath=basePath }}}</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
{{d-button label="bookmarks.save" class="btn-primary" action=(action "saveAndClose")}}
|
||||
{{d-modal-cancel close=(action "closeWithoutSavingBookmark")}}
|
||||
</div>
|
||||
{{/conditional-loading-spinner}}
|
||||
{{/d-modal-body}}
|
|
@ -193,6 +193,7 @@
|
|||
expandHidden=(action "expandHidden")
|
||||
newTopicAction=(action "replyAsNewTopic")
|
||||
toggleBookmark=(action "toggleBookmark")
|
||||
toggleBookmarkWithReminder=(action "toggleBookmarkWithReminder")
|
||||
togglePostType=(action "togglePostType")
|
||||
rebakePost=(action "rebakePost")
|
||||
changePostOwner=(action "changePostOwner")
|
||||
|
|
|
@ -301,6 +301,41 @@ registerButton("bookmark", attrs => {
|
|||
};
|
||||
});
|
||||
|
||||
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;
|
||||
|
@ -409,7 +444,10 @@ export default createWidget("post-menu", {
|
|||
const hiddenSetting = siteSettings.post_menu_hidden_items || "";
|
||||
const hiddenButtons = hiddenSetting
|
||||
.split("|")
|
||||
.filter(s => !attrs.bookmarked || s !== "bookmark");
|
||||
.filter(s => !attrs.bookmarked || s !== "bookmark")
|
||||
.filter(
|
||||
s => !attrs.bookmarkedWithReminder || s !== "bookmarkWithReminder"
|
||||
);
|
||||
|
||||
if (currentUser && keyValueStore) {
|
||||
const likedPostId = keyValueStore.getInt("likedPostId");
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
.bookmark-with-reminder.modal {
|
||||
.modal-body {
|
||||
max-width: 410px;
|
||||
min-width: 380px;
|
||||
|
||||
.control-label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ember-text-field.bookmark-name {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
.tap-tile-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
$tile-width: 100px;
|
||||
$horizontal-tile-padding: 5px;
|
||||
|
||||
.tap-tile {
|
||||
padding: 10px $horizontal-tile-padding;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
border: 1px solid $primary-medium;
|
||||
margin: 0 0 20px;
|
||||
width: $tile-width;
|
||||
font-size: $font-down-2;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $tertiary-low;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $highlight-medium;
|
||||
}
|
||||
|
||||
.svg-icon,
|
||||
.svg-icon-title {
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: $tile-width + ($horizontal-tile-padding * 3);
|
||||
}
|
||||
}
|
|
@ -206,6 +206,9 @@ nav.post-controls {
|
|||
&.bookmarked .d-icon {
|
||||
color: $tertiary;
|
||||
}
|
||||
&.with-reminder.bookmarked .d-icon {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
.post-admin-menu {
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class BookmarksController < ApplicationController
|
||||
requires_login
|
||||
|
||||
def create
|
||||
params.require(:post_id)
|
||||
|
||||
existing_bookmark = Bookmark.find_by(post_id: params[:post_id], user_id: current_user.id)
|
||||
if existing_bookmark.present?
|
||||
return render json: failed_json.merge(errors: [I18n.t("bookmarks.errors.already_bookmarked_post")]), status: 422
|
||||
end
|
||||
|
||||
bookmark = Bookmark.create(
|
||||
user_id: current_user.id,
|
||||
topic_id: params[:topic_id],
|
||||
post_id: params[:post_id],
|
||||
name: params[:name],
|
||||
reminder_type: Bookmark.reminder_types[params[:reminder_type].to_sym],
|
||||
reminder_at: params[:reminder_at]
|
||||
)
|
||||
|
||||
return render json: success_json if bookmark.save
|
||||
render json: failed_json.merge(errors: bookmark.errors.full_messages), status: 400
|
||||
end
|
||||
end
|
|
@ -508,6 +508,15 @@ class PostsController < ApplicationController
|
|||
render_json_dump(topic_bookmarked: topic_user.try(:bookmarked))
|
||||
end
|
||||
|
||||
def destroy_bookmark
|
||||
params.require(:post_id)
|
||||
|
||||
existing_bookmark = Bookmark.find_by(post_id: params[:post_id], user_id: current_user.id)
|
||||
existing_bookmark.destroy if existing_bookmark.present?
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def wiki
|
||||
post = find_post_from_params
|
||||
guardian.ensure_can_wiki!(post)
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Bookmark < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :post
|
||||
belongs_to :topic
|
||||
|
||||
validates :reminder_at, presence: {
|
||||
message: I18n.t("bookmarks.errors.time_must_be_provided", reminder_type: I18n.t("bookmarks.reminders.at_desktop")),
|
||||
if: -> { reminder_type.present? && reminder_type != Bookmark.reminder_types[:at_desktop] }
|
||||
}
|
||||
|
||||
def self.reminder_types
|
||||
@reminder_type = Enum.new(
|
||||
at_desktop: 0,
|
||||
later_today: 1,
|
||||
next_business_day: 2,
|
||||
tomorrow: 3,
|
||||
next_week: 4,
|
||||
next_month: 5,
|
||||
custom: 6
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: bookmarks
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# user_id :bigint not null
|
||||
# topic_id :bigint
|
||||
# post_id :bigint not null
|
||||
# name :string
|
||||
# reminder_type :integer
|
||||
# reminder_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_bookmarks_on_post_id (post_id)
|
||||
# index_bookmarks_on_reminder_at (reminder_at)
|
||||
# index_bookmarks_on_reminder_type (reminder_type)
|
||||
# index_bookmarks_on_topic_id (topic_id)
|
||||
# index_bookmarks_on_user_id (user_id)
|
||||
# index_bookmarks_on_user_id_and_post_id (user_id,post_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (post_id => posts.id)
|
||||
# fk_rails_... (topic_id => topics.id)
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
|
@ -49,6 +49,8 @@ class PostSerializer < BasicPostSerializer
|
|||
:user_title,
|
||||
:reply_to_user,
|
||||
:bookmarked,
|
||||
:bookmarked_with_reminder,
|
||||
:bookmark_reminder_at,
|
||||
:raw,
|
||||
:actions_summary,
|
||||
:moderator?,
|
||||
|
@ -218,10 +220,6 @@ class PostSerializer < BasicPostSerializer
|
|||
}
|
||||
end
|
||||
|
||||
def bookmarked
|
||||
true
|
||||
end
|
||||
|
||||
def deleted_by
|
||||
BasicUserSerializer.new(object.deleted_by, root: false).as_json
|
||||
end
|
||||
|
@ -309,8 +307,35 @@ class PostSerializer < BasicPostSerializer
|
|||
!(SiteSetting.suppress_reply_when_quoting && object.reply_quoted?) && object.reply_to_user
|
||||
end
|
||||
|
||||
# this atrtribute is not even included unless include_bookmarked? is true,
|
||||
# which is why it is always true if included
|
||||
def bookmarked
|
||||
true
|
||||
end
|
||||
|
||||
def bookmarked_with_reminder
|
||||
true
|
||||
end
|
||||
|
||||
def include_bookmarked?
|
||||
actions.present? && actions.keys.include?(PostActionType.types[:bookmark])
|
||||
(actions.present? && actions.keys.include?(PostActionType.types[:bookmark]))
|
||||
end
|
||||
|
||||
def include_bookmarked_with_reminder?
|
||||
post_bookmark.present?
|
||||
end
|
||||
|
||||
def include_bookmark_reminder_at?
|
||||
include_bookmarked_with_reminder?
|
||||
end
|
||||
|
||||
def post_bookmark
|
||||
return nil if !SiteSetting.enable_bookmarks_with_reminders?
|
||||
@post_bookmark ||= @topic_view.user_post_bookmarks.find { |bookmark| bookmark.post_id == object.id }
|
||||
end
|
||||
|
||||
def bookmark_reminder_at
|
||||
post_bookmark&.reminder_at
|
||||
end
|
||||
|
||||
def include_display_username?
|
||||
|
|
|
@ -40,6 +40,10 @@ en:
|
|||
# Use Moment.js format string: https://momentjs.com/docs/#/displaying/format/
|
||||
time: "HH:mm"
|
||||
# Use Moment.js format string: https://momentjs.com/docs/#/displaying/format/
|
||||
time_short_day: "ddd HH:mm a"
|
||||
# Use Moment.js format string: https://momentjs.com/docs/#/displaying/format/
|
||||
month_day_time: "MMM D, HH:mm a"
|
||||
# Use Moment.js format string: https://momentjs.com/docs/#/displaying/format/
|
||||
timeline_date: "MMM YYYY"
|
||||
# Use Moment.js format string: https://momentjs.com/docs/#/displaying/format/
|
||||
long_no_year: "D MMM HH:mm"
|
||||
|
@ -303,8 +307,19 @@ en:
|
|||
bookmarks:
|
||||
created: "you've bookmarked this post"
|
||||
not_bookmarked: "bookmark this post"
|
||||
created_with_reminder: "you've bookmarked this post with a reminder at %{date}"
|
||||
remove: "Remove Bookmark"
|
||||
confirm_clear: "Are you sure you want to clear all your bookmarks from this topic?"
|
||||
save: "Save"
|
||||
no_timezone: "You have not set a timezone yet. You will not be able to set reminders. Set one up <a href=\"%{basePath}/my/preferences/profile\">in your profile</a>."
|
||||
reminders:
|
||||
at_desktop: "Next time I'm at my desktop"
|
||||
later_today: "Later today <br/>{{date}}"
|
||||
next_business_day: "Next business day <br/>{{date}}"
|
||||
tomorrow: "Tomorrow <br/>{{date}}"
|
||||
next_week: "Next week <br/>{{date}}"
|
||||
next_month: "Next month <br/>{{date}}"
|
||||
custom: "Custom date and time"
|
||||
|
||||
drafts:
|
||||
resume: "Resume"
|
||||
|
@ -2606,6 +2621,12 @@ en:
|
|||
html_part:
|
||||
title: "Show the html part of the email"
|
||||
button: "HTML"
|
||||
|
||||
bookmarks:
|
||||
create: "Create bookmark"
|
||||
name: "Name"
|
||||
name_placeholder: "Name the bookmark to help jog your memory"
|
||||
set_reminder: "Set a reminder"
|
||||
|
||||
category:
|
||||
can: "can… "
|
||||
|
|
|
@ -379,6 +379,20 @@ en:
|
|||
|
||||
excerpt_image: "image"
|
||||
|
||||
bookmarks:
|
||||
errors:
|
||||
already_bookmarked_post: "You cannot bookmark the same post twice."
|
||||
time_must_be_provided: "time must be provided for all reminders except '%{reminder_type}'"
|
||||
|
||||
reminders:
|
||||
at_desktop: "Next time I'm at my desktop"
|
||||
later_today: "Later today <br/>{{date}}"
|
||||
next_business_day: "Next business day <br/>{{date}}"
|
||||
tomorrow: "Tomorrow <br/>{{date}}"
|
||||
next_week: "Next week <br/>{{date}}"
|
||||
next_month: "Next month <br/>{{date}}"
|
||||
custom: "Custom date and time"
|
||||
|
||||
groups:
|
||||
success:
|
||||
bulk_add:
|
||||
|
@ -561,6 +575,7 @@ en:
|
|||
attributes:
|
||||
word:
|
||||
too_many: "Too many words for that action"
|
||||
|
||||
|
||||
uncategorized_category_name: "Uncategorized"
|
||||
|
||||
|
|
|
@ -573,6 +573,7 @@ Discourse::Application.routes.draw do
|
|||
|
||||
resources :posts do
|
||||
put "bookmark"
|
||||
delete "bookmark", to: "posts#destroy_bookmark"
|
||||
put "wiki"
|
||||
put "post_type"
|
||||
put "rebake"
|
||||
|
@ -592,6 +593,8 @@ Discourse::Application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
resources :bookmarks, only: %i[create]
|
||||
|
||||
resources :notifications, except: :show do
|
||||
collection do
|
||||
put 'mark-read' => 'notifications#mark_read'
|
||||
|
|
|
@ -187,7 +187,7 @@ basic:
|
|||
post_menu:
|
||||
client: true
|
||||
type: list
|
||||
default: "read|like|share|flag|edit|bookmark|delete|admin|reply"
|
||||
default: "read|like|share|flag|edit|bookmark|bookmarkWithReminder|delete|admin|reply"
|
||||
allow_any: false
|
||||
choices:
|
||||
- read
|
||||
|
@ -199,10 +199,11 @@ basic:
|
|||
- bookmark
|
||||
- admin
|
||||
- reply
|
||||
- bookmarkWithReminder
|
||||
post_menu_hidden_items:
|
||||
client: true
|
||||
type: list
|
||||
default: "flag|bookmark|edit|delete|admin"
|
||||
default: "flag|bookmark|bookmarkWithReminder|edit|delete|admin"
|
||||
allow_any: false
|
||||
choices:
|
||||
- like
|
||||
|
@ -213,6 +214,7 @@ basic:
|
|||
- bookmark
|
||||
- admin
|
||||
- reply
|
||||
- bookmarkWithReminder
|
||||
share_links:
|
||||
client: true
|
||||
type: list
|
||||
|
@ -288,6 +290,10 @@ basic:
|
|||
enable_whispers:
|
||||
client: true
|
||||
default: false
|
||||
enable_bookmarks_with_reminders:
|
||||
client: true
|
||||
default: false
|
||||
hidden: true
|
||||
push_notifications_prompt:
|
||||
default: true
|
||||
client: true
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateStandaloneBookmarksTable < ActiveRecord::Migration[6.0]
|
||||
def up
|
||||
create_table :bookmarks do |t|
|
||||
t.references :user, index: true, foreign_key: true, null: false
|
||||
t.references :topic, index: true, foreign_key: true, null: true
|
||||
t.references :post, index: true, foreign_key: true, null: false
|
||||
t.string :name, null: true
|
||||
t.integer :reminder_type, null: true, index: true
|
||||
t.datetime :reminder_at, null: true, index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :bookmarks, [:user_id, :post_id], unique: true
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table(:bookmarks) if table_exists?(:bookmarks)
|
||||
end
|
||||
end
|
|
@ -428,6 +428,10 @@ class TopicView
|
|||
@links ||= TopicLink.topic_map(@guardian, @topic.id)
|
||||
end
|
||||
|
||||
def user_post_bookmarks
|
||||
@user_post_bookmarks ||= Bookmark.where(user: @user, post_id: unfiltered_post_ids)
|
||||
end
|
||||
|
||||
def reviewable_counts
|
||||
if @reviewable_counts.nil?
|
||||
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:bookmark) do
|
||||
user
|
||||
post { Fabricate(:post) }
|
||||
topic nil
|
||||
name "This looked interesting"
|
||||
reminder_type { Bookmark.reminder_types[:tomorrow] }
|
||||
reminder_at { (Time.now.utc + 1.day).iso8601 }
|
||||
end
|
||||
|
||||
Fabricator(:bookmark_next_business_day_reminder, from: :bookmark) do
|
||||
reminder_type { Bookmark.reminder_types[:next_business_day] }
|
||||
reminder_at do
|
||||
date = if Time.now.utc.friday?
|
||||
Time.now.utc + 3.days
|
||||
elsif Time.now.utc.saturday?
|
||||
Time.now.utc + 2.days
|
||||
else
|
||||
Time.now.utc + 1.day
|
||||
end
|
||||
date.iso8601
|
||||
end
|
||||
end
|
|
@ -0,0 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe BookmarksController do
|
||||
let(:current_user) { Fabricate(:user) }
|
||||
let(:bookmark_post) { Fabricate(:post) }
|
||||
|
||||
before do
|
||||
sign_in(current_user)
|
||||
end
|
||||
|
||||
describe "#create" do
|
||||
context "if the user already has bookmarked the post" do
|
||||
before do
|
||||
Fabricate(:bookmark, post: bookmark_post, user: current_user)
|
||||
end
|
||||
|
||||
it "returns failed JSON with a 422 error" do
|
||||
post "/bookmarks.json", params: {
|
||||
post_id: bookmark_post.id,
|
||||
reminder_type: "tomorrow",
|
||||
reminder_at: (Time.now.utc + 1.day).iso8601
|
||||
}
|
||||
|
||||
expect(response.status).to eq(422)
|
||||
expect(JSON.parse(response.body)['errors']).to include(
|
||||
I18n.t("bookmarks.errors.already_bookmarked_post")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "if the user provides a reminder type that needs a reminder_at that is missing" do
|
||||
it "returns failed JSON with a 400 error" do
|
||||
post "/bookmarks.json", params: {
|
||||
post_id: bookmark_post.id,
|
||||
reminder_type: "tomorrow"
|
||||
}
|
||||
|
||||
expect(response.status).to eq(400)
|
||||
expect(JSON.parse(response.body)['errors'].first).to include(
|
||||
I18n.t("bookmarks.errors.time_must_be_provided", reminder_type: I18n.t("bookmarks.reminders.at_desktop"))
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -109,12 +109,6 @@ describe PostSerializer do
|
|||
let(:user) { Fabricate.build(:user, id: 101) }
|
||||
let(:raw) { "Raw contents of the post." }
|
||||
|
||||
def serialized_post_for_user(u)
|
||||
s = PostSerializer.new(post, scope: Guardian.new(u), root: false)
|
||||
s.add_raw = true
|
||||
s.as_json
|
||||
end
|
||||
|
||||
context "a public post" do
|
||||
let(:post) { Fabricate.build(:post, raw: raw, user: user) }
|
||||
|
||||
|
@ -231,4 +225,68 @@ describe PostSerializer do
|
|||
end
|
||||
end
|
||||
|
||||
context "post with bookmarks" do
|
||||
let(:current_user) { Fabricate(:user) }
|
||||
let(:serialized) do
|
||||
s = serialized_post(current_user)
|
||||
s.post_actions = PostAction.counts_for([post], current_user)[post.id]
|
||||
s.topic_view = TopicView.new(post.topic, current_user)
|
||||
s
|
||||
end
|
||||
|
||||
context "when a user post action for the bookmark exists" do
|
||||
before do
|
||||
PostActionCreator.create(current_user, post, :bookmark)
|
||||
end
|
||||
|
||||
it "returns true" do
|
||||
expect(serialized.as_json[:bookmarked]).to eq(true)
|
||||
end
|
||||
end
|
||||
|
||||
context "when a user post action for the bookmark does not exist" do
|
||||
it "does not return the bookmarked attribute" do
|
||||
expect(serialized.as_json.key?(:bookmarked)).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
context "when a Bookmark record exists for the user on the post" do
|
||||
let!(:bookmark) { Fabricate(:bookmark_next_business_day_reminder, user: current_user, post: post) }
|
||||
|
||||
context "when the site setting for bookmarks with reminders is enabled" do
|
||||
before do
|
||||
SiteSetting.enable_bookmarks_with_reminders = true
|
||||
end
|
||||
|
||||
it "returns true" do
|
||||
expect(serialized.as_json[:bookmarked_with_reminder]).to eq(true)
|
||||
end
|
||||
|
||||
it "returns the reminder_at for the bookmark" do
|
||||
expect(serialized.as_json[:bookmark_reminder_at]).to eq(bookmark.reminder_at.iso8601)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the site setting for bookmarks with reminders is disabled" do
|
||||
it "does not return the bookmarked attribute" do
|
||||
expect(serialized.as_json.key?(:bookmarked_with_reminder)).to eq(false)
|
||||
end
|
||||
|
||||
it "does not return the bookmark_reminder_at attribute" do
|
||||
expect(serialized.as_json.key?(:bookmark_reminder_at)).to eq(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def serialized_post(u)
|
||||
s = PostSerializer.new(post, scope: Guardian.new(u), root: false)
|
||||
s.add_raw = true
|
||||
s
|
||||
end
|
||||
|
||||
def serialized_post_for_user(u)
|
||||
s = serialized_post(u)
|
||||
s.as_json
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
import { currentUser } from "helpers/qunit-helpers";
|
||||
let BookmarkController;
|
||||
|
||||
moduleFor("controller:bookmark", {
|
||||
beforeEach() {
|
||||
BookmarkController = this.subject({ currentUser: currentUser() });
|
||||
}
|
||||
});
|
||||
|
||||
function mockMomentTz(dateString) {
|
||||
let now = moment.tz(dateString, BookmarkController.currentUser.timezone);
|
||||
sandbox.useFakeTimers(now.valueOf());
|
||||
}
|
||||
|
||||
QUnit.test("showLaterToday when later today is tomorrow do not show", function(
|
||||
assert
|
||||
) {
|
||||
mockMomentTz("2019-12-11T13:00:00Z");
|
||||
|
||||
assert.equal(BookmarkController.get("showLaterToday"), false);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"showLaterToday when later today is before the end of the day, show",
|
||||
function(assert) {
|
||||
mockMomentTz("2019-12-11T08:00:00Z");
|
||||
|
||||
assert.equal(BookmarkController.get("showLaterToday"), true);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("nextWeek gets next week correctly", function(assert) {
|
||||
mockMomentTz("2019-12-11T08:00:00Z");
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.nextWeek().format("YYYY-MM-DD"),
|
||||
"2019-12-18"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("nextMonth gets next month correctly", function(assert) {
|
||||
mockMomentTz("2019-12-11T08:00:00Z");
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.nextMonth().format("YYYY-MM-DD"),
|
||||
"2020-01-11"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"nextBusinessDay gets next business day of monday correctly if today is friday",
|
||||
function(assert) {
|
||||
mockMomentTz("2019-12-13T08:00:00Z");
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.nextBusinessDay().format("YYYY-MM-DD"),
|
||||
"2019-12-16"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"nextBusinessDay gets next business day of monday correctly if today is saturday",
|
||||
function(assert) {
|
||||
mockMomentTz("2019-12-14T08:00:00Z");
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.nextBusinessDay().format("YYYY-MM-DD"),
|
||||
"2019-12-16"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"nextBusinessDay gets next business day of monday correctly if today is sunday",
|
||||
function(assert) {
|
||||
mockMomentTz("2019-12-15T08:00:00Z");
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.nextBusinessDay().format("YYYY-MM-DD"),
|
||||
"2019-12-16"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"nextBusinessDay gets next business day of thursday correctly if today is wednesday",
|
||||
function(assert) {
|
||||
mockMomentTz("2019-12-11T08:00:00Z");
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.nextBusinessDay().format("YYYY-MM-DD"),
|
||||
"2019-12-12"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("tomorrow gets tomorrow correctly", function(assert) {
|
||||
mockMomentTz("2019-12-11T08:00:00Z");
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.tomorrow().format("YYYY-MM-DD"),
|
||||
"2019-12-12"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"startOfDay changes the time of the provided date to 8:00am correctly",
|
||||
function(assert) {
|
||||
let dt = moment.tz(
|
||||
"2019-12-11T11:37:16Z",
|
||||
BookmarkController.currentUser.timezone
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.startOfDay(dt).format("YYYY-MM-DD HH:mm:ss"),
|
||||
"2019-12-11 08:00:00"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"laterToday gets 3 hours from now and if before half-past, it sets the time to half-past",
|
||||
function(assert) {
|
||||
mockMomentTz("2019-12-11T08:13:00Z");
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.laterToday().format("YYYY-MM-DD HH:mm:ss"),
|
||||
"2019-12-11 21:30:00"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"laterToday gets 3 hours from now and if after half-past, it rounds up to the next hour",
|
||||
function(assert) {
|
||||
mockMomentTz("2019-12-11T08:43:00Z");
|
||||
|
||||
assert.equal(
|
||||
BookmarkController.laterToday().format("YYYY-MM-DD HH:mm:ss"),
|
||||
"2019-12-11 22:00:00"
|
||||
);
|
||||
}
|
||||
);
|
|
@ -27,7 +27,8 @@ export default {
|
|||
muted_category_ids: [],
|
||||
dismissed_banner_key: null,
|
||||
akismet_review_count: 0,
|
||||
title_count_mode: "notifications"
|
||||
title_count_mode: "notifications",
|
||||
timezone: "Australia/Brisbane"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue