diff --git a/app/assets/javascripts/admin/templates/components/tap-tile-grid.hbs b/app/assets/javascripts/admin/templates/components/tap-tile-grid.hbs
new file mode 100644
index 00000000000..7807cc0567d
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/tap-tile-grid.hbs
@@ -0,0 +1 @@
+{{ yield (hash activeTile=this.activeTile) }}
diff --git a/app/assets/javascripts/admin/templates/components/tap-tile.hbs b/app/assets/javascripts/admin/templates/components/tap-tile.hbs
new file mode 100644
index 00000000000..4e042009450
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/tap-tile.hbs
@@ -0,0 +1,2 @@
+{{d-icon icon}}
+{{text}}
diff --git a/app/assets/javascripts/discourse/components/tap-tile-grid.js.es6 b/app/assets/javascripts/discourse/components/tap-tile-grid.js.es6
new file mode 100644
index 00000000000..37d5fda33a1
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/tap-tile-grid.js.es6
@@ -0,0 +1,6 @@
+import Component from "@ember/component";
+
+export default Component.extend({
+ classNames: ["tap-tile-grid"],
+ activeTile: null
+});
diff --git a/app/assets/javascripts/discourse/components/tap-tile.js.es6 b/app/assets/javascripts/discourse/components/tap-tile.js.es6
new file mode 100644
index 00000000000..a7dfbe267cc
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/tap-tile.js.es6
@@ -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")
+});
diff --git a/app/assets/javascripts/discourse/controllers/bookmark.js.es6 b/app/assets/javascripts/discourse/controllers/bookmark.js.es6
new file mode 100644
index 00000000000..b02a2d9ac6e
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/bookmark.js.es6
@@ -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);
+ }
+ }
+});
diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6
index ed1f8d8add4..66bb4acebfb 100644
--- a/app/assets/javascripts/discourse/controllers/topic.js.es6
+++ b/app/assets/javascripts/discourse/controllers/topic.js.es6
@@ -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;
diff --git a/app/assets/javascripts/discourse/lib/transform-post.js.es6 b/app/assets/javascripts/discourse/lib/transform-post.js.es6
index a0189345210..f6d19c6b46b 100644
--- a/app/assets/javascripts/discourse/lib/transform-post.js.es6
+++ b/app/assets/javascripts/discourse/lib/transform-post.js.es6
@@ -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,
diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6
index 5bb020e4442..3fbf7af8771 100644
--- a/app/assets/javascripts/discourse/models/post.js.es6
+++ b/app/assets/javascripts/discourse/models/post.js.es6
@@ -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",
diff --git a/app/assets/javascripts/discourse/templates/modal/bookmark.hbs b/app/assets/javascripts/discourse/templates/modal/bookmark.hbs
new file mode 100644
index 00000000000..ff1800cdcd3
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/bookmark.hbs
@@ -0,0 +1,49 @@
+{{#d-modal-body}}
+ {{#conditional-loading-spinner condition=loading}}
+ {{#if errorMessage}}
+
+ {{/if}}
+
+
+
+
+ {{input value=name name="name" class="bookmark-name" placeholder=(i18n "post.bookmarks.name_placeholder")}}
+
+
+
+
+
+ {{#if userHasTimezoneSet}}
+ {{#tap-tile-grid activeTile=selectedReminderType as |grid|}}
+ {{#if usingMobileDevice}}
+
+ {{/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-grid}}
+ {{else}}
+
{{{i18n "bookmarks.no_timezone" basePath=basePath }}}
+ {{/if}}
+
+
+
+ {{d-button label="bookmarks.save" class="btn-primary" action=(action "saveAndClose")}}
+ {{d-modal-cancel close=(action "closeWithoutSavingBookmark")}}
+
+ {{/conditional-loading-spinner}}
+{{/d-modal-body}}
diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs
index ad6a1557840..f1cdd677658 100644
--- a/app/assets/javascripts/discourse/templates/topic.hbs
+++ b/app/assets/javascripts/discourse/templates/topic.hbs
@@ -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")
diff --git a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-menu.js.es6
index 2039ed446f5..a21c921420b 100644
--- a/app/assets/javascripts/discourse/widgets/post-menu.js.es6
+++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6
@@ -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");
diff --git a/app/assets/stylesheets/common/components/bookmark-modal.scss b/app/assets/stylesheets/common/components/bookmark-modal.scss
new file mode 100644
index 00000000000..8df4d572035
--- /dev/null
+++ b/app/assets/stylesheets/common/components/bookmark-modal.scss
@@ -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%;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/common/components/tap-tile.scss b/app/assets/stylesheets/common/components/tap-tile.scss
new file mode 100644
index 00000000000..7e8149650ab
--- /dev/null
+++ b/app/assets/stylesheets/common/components/tap-tile.scss
@@ -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);
+ }
+}
diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss
index ee169e499f9..d6e0bfd9c87 100644
--- a/app/assets/stylesheets/desktop/topic-post.scss
+++ b/app/assets/stylesheets/desktop/topic-post.scss
@@ -206,6 +206,9 @@ nav.post-controls {
&.bookmarked .d-icon {
color: $tertiary;
}
+ &.with-reminder.bookmarked .d-icon {
+ color: $danger;
+ }
}
}
.post-admin-menu {
diff --git a/app/controllers/bookmarks_controller.rb b/app/controllers/bookmarks_controller.rb
new file mode 100644
index 00000000000..d342becadae
--- /dev/null
+++ b/app/controllers/bookmarks_controller.rb
@@ -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
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index c28102530cd..44dd116c19f 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -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)
diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb
new file mode 100644
index 00000000000..73a6bd723cf
--- /dev/null
+++ b/app/models/bookmark.rb
@@ -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)
+#
diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb
index 8651252e845..b148045041d 100644
--- a/app/serializers/post_serializer.rb
+++ b/app/serializers/post_serializer.rb
@@ -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?
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 1db21152703..5f9055905fa 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -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 in your profile."
+ reminders:
+ at_desktop: "Next time I'm at my desktop"
+ later_today: "Later today
{{date}}"
+ next_business_day: "Next business day
{{date}}"
+ tomorrow: "Tomorrow
{{date}}"
+ next_week: "Next week
{{date}}"
+ next_month: "Next month
{{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… "
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 74d947869d1..2f1cf679582 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -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
{{date}}"
+ next_business_day: "Next business day
{{date}}"
+ tomorrow: "Tomorrow
{{date}}"
+ next_week: "Next week
{{date}}"
+ next_month: "Next month
{{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"
diff --git a/config/routes.rb b/config/routes.rb
index 0edafdffa75..60a82ae4760 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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'
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 53a864d27a1..a39d2bbd651 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -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
diff --git a/db/migrate/20191205100434_create_standalone_bookmarks_table.rb b/db/migrate/20191205100434_create_standalone_bookmarks_table.rb
new file mode 100644
index 00000000000..74c636a8815
--- /dev/null
+++ b/db/migrate/20191205100434_create_standalone_bookmarks_table.rb
@@ -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
diff --git a/lib/topic_view.rb b/lib/topic_view.rb
index b95254d14ae..4e6a8f597d2 100644
--- a/lib/topic_view.rb
+++ b/lib/topic_view.rb
@@ -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?
diff --git a/spec/fabricators/bookmark_fabricator.rb b/spec/fabricators/bookmark_fabricator.rb
new file mode 100644
index 00000000000..3234b622808
--- /dev/null
+++ b/spec/fabricators/bookmark_fabricator.rb
@@ -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
diff --git a/spec/requests/bookmarks_controller_spec.rb b/spec/requests/bookmarks_controller_spec.rb
new file mode 100644
index 00000000000..64e23960f5d
--- /dev/null
+++ b/spec/requests/bookmarks_controller_spec.rb
@@ -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
diff --git a/spec/serializers/post_serializer_spec.rb b/spec/serializers/post_serializer_spec.rb
index 1032909aad3..fe30193d30f 100644
--- a/spec/serializers/post_serializer_spec.rb
+++ b/spec/serializers/post_serializer_spec.rb
@@ -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
diff --git a/test/javascripts/controllers/bookmark-test.js.es6 b/test/javascripts/controllers/bookmark-test.js.es6
new file mode 100644
index 00000000000..acb9085f69f
--- /dev/null
+++ b/test/javascripts/controllers/bookmark-test.js.es6
@@ -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"
+ );
+ }
+);
diff --git a/test/javascripts/fixtures/session-fixtures.js.es6 b/test/javascripts/fixtures/session-fixtures.js.es6
index c4184c13fea..090bf099366 100644
--- a/test/javascripts/fixtures/session-fixtures.js.es6
+++ b/test/javascripts/fixtures/session-fixtures.js.es6
@@ -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"
}
}
};