From d17425cbb843eef005dd868471ca2e8adfc5f96e Mon Sep 17 00:00:00 2001 From: Rimian Perkins Date: Mon, 9 Sep 2019 11:27:41 +1000 Subject: [PATCH 001/126] FIX: Migrate post_edit_time_limit to tl2_post_edit_time_limit (#8082) If a user amended edit_time_limit keep the behavior as is, instead of introducing a potentially tighter time for tl2 edit time than what they had set in the past. --- ...0908234054_migrate_post_edit_time_limit.rb | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 db/migrate/20190908234054_migrate_post_edit_time_limit.rb diff --git a/db/migrate/20190908234054_migrate_post_edit_time_limit.rb b/db/migrate/20190908234054_migrate_post_edit_time_limit.rb new file mode 100644 index 0000000000..cd84b391f9 --- /dev/null +++ b/db/migrate/20190908234054_migrate_post_edit_time_limit.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class MigratePostEditTimeLimit < ActiveRecord::Migration[5.2] + def up + execute <<~SQL + INSERT INTO site_settings ( + name, + value, + data_type, + created_at, + updated_at + ) + SELECT + 'tl2_post_edit_time_limit', + value, + data_type, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM site_settings + WHERE + name = 'post_edit_time_limit' + ON CONFLICT + DO NOTHING + SQL + end +end From 7d5f3c1338f04ad3e4f6b51196231645464366f4 Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Sun, 8 Sep 2019 22:29:15 -0300 Subject: [PATCH 002/126] UX/PERF: Update readers count when a post from another user is read. Don't fetch the post data again just to update the count. (#8078) --- .../discourse/controllers/topic.js.es6 | 15 +++++---------- .../discourse/models/post-stream.js.es6 | 13 +++++++++++++ app/controllers/post_readers_controller.rb | 2 +- app/models/post.rb | 5 +++++ app/models/topic_tracking_state.rb | 7 ++++--- app/serializers/post_serializer.rb | 7 ------- spec/requests/post_readers_controller_spec.rb | 9 --------- 7 files changed, 28 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 7a7996e627..fbfdc0f486 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -1352,17 +1352,12 @@ export default Ember.Controller.extend(bufferedProperty("model"), { }) .then(() => refresh({ id: data.id, refreshLikes: true })); break; - case "read": + case "read": { postStream - .triggerChangedPost(data.id, data.updated_at, { - preserveCooked: true - }) - .then(() => - refresh({ - id: data.id, - refreshReaders: topic.show_read_indicator - }) - ); + .triggerReadPost(data.id, data.readers_count) + .then(() => refresh({ id: data.id, refreshLikes: true })); + break; + } case "revised": case "rebaked": { postStream diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 51e536965e..346226b9eb 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -716,6 +716,19 @@ export default RestModel.extend({ return resolved; }, + triggerReadPost(postId, readersCount) { + const resolved = Ember.RSVP.Promise.resolve(); + resolved.then(() => { + const post = this.findLoadedPost(postId); + if (post && readersCount > post.readers_count) { + post.set("readers_count", readersCount); + this.storePost(post); + } + }); + + return resolved; + }, + postForPostNumber(postNumber) { if (!this.hasPosts) { return; diff --git a/app/controllers/post_readers_controller.rb b/app/controllers/post_readers_controller.rb index a5fd76fafc..392f575c9b 100644 --- a/app/controllers/post_readers_controller.rb +++ b/app/controllers/post_readers_controller.rb @@ -12,7 +12,7 @@ class PostReadersController < ApplicationController .joins(:topic_users) .where.not(topic_users: { last_read_post_number: nil }) .where('topic_users.topic_id = ? AND topic_users.last_read_post_number >= ?', post.topic_id, post.post_number) - .where.not(id: [current_user.id, post.user_id]) + .where.not(id: post.user_id) readers = readers.map do |r| { diff --git a/app/models/post.rb b/app/models/post.rb index 56d96b0c33..c22d5aae6c 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -170,6 +170,11 @@ class Post < ActiveRecord::Base end end + def readers_count + read_count = reads - 1 # Excludes poster + read_count < 0 ? 0 : read_count + end + def publish_change_to_clients!(type, opts = {}) # special failsafe for posts missing topics consistency checks should fix, # but message is safe to skip diff --git a/app/models/topic_tracking_state.rb b/app/models/topic_tracking_state.rb index 3ed182ba21..cec4fa99e1 100644 --- a/app/models/topic_tracking_state.rb +++ b/app/models/topic_tracking_state.rb @@ -357,7 +357,7 @@ SQL if topic&.private_message? groups = read_allowed_groups_of(topic) post = Post.find_by(topic_id: topic.id, post_number: last_read_post_number) - trigger_post_read_count_update(post, groups) + trigger_post_read_count_update(post, groups, last_read_post_number, user_id) update_topic_list_read_indicator(topic, groups, last_read_post_number, user_id, false) end end @@ -389,8 +389,9 @@ SQL MessageBus.publish("/private-messages/unread-indicator/#{topic.id}", message, user_ids: groups_to_update.flat_map(&:members)) end - def self.trigger_post_read_count_update(post, groups) + def self.trigger_post_read_count_update(post, groups, last_read_post_number, user_id) return if groups.empty? - post.publish_change_to_clients!(:read) + opts = { readers_count: post.readers_count, reader_id: user_id } + post.publish_change_to_clients!(:read, opts) end end diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index d0bd771a2a..0e2a7b3072 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -459,13 +459,6 @@ class PostSerializer < BasicPostSerializer can_review_topic? end - def readers_count - read_count = object.reads - 1 # Exclude logged user - read_count -= 1 unless yours - - read_count < 0 ? 0 : read_count - end - private def can_review_topic? diff --git a/spec/requests/post_readers_controller_spec.rb b/spec/requests/post_readers_controller_spec.rb index d5d60c8ed2..267dfea110 100644 --- a/spec/requests/post_readers_controller_spec.rb +++ b/spec/requests/post_readers_controller_spec.rb @@ -57,15 +57,6 @@ describe PostReadersController do expect(readers).to be_empty end - it "doesn't include current_user in the readers list" do - TopicUser.create!(user: admin, topic: @group_message, last_read_post_number: 3) - - get '/post_readers.json', params: { id: @post.id } - reader = JSON.parse(response.body)['post_readers'].detect { |r| r['username'] == admin.username } - - expect(reader).to be_nil - end - it "doesn't include users without reading progress on first post" do @post.update(post_number: 1) TopicUser.create!(user: reader, topic: @group_message, last_read_post_number: nil) From 1d73754e8442bad3eea0173023230cc913668bfc Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Mon, 9 Sep 2019 17:38:37 +1000 Subject: [PATCH 003/126] FIX: Modify frozen String and profile_db_generator uses category id (#8080) --- script/profile_db_generator.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/profile_db_generator.rb b/script/profile_db_generator.rb index 0c9d64a45d..d356a0b690 100644 --- a/script/profile_db_generator.rb +++ b/script/profile_db_generator.rb @@ -35,7 +35,7 @@ def sentence gabbler.learn(story) end - sentence = "" + sentence = +"" until sentence.length > 800 do sentence << @gabbler.sentence sentence << "\n" @@ -104,7 +104,7 @@ puts puts "Creating 100 topics" topic_ids = 100.times.map do - post = PostCreator.create(users.sample, raw: sentence, title: sentence[0..50].strip, category: categories.sample.name, skip_validations: true) + post = PostCreator.create(users.sample, raw: sentence, title: sentence[0..50].strip, category: categories.sample.id, skip_validations: true) putc "." post.topic_id From 530356f465c9411f55e3282f16fff4bab52bfaff Mon Sep 17 00:00:00 2001 From: Nick Quaranto Date: Mon, 9 Sep 2019 03:41:37 -0400 Subject: [PATCH 004/126] Use Discourse.getURL for /clicks/track so clicks can be tracked on relative URLs (#8079) --- app/assets/javascripts/discourse/lib/ajax.js.es6 | 2 +- app/assets/javascripts/discourse/lib/click-track.js.es6 | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/lib/ajax.js.es6 b/app/assets/javascripts/discourse/lib/ajax.js.es6 index 1cec5148e7..cafa6e6f70 100644 --- a/app/assets/javascripts/discourse/lib/ajax.js.es6 +++ b/app/assets/javascripts/discourse/lib/ajax.js.es6 @@ -159,7 +159,7 @@ export function ajax() { if ( args.type && args.type.toUpperCase() !== "GET" && - url !== "/clicks/track" && + url !== Discourse.getURL("/clicks/track") && !Discourse.Session.currentProp("csrfToken") ) { promise = new Ember.RSVP.Promise((resolve, reject) => { diff --git a/app/assets/javascripts/discourse/lib/click-track.js.es6 b/app/assets/javascripts/discourse/lib/click-track.js.es6 index 438915c13f..42a65623a6 100644 --- a/app/assets/javascripts/discourse/lib/click-track.js.es6 +++ b/app/assets/javascripts/discourse/lib/click-track.js.es6 @@ -102,9 +102,9 @@ export default { data.append("url", href); data.append("post_id", postId); data.append("topic_id", topicId); - navigator.sendBeacon("/clicks/track", data); + navigator.sendBeacon(Discourse.getURL("/clicks/track"), data); } else { - trackPromise = ajax("/clicks/track", { + trackPromise = ajax(Discourse.getURL("/clicks/track"), { type: "POST", data: { url: href, From 27f7bd427365307ddaca511632a6e21821f7ddc6 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 9 Sep 2019 20:47:29 +0800 Subject: [PATCH 005/126] UX: adjusts RTL composer presence avatar alignment context: https://meta.discourse.org/t/user-is-typing-misaligned-and-mixed-with-reply-choices-with-rtl/127963 This commit adds a temporary fix for presence avatar overlap in the composer for RTL. More context on TMP here: https://meta.discourse.org/t/moving-the-voting-box-to-the-right-side-of-the-screen/126752 --- .../assets/stylesheets/presence.scss | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/plugins/discourse-presence/assets/stylesheets/presence.scss b/plugins/discourse-presence/assets/stylesheets/presence.scss index fdd8de25be..f3b9261a0a 100644 --- a/plugins/discourse-presence/assets/stylesheets/presence.scss +++ b/plugins/discourse-presence/assets/stylesheets/presence.scss @@ -74,3 +74,23 @@ } } } + +// TMP: RTL overrides +.rtl { + span.presence-text { + margin-left: 2px; + margin-right: 5px; + } + + .composer-fields .presence-users { + right: unset; + left: 95px; + } + + &.mobile-view { + .composer-fields .presence-users { + right: unset; + left: 65px; + } + } +} From 9b10a78d8256146bea15ba87df7d596b03f7a8b5 Mon Sep 17 00:00:00 2001 From: Kyle Zhao Date: Mon, 9 Sep 2019 08:03:57 -0700 Subject: [PATCH 006/126] FEATURE: Quick access panels in user menu (#8073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Extract QuickAccessPanel from UserNotifications. * FEATURE: Quick access panels in user menu. This feature adds quick access panels for bookmarks and personal messages. It allows uses to browse recent items directly in the user menu, without being redirected to the full pages. * REFACTOR: Use QuickAccessItem for messages. Reusing `DefaultNotificationItem` feels nice but it actually requires a lot of extra work that is not needed for a quick access item. Also, `DefaultNotificationItem` shows an incorrect tooptip ("unread private message"), and it is not trivial to remove / override that. * Use a plain JS object instead. An Ember object was required when `DefaultNotificationItem` was used. * Prefix instead suffix `_` for private helpers. * Set to null instead of deleting object keys. JavaScript engines can optimize object property access based on the object’s shape. https://mathiasbynens.be/notes/shapes-ics * Change trivial try/catch to one-liners. * Return the promise in case needs to be waited on. * Refactor showAll to a link with href * Store `emptyStatePlaceholderItemText` in state. * Store items in Session singleton instead. We can drop `staleItems` (and `findStaleItems`) altogether. Because `(old) items === staleItems` when switching back to a quick access panel. * Add `limit` parameter to the `user_actions` API. * Explicitly import Session instead. --- .../discourse/components/site-header.js.es6 | 6 + .../widgets/quick-access-bookmarks.js.es6 | 51 ++++++ .../widgets/quick-access-item.js.es6 | 43 +++++ .../widgets/quick-access-messages.js.es6 | 50 ++++++ .../widgets/quick-access-notifications.js.es6 | 55 +++++++ .../widgets/quick-access-panel.js.es6 | 143 ++++++++++++++++ .../widgets/quick-access-profile.js.es6 | 91 +++++++++++ .../discourse/widgets/user-menu.js.es6 | 152 ++++++++++-------- .../widgets/user-notifications.js.es6 | 131 --------------- .../stylesheets/common/base/menu-panel.scss | 89 +++++++--- app/controllers/user_actions_controller.rb | 5 +- config/locales/client.en.yml | 2 +- lib/svg_sprite/svg_sprite.rb | 2 + .../fixtures/private_messages_fixtures.js.es6 | 79 +++++++++ .../javascripts/fixtures/user_fixtures.js.es6 | 2 +- .../helpers/create-pretender.js.es6 | 2 +- test/javascripts/helpers/create-store.js.es6 | 6 + .../javascripts/widgets/user-menu-test.js.es6 | 87 ++++++++-- 18 files changed, 765 insertions(+), 231 deletions(-) create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 create mode 100644 app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 delete mode 100644 app/assets/javascripts/discourse/widgets/user-notifications.js.es6 create mode 100644 test/javascripts/fixtures/private_messages_fixtures.js.es6 diff --git a/app/assets/javascripts/discourse/components/site-header.js.es6 b/app/assets/javascripts/discourse/components/site-header.js.es6 index 99c41d5350..41c71a48e5 100644 --- a/app/assets/javascripts/discourse/components/site-header.js.es6 +++ b/app/assets/javascripts/discourse/components/site-header.js.es6 @@ -362,6 +362,12 @@ export default SiteHeaderComponent; export function headerHeight() { const $header = $("header.d-header"); + + // Header may not exist in tests (e.g. in the user menu component test). + if ($header.length === 0) { + return 0; + } + const headerOffset = $header.offset(); const headerOffsetTop = headerOffset ? headerOffset.top : 0; return parseInt( diff --git a/app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6 new file mode 100644 index 0000000000..ca1642b897 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-bookmarks.js.es6 @@ -0,0 +1,51 @@ +import { h } from "virtual-dom"; +import QuickAccessPanel from "discourse/widgets/quick-access-panel"; +import UserAction from "discourse/models/user-action"; +import { ajax } from "discourse/lib/ajax"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import { postUrl } from "discourse/lib/utilities"; + +const ICON = "bookmark"; + +createWidgetFrom(QuickAccessPanel, "quick-access-bookmarks", { + buildKey: () => "quick-access-bookmarks", + + hasMore() { + // Always show the button to the bookmarks page. + return true; + }, + + showAllHref() { + return `${this.attrs.path}/activity/bookmarks`; + }, + + emptyStatePlaceholderItem() { + return h("li.read", this.state.emptyStatePlaceholderItemText); + }, + + findNewItems() { + return ajax("/user_actions.json", { + cache: "false", + data: { + username: this.currentUser.username, + filter: UserAction.TYPES.bookmarks, + limit: this.estimateItemLimit(), + no_results_help_key: "user_activity.no_bookmarks" + } + }).then(({ user_actions, no_results_help }) => { + // The empty state help text for bookmarks page is localized on the + // server. + this.state.emptyStatePlaceholderItemText = no_results_help; + 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 + }); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 new file mode 100644 index 0000000000..a869484cc7 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 @@ -0,0 +1,43 @@ +import { h } from "virtual-dom"; +import RawHtml from "discourse/widgets/raw-html"; +import { createWidget } from "discourse/widgets/widget"; +import { emojiUnescape } from "discourse/lib/text"; +import { iconNode } from "discourse-common/lib/icon-library"; + +createWidget("quick-access-item", { + tagName: "li", + + buildClasses(attrs) { + const result = []; + if (attrs.className) { + result.push(attrs.className); + } + if (attrs.read === undefined || attrs.read) { + result.push("read"); + } + return result; + }, + + html({ icon, href, content }) { + return h("a", { attributes: { href } }, [ + iconNode(icon), + new RawHtml({ + html: `
${this._usernameHtml()}${emojiUnescape( + Handlebars.Utils.escapeExpression(content) + )}
` + }) + ]); + }, + + click(e) { + this.attrs.read = true; + if (this.attrs.action) { + e.preventDefault(); + return this.sendWidgetAction(this.attrs.action, this.attrs.actionParam); + } + }, + + _usernameHtml() { + return this.attrs.username ? `${this.attrs.username} ` : ""; + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6 new file mode 100644 index 0000000000..9988e649d7 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-messages.js.es6 @@ -0,0 +1,50 @@ +import QuickAccessPanel from "discourse/widgets/quick-access-panel"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import { postUrl } from "discourse/lib/utilities"; + +const ICON = "notification.private_message"; + +function toItem(message) { + const lastReadPostNumber = message.last_read_post_number || 0; + const nextUnreadPostNumber = Math.min( + lastReadPostNumber + 1, + message.highest_post_number + ); + + return { + content: message.fancy_title, + href: postUrl(message.slug, message.id, nextUnreadPostNumber), + icon: ICON, + read: message.last_read_post_number >= message.highest_post_number, + username: message.last_poster_username + }; +} + +createWidgetFrom(QuickAccessPanel, "quick-access-messages", { + buildKey: () => "quick-access-messages", + emptyStatePlaceholderItemKey: "choose_topic.none_found", + + hasMore() { + // Always show the button to the messages page for composing, archiving, + // etc. + return true; + }, + + showAllHref() { + return `${this.attrs.path}/messages`; + }, + + findNewItems() { + return this.store + .findFiltered("topicList", { + filter: `topics/private-messages/${this.currentUser.username_lower}` + }) + .then(({ topic_list }) => { + return topic_list.topics.map(toItem).slice(0, this.estimateItemLimit()); + }); + }, + + itemHtml(message) { + return this.attach("quick-access-item", message); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6 new file mode 100644 index 0000000000..515e702ace --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-notifications.js.es6 @@ -0,0 +1,55 @@ +import { ajax } from "discourse/lib/ajax"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import QuickAccessPanel from "discourse/widgets/quick-access-panel"; + +createWidgetFrom(QuickAccessPanel, "quick-access-notifications", { + buildKey: () => "quick-access-notifications", + emptyStatePlaceholderItemKey: "notifications.empty", + + markReadRequest() { + return ajax("/notifications/mark-read", { method: "PUT" }); + }, + + newItemsLoaded() { + if (!this.currentUser.enforcedSecondFactor) { + this.currentUser.set("unread_notifications", 0); + } + }, + + itemHtml(notification) { + const notificationName = this.site.notificationLookup[ + notification.notification_type + ]; + + return this.attach( + `${notificationName.dasherize()}-notification-item`, + notification, + {}, + { fallbackWidgetName: "default-notification-item" } + ); + }, + + findNewItems() { + return this._findStaleItemsInStore().refresh(); + }, + + showAllHref() { + return `${this.attrs.path}/notifications`; + }, + + hasUnread() { + return this.getItems().filterBy("read", false).length > 0; + }, + + _findStaleItemsInStore() { + return this.store.findStale( + "notification", + { + recent: true, + silent: this.currentUser.enforcedSecondFactor, + limit: this.estimateItemLimit() + }, + { cacheKey: "recent-notifications" } + ); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 new file mode 100644 index 0000000000..e70985fde2 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 @@ -0,0 +1,143 @@ +import Session from "discourse/models/session"; +import { createWidget } from "discourse/widgets/widget"; +import { h } from "virtual-dom"; +import { headerHeight } from "discourse/components/site-header"; + +const AVERAGE_ITEM_HEIGHT = 55; + +/** + * This tries to enforce a consistent flow of fetching, caching, refreshing, + * and rendering for "quick access items". + * + * There are parts to introducing a new quick access panel: + * 1. A user menu link that sends a `quickAccess` action, with a unique `type`. + * 2. A `quick-access-${type}` widget, extended from `quick-access-panel`. + */ +export default createWidget("quick-access-panel", { + tagName: "div.quick-access-panel", + emptyStatePlaceholderItemKey: "", + + buildKey: () => { + throw Error('Cannot attach abstract widget "quick-access-panel".'); + }, + + markReadRequest() { + return Ember.RSVP.Promise.resolve(); + }, + + hasUnread() { + return false; + }, + + showAllHref() { + return ""; + }, + + hasMore() { + return this.getItems().length >= this.estimateItemLimit(); + }, + + findNewItems() { + return Ember.RSVP.Promise.resolve([]); + }, + + newItemsLoaded() {}, + + itemHtml(item) {}, // eslint-disable-line no-unused-vars + + emptyStatePlaceholderItem() { + if (this.emptyStatePlaceholderItemKey) { + return h("li.read", I18n.t(this.emptyStatePlaceholderItemKey)); + } else { + return ""; + } + }, + + defaultState() { + return { items: [], loading: false, loaded: false }; + }, + + markRead() { + return this.markReadRequest().then(() => { + this.refreshNotifications(this.state); + }); + }, + + estimateItemLimit() { + // Estimate (poorly) the amount of notifications to return. + let limit = Math.round( + ($(window).height() - headerHeight()) / AVERAGE_ITEM_HEIGHT + ); + + // We REALLY don't want to be asking for negative counts of notifications + // less than 5 is also not that useful. + if (limit < 5) { + limit = 5; + } else if (limit > 40) { + limit = 40; + } + + return limit; + }, + + refreshNotifications(state) { + if (this.loading) { + return; + } + + if (this.getItems().length === 0) { + state.loading = true; + } + + this.findNewItems() + .then(newItems => this.setItems(newItems)) + .catch(() => this.setItems([])) + .finally(() => { + state.loading = false; + state.loaded = true; + this.newItemsLoaded(); + this.sendWidgetAction("itemsLoaded", { + hasUnread: this.hasUnread(), + markRead: () => this.markRead() + }); + this.scheduleRerender(); + }); + }, + + html(attrs, state) { + if (!state.loaded) { + this.refreshNotifications(state); + } + + if (state.loading) { + return [h("div.spinner-container", h("div.spinner"))]; + } + + const items = this.getItems().length + ? this.getItems().map(item => this.itemHtml(item)) + : [this.emptyStatePlaceholderItem()]; + + if (this.hasMore()) { + items.push( + h( + "li.read.last.show-all", + this.attach("link", { + title: "view_all", + icon: "chevron-down", + href: this.showAllHref() + }) + ) + ); + } + + return [h("ul", items)]; + }, + + getItems() { + return Session.currentProp(`${this.key}-items`) || []; + }, + + setItems(newItems) { + Session.currentProp(`${this.key}-items`, newItems); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 new file mode 100644 index 0000000000..b74054e243 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 @@ -0,0 +1,91 @@ +import QuickAccessPanel from "discourse/widgets/quick-access-panel"; +import { createWidgetFrom } from "discourse/widgets/widget"; + +createWidgetFrom(QuickAccessPanel, "quick-access-profile", { + buildKey: () => "quick-access-profile", + + hasMore() { + // Never show the button to the full profile page. + return false; + }, + + findNewItems() { + return Ember.RSVP.Promise.resolve(this._getItems()); + }, + + itemHtml(item) { + return this.attach("quick-access-item", item); + }, + + _getItems() { + const items = this._getDefaultItems(); + if (this._showToggleAnonymousButton()) { + items.push(this._toggleAnonymousButton()); + } + if (this.attrs.showLogoutButton) { + items.push(this._logOutButton()); + } + return items; + }, + + _getDefaultItems() { + return [ + { + icon: "user", + href: `${this.attrs.path}/summary`, + content: I18n.t("user.summary.title") + }, + { + icon: "stream", + href: `${this.attrs.path}/activity`, + content: I18n.t("user.activity_stream") + }, + { + icon: "envelope", + href: `${this.attrs.path}/messages`, + content: I18n.t("user.private_messages") + }, + { + icon: "cog", + href: `${this.attrs.path}/preferences`, + content: I18n.t("user.preferences") + } + ]; + }, + + _toggleAnonymousButton() { + if (this.currentUser.is_anonymous) { + return { + action: "toggleAnonymous", + className: "disable-anonymous", + content: I18n.t("switch_from_anon"), + icon: "ban" + }; + } else { + return { + action: "toggleAnonymous", + className: "enable-anonymous", + content: I18n.t("switch_to_anon"), + icon: "user-secret" + }; + } + }, + + _logOutButton() { + return { + action: "logout", + className: "logout", + content: I18n.t("user.log_out"), + icon: "sign-out-alt" + }; + }, + + _showToggleAnonymousButton() { + return ( + (this.siteSettings.allow_anonymous_posting && + this.currentUser.trust_level >= + this.siteSettings.anonymous_posting_min_trust_level) || + this.currentUser.is_anonymous + ); + } +}); diff --git a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 index a25240db72..96e56fb7ac 100644 --- a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 @@ -3,6 +3,17 @@ import { h } from "virtual-dom"; import { formatUsername } from "discourse/lib/utilities"; import hbs from "discourse/widgets/hbs-compiler"; +const UserMenuAction = { + QUICK_ACCESS: "quickAccess" +}; + +const QuickAccess = { + BOOKMARKS: "bookmarks", + MESSAGES: "messages", + NOTIFICATIONS: "notifications", + PROFILE: "profile" +}; + let extraGlyphs; export function addUserMenuGlyph(glyph) { @@ -15,6 +26,8 @@ createWidget("user-menu-links", { profileLink() { const link = { + action: UserMenuAction.QUICK_ACCESS, + actionParam: QuickAccess.PROFILE, route: "user", model: this.currentUser, className: "user-activity-link", @@ -30,8 +43,21 @@ createWidget("user-menu-links", { return link; }, + notificationsGlyph() { + return { + label: "user.notifications", + className: "user-notifications-link", + icon: "bell", + href: `${this.attrs.path}/notifications`, + action: UserMenuAction.QUICK_ACCESS, + actionParam: QuickAccess.NOTIFICATIONS + }; + }, + bookmarksGlyph() { return { + action: UserMenuAction.QUICK_ACCESS, + actionParam: QuickAccess.BOOKMARKS, label: "user.bookmarks", className: "user-bookmarks-link", icon: "bookmark", @@ -41,6 +67,8 @@ createWidget("user-menu-links", { messagesGlyph() { return { + action: UserMenuAction.QUICK_ACCESS, + actionParam: QuickAccess.MESSAGES, label: "user.private_messages", className: "user-pms-link", icon: "envelope", @@ -49,24 +77,20 @@ createWidget("user-menu-links", { }, linkHtml(link) { + if (this.isActive(link)) { + link = this.markAsActive(link); + } return this.attach("link", link); }, glyphHtml(glyph) { + if (this.isActive(glyph)) { + glyph = this.markAsActive(glyph); + } return this.attach("link", $.extend(glyph, { hideLabel: true })); }, - html(attrs) { - const { currentUser, siteSettings } = this; - - const isAnon = currentUser.is_anonymous; - const allowAnon = - (siteSettings.allow_anonymous_posting && - currentUser.trust_level >= - siteSettings.anonymous_posting_min_trust_level) || - isAnon; - - const path = attrs.path; + html() { const links = [this.profileLink()]; const glyphs = []; @@ -81,42 +105,39 @@ createWidget("user-menu-links", { }); } + glyphs.push(this.notificationsGlyph()); glyphs.push(this.bookmarksGlyph()); - if (siteSettings.enable_personal_messages) { + if (this.siteSettings.enable_personal_messages) { glyphs.push(this.messagesGlyph()); } - if (allowAnon) { - if (!isAnon) { - glyphs.push({ - action: "toggleAnonymous", - label: "switch_to_anon", - className: "enable-anonymous", - icon: "user-secret" - }); - } else { - glyphs.push({ - action: "toggleAnonymous", - label: "switch_from_anon", - className: "disable-anonymous", - icon: "ban" - }); - } - } - - // preferences always goes last - glyphs.push({ - label: "user.preferences", - className: "user-preferences-link", - icon: "cog", - href: `${path}/preferences` - }); - return h("ul.menu-links-row", [ links.map(l => h("li.user", this.linkHtml(l))), h("li.glyphs", glyphs.map(l => this.glyphHtml(l))) ]); + }, + + markAsActive(definition) { + // Clicking on an active quick access tab icon should redirect the user to + // the full page. + definition.action = null; + definition.actionParam = null; + + if (definition.className) { + definition.className += " active"; + } else { + definition.className = "active"; + } + + return definition; + }, + + isActive({ action, actionParam }) { + return ( + action === UserMenuAction.QUICK_ACCESS && + actionParam === this.attrs.currentQuickAccess + ); } }); @@ -148,6 +169,7 @@ export default createWidget("user-menu", { defaultState() { return { + currentQuickAccess: QuickAccess.NOTIFICATIONS, hasUnread: false, markUnread: null }; @@ -155,37 +177,18 @@ export default createWidget("user-menu", { panelContents() { const path = this.currentUser.get("path"); + const { currentQuickAccess } = this.state; - let result = [ - this.attach("user-menu-links", { path }), - this.attach("user-notifications", { path }) + const result = [ + this.attach("user-menu-links", { + path, + currentQuickAccess + }), + this.quickAccessPanel(path) ]; - if (this.settings.showLogoutButton || this.state.hasUnread) { - result.push(h("hr.bottom-area")); - } - - if (this.settings.showLogoutButton) { - result.push( - h("div.logout-link", [ - h( - "ul.menu-links", - h( - "li", - this.attach("link", { - action: "logout", - className: "logout", - icon: "sign-out-alt", - href: "", - label: "user.log_out" - }) - ) - ) - ]) - ); - } - if (this.state.hasUnread) { + result.push(h("hr.bottom-area")); result.push(this.attach("user-menu-dismiss-link")); } @@ -196,8 +199,8 @@ export default createWidget("user-menu", { return this.state.markRead(); }, - notificationsLoaded({ notifications, markRead }) { - this.state.hasUnread = notifications.filterBy("read", false).length > 0; + itemsLoaded({ hasUnread, markRead }) { + this.state.hasUnread = hasUnread; this.state.markRead = markRead; }, @@ -234,5 +237,20 @@ export default createWidget("user-menu", { } else { this.sendWidgetAction("toggleUserMenu"); } + }, + + quickAccess(type) { + if (this.state.currentQuickAccess !== type) { + this.state.currentQuickAccess = type; + } + }, + + quickAccessPanel(path) { + const { showLogoutButton } = this.settings; + // This deliberately does NOT fallback to a default quick access panel. + return this.attach(`quick-access-${this.state.currentQuickAccess}`, { + path, + showLogoutButton + }); } }); diff --git a/app/assets/javascripts/discourse/widgets/user-notifications.js.es6 b/app/assets/javascripts/discourse/widgets/user-notifications.js.es6 deleted file mode 100644 index fb19fb56a7..0000000000 --- a/app/assets/javascripts/discourse/widgets/user-notifications.js.es6 +++ /dev/null @@ -1,131 +0,0 @@ -import { createWidget } from "discourse/widgets/widget"; -import { headerHeight } from "discourse/components/site-header"; -import { h } from "virtual-dom"; -import DiscourseURL from "discourse/lib/url"; -import { ajax } from "discourse/lib/ajax"; - -export default createWidget("user-notifications", { - tagName: "div.notifications", - buildKey: () => "user-notifications", - - defaultState() { - return { notifications: [], loading: false, loaded: false }; - }, - - markRead() { - ajax("/notifications/mark-read", { method: "PUT" }).then(() => { - this.refreshNotifications(this.state); - }); - }, - - refreshNotifications(state) { - if (this.loading) { - return; - } - - // estimate (poorly) the amount of notifications to return - let limit = Math.round(($(window).height() - headerHeight()) / 55); - // we REALLY don't want to be asking for negative counts of notifications - // less than 5 is also not that useful - if (limit < 5) { - limit = 5; - } - if (limit > 40) { - limit = 40; - } - - const silent = this.currentUser.get("enforcedSecondFactor"); - const stale = this.store.findStale( - "notification", - { recent: true, silent, limit }, - { cacheKey: "recent-notifications" } - ); - - if (stale.hasResults) { - const results = stale.results; - let content = results.get("content"); - - // we have to truncate to limit, otherwise we will render too much - if (content && content.length > limit) { - content = content.splice(0, limit); - results.set("content", content); - results.set("totalRows", limit); - } - - state.notifications = results; - } else { - state.loading = true; - } - - stale - .refresh() - .then(notifications => { - if (!silent) { - this.currentUser.set("unread_notifications", 0); - } - state.notifications = notifications; - }) - .catch(() => { - state.notifications = []; - }) - .finally(() => { - state.loading = false; - state.loaded = true; - this.sendWidgetAction("notificationsLoaded", { - notifications: state.notifications, - markRead: () => this.markRead() - }); - this.scheduleRerender(); - }); - }, - - html(attrs, state) { - if (!state.loaded) { - this.refreshNotifications(state); - } - - const result = []; - if (state.loading) { - result.push(h("div.spinner-container", h("div.spinner"))); - } else if (state.notifications.length) { - const notificationItems = state.notifications.map(notificationAttrs => { - const notificationName = this.site.notificationLookup[ - notificationAttrs.notification_type - ]; - - return this.attach( - `${notificationName.dasherize()}-notification-item`, - notificationAttrs, - {}, - { fallbackWidgetName: "default-notification-item" } - ); - }); - - result.push(h("hr")); - - const items = [notificationItems]; - - if (notificationItems.length > 5) { - items.push( - h( - "li.read.last.heading.show-all", - this.attach("button", { - title: "notifications.more", - icon: "chevron-down", - action: "showAllNotifications", - className: "btn" - }) - ) - ); - } - - result.push(h("ul", items)); - } - - return result; - }, - - showAllNotifications() { - DiscourseURL.routeTo(`${this.attrs.path}/notifications`); - } -}); diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index b0b84006d3..44f1d6be7d 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -147,7 +147,7 @@ } .user-menu { - .notifications { + .quick-access-panel { width: 100%; display: table; @@ -187,6 +187,11 @@ padding: 0; > div { overflow: hidden; // clears the text from wrapping below icons + + // Truncate items with more than 2 lines. + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } } @@ -223,9 +228,12 @@ border-width: 2px; margin: 0 auto; } - .show-all .btn { + .show-all a { width: 100%; - padding: 2px 0; + display: flex; + justify-content: center; + align-items: center; + min-height: 30px; color: dark-light-choose($primary-medium, $secondary-high); background: blend-primary-secondary(5%); &:hover { @@ -237,29 +245,24 @@ @include unselectable; } - .logout-link, .dismiss-link { display: inline-block; - } - .dismiss-link { float: right; } } -.notifications .logout { - padding: 0.25em; - &:hover { - background-color: $highlight-medium; - } -} - div.menu-links-header { width: 100%; display: table; border-collapse: separate; border-spacing: 0 0.5em; .menu-links-row { + border-bottom: 1px solid dark-light-choose($primary-low, $secondary-medium); display: flex; + + // Tabs should have "ears". + padding: 0 4px; + li { display: inline-flex; align-items: center; @@ -271,6 +274,42 @@ div.menu-links-header { flex-wrap: wrap; text-align: right; max-width: 65%; //IE11 + + a { + // Expand the click area a bit. + padding-left: 0.6em; + padding-right: 0.6em; + } + } + + a { + // This is to make sure active and inactive tab icons have the same + // size. `box-sizing` does not work and I have no idea why. + border: 1px solid transparent; + border-bottom: 0; + } + + a.active { + border: 1px solid dark-light-choose($primary-low, $secondary-medium); + border-bottom: 0; + position: relative; + + &:after { + display: block; + position: absolute; + top: 100%; + left: 0; + z-index: z("header") + 1; // Higher than .menu-panel + width: 100%; + height: 0; + content: ""; + border-top: 1px solid $secondary; + } + + &:focus, + &:hover { + background-color: inherit; + } } } } @@ -283,12 +322,24 @@ div.menu-links-header { padding: 0.3em 0.5em; } a.user-activity-link { - max-width: 150px; - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + align-items: center; + display: flex; margin: -0.5em 0; + max-width: 130px; + + // `overflow: hidden` on `.user-activity-link` would hide the `::after` + // pseudo element (used to create the tab-looking effect). Sets `overflow: + // hidden` on the child username label instead. + overflow: visible; + + span.d-label { + display: block; + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + @include breakpoint(mobile-medium) { max-width: 125px; } @@ -311,6 +362,6 @@ div.menu-links-header { } .d-icon-user { - margin-right: 0.2em; + margin-right: 0.475em; } } diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb index 489dabd865..2c133273cf 100644 --- a/app/controllers/user_actions_controller.rb +++ b/app/controllers/user_actions_controller.rb @@ -4,19 +4,20 @@ class UserActionsController < ApplicationController def index params.require(:username) - params.permit(:filter, :offset, :acting_username) + params.permit(:filter, :offset, :acting_username, :limit) user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)) raise Discourse::NotFound unless guardian.can_see_profile?(user) offset = [0, params[:offset].to_i].max action_types = (params[:filter] || "").split(",").map(&:to_i) + limit = params.fetch(:limit, 30).to_i opts = { user_id: user.id, user: user, offset: offset, - limit: 30, + limit: limit, action_types: action_types, guardian: guardian, ignore_private_messages: params[:filter] ? false : true, diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2b0eab6e19..bc0dcbb0e0 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1726,7 +1726,6 @@ en: title: "notifications of @name mentions, replies to your posts and topics, messages, etc" none: "Unable to load notifications at this time." empty: "No notifications found." - more: "view older notifications" post_approved: "Your post was approved" reviewable_items: "items requiring review" mentioned: "{{username}} {{description}}" @@ -1892,6 +1891,7 @@ en: go_back: "go back" not_logged_in_user: "user page with summary of current activity and preferences" current_user: "go to your user page" + view_all: "view all" topics: new_messages_marker: "last visit" diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index 6bdc6d2653..3630efc0c5 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -26,6 +26,7 @@ module SvgSprite "ban", "bars", "bed", + "bell", "bell-slash", "bold", "book", @@ -167,6 +168,7 @@ module SvgSprite "signal", "step-backward", "step-forward", + "stream", "sync", "table", "tag", diff --git a/test/javascripts/fixtures/private_messages_fixtures.js.es6 b/test/javascripts/fixtures/private_messages_fixtures.js.es6 new file mode 100644 index 0000000000..e6e22c502a --- /dev/null +++ b/test/javascripts/fixtures/private_messages_fixtures.js.es6 @@ -0,0 +1,79 @@ +/*jshint maxlen:10000000 */ +export default { + "/topics/private-messages/eviltrout.json": { + users: [ + { + id: 19, + username: "eviltrout", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png" + }, + { + id: 13, + username: "mixtape", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/34f0e0/{size}.png" + } + ], + primary_groups: [], + topic_list: { + can_create_topic: true, + draft: null, + draft_key: "new_topic", + draft_sequence: 33, + per_page: 30, + topics: [ + { + id: 174, + title: "BUG: Can not render emoji properly :/", + fancy_title: "BUG: Can not render emoji properly :confused:", + slug: "bug-can-not-render-emoji-properly", + posts_count: 1, + reply_count: 0, + highest_post_number: 2, + image_url: null, + created_at: "2019-07-26T01:29:24.008Z", + last_posted_at: "2019-07-26T01:29:24.177Z", + bumped: true, + bumped_at: "2019-07-26T01:29:24.177Z", + unseen: false, + last_read_post_number: 2, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + notification_level: 3, + bookmarked: false, + liked: false, + views: 5, + like_count: 0, + has_summary: false, + archetype: "private_message", + last_poster_username: "mixtape", + category_id: null, + pinned_globally: false, + featured_link: null, + posters: [ + { + extras: "latest single", + description: "Original Poster, Most Recent Poster", + user_id: 13, + primary_group_id: null + } + ], + participants: [ + { + extras: "latest", + description: null, + user_id: 13, + primary_group_id: null + } + ] + } + ] + } + } +}; diff --git a/test/javascripts/fixtures/user_fixtures.js.es6 b/test/javascripts/fixtures/user_fixtures.js.es6 index e68bb101eb..c801b5d355 100644 --- a/test/javascripts/fixtures/user_fixtures.js.es6 +++ b/test/javascripts/fixtures/user_fixtures.js.es6 @@ -302,7 +302,7 @@ export default { acting_username: "Abhishek_Gupta", acting_name: "Abhishek Gupta", acting_user_id: 8021, - title: "How to check the user level via ajax?", + title: "How to check the user level via ajax? :/", deleted: false, hidden: false, moderator_action: false, diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index ea7a4cd71d..9d229a654c 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -139,7 +139,7 @@ export default function() { }); this.get("/topics/private-messages/eviltrout.json", () => { - return response({ topic_list: { topics: [] } }); + return response(fixturesByUrl["/topics/private-messages/eviltrout.json"]); }); this.get("/topics/feature_stats.json", () => { diff --git a/test/javascripts/helpers/create-store.js.es6 b/test/javascripts/helpers/create-store.js.es6 index dfb5a1b4e2..f0c55cd386 100644 --- a/test/javascripts/helpers/create-store.js.es6 +++ b/test/javascripts/helpers/create-store.js.es6 @@ -1,6 +1,7 @@ import Store from "discourse/models/store"; import RestAdapter from "discourse/adapters/rest"; import KeyValueStore from "discourse/lib/key-value-store"; +import TopicListAdapter from "discourse/adapters/topic-list"; import TopicTrackingState from "discourse/models/topic-tracking-state"; import { buildResolver } from "discourse-common/resolver"; @@ -16,6 +17,11 @@ export default function() { } return this._restAdapter; } + if (type === "adapter:topicList") { + this._topicListAdapter = + this._topicListAdapter || TopicListAdapter.create({ owner: this }); + return this._topicListAdapter; + } if (type === "key-value-store:main") { this._kvs = this._kvs || new KeyValueStore(); return this._kvs; diff --git a/test/javascripts/widgets/user-menu-test.js.es6 b/test/javascripts/widgets/user-menu-test.js.es6 index 66abb7cc87..35d73a8542 100644 --- a/test/javascripts/widgets/user-menu-test.js.es6 +++ b/test/javascripts/widgets/user-menu-test.js.es6 @@ -1,3 +1,4 @@ +import DiscourseURL from "discourse/lib/url"; import { moduleForWidget, widgetTest } from "helpers/widget-test"; moduleForWidget("user-menu"); @@ -8,9 +9,9 @@ widgetTest("basics", { test(assert) { assert.ok(find(".user-menu").length); assert.ok(find(".user-activity-link").length); + assert.ok(find(".user-notifications-link").length); assert.ok(find(".user-bookmarks-link").length); - assert.ok(find(".user-preferences-link").length); - assert.ok(find(".notifications").length); + assert.ok(find(".quick-access-panel").length); assert.ok(find(".dismiss-link").length); } }); @@ -18,8 +19,8 @@ widgetTest("basics", { widgetTest("notifications", { template: '{{mount-widget widget="user-menu"}}', - test(assert) { - const $links = find(".notifications li a"); + async test(assert) { + const $links = find(".quick-access-panel li a"); assert.equal($links.length, 5); assert.ok($links[0].href.includes("/t/a-slug/123")); @@ -62,6 +63,13 @@ widgetTest("notifications", { }) ) ); + + const routeToStub = sandbox.stub(DiscourseURL, "routeTo"); + await click(".user-notifications-link"); + assert.ok( + routeToStub.calledWith(find(".user-notifications-link")[0].href), + "a second click should redirect to the full notifications page" + ); } }); @@ -73,6 +81,7 @@ widgetTest("log out", { }, async test(assert) { + await click(".user-activity-link"); assert.ok(find(".logout").length); await click(".logout"); @@ -97,8 +106,63 @@ widgetTest("private messages - enabled", { this.siteSettings.enable_personal_messages = true; }, - test(assert) { - assert.ok(find(".user-pms-link").length); + async test(assert) { + const userPmsLink = find(".user-pms-link")[0]; + assert.ok(userPmsLink); + await click(".user-pms-link"); + + const message = find(".quick-access-panel li a")[0]; + assert.ok(message); + + assert.ok( + message.href.includes("/t/bug-can-not-render-emoji-properly/174/2"), + "should link to the next unread post" + ); + assert.ok( + message.innerHTML.includes("mixtape"), + "should include the last poster's username" + ); + assert.ok( + message.innerHTML.match(//), + "should correctly render emoji in message title" + ); + + const routeToStub = sandbox.stub(DiscourseURL, "routeTo"); + await click(".user-pms-link"); + assert.ok( + routeToStub.calledWith(userPmsLink.href), + "a second click should redirect to the full private messages page" + ); + } +}); + +widgetTest("bookmarks", { + template: '{{mount-widget widget="user-menu"}}', + + async test(assert) { + await click(".user-bookmarks-link"); + + const bookmark = find(".quick-access-panel li a")[0]; + assert.ok(bookmark); + + assert.ok( + bookmark.href.includes("/t/how-to-check-the-user-level-via-ajax/11993") + ); + assert.ok( + bookmark.innerHTML.includes("Abhishek_Gupta"), + "should include the last poster's username" + ); + assert.ok( + bookmark.innerHTML.match(//), + "should correctly render emoji in bookmark title" + ); + + const routeToStub = sandbox.stub(DiscourseURL, "routeTo"); + await click(".user-bookmarks-link"); + assert.ok( + routeToStub.calledWith(find(".user-bookmarks-link")[0].href), + "a second click should redirect to the full bookmarks page" + ); } }); @@ -115,7 +179,9 @@ widgetTest("anonymous", { }, async test(assert) { + await click(".user-activity-link"); assert.ok(find(".enable-anonymous").length); + await click(".enable-anonymous"); assert.ok(this.anonymous); } @@ -128,7 +194,8 @@ widgetTest("anonymous - disabled", { this.siteSettings.allow_anonymous_posting = false; }, - test(assert) { + async test(assert) { + await click(".user-activity-link"); assert.ok(!find(".enable-anonymous").length); } }); @@ -141,12 +208,14 @@ widgetTest("anonymous - switch back", { this.currentUser.setProperties({ is_anonymous: true }); this.siteSettings.allow_anonymous_posting = true; - this.on("toggleAnonymous", () => (this.anonymous = true)); + this.on("toggleAnonymous", () => (this.anonymous = false)); }, async test(assert) { + await click(".user-activity-link"); assert.ok(find(".disable-anonymous").length); + await click(".disable-anonymous"); - assert.ok(this.anonymous); + assert.notOk(this.anonymous); } }); From 972c1acef8d1eca679f7f50fc6affabe265222f2 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Mon, 9 Sep 2019 21:33:53 +0530 Subject: [PATCH 007/126] DEV: better class names for Flexbox --- app/assets/stylesheets/embed.scss | 6 +++--- app/views/embed/topics.html.erb | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/embed.scss b/app/assets/stylesheets/embed.scss index 286570b081..c4c7046463 100644 --- a/app/assets/stylesheets/embed.scss +++ b/app/assets/stylesheets/embed.scss @@ -196,10 +196,10 @@ div.lightbox-wrapper { } } - .topic-details-flex { + .topic-column-wrapper { display: flex; - .topic-details-column-1 { + .topic-column.details-column { flex-direction: column; width: 80%; @@ -225,7 +225,7 @@ div.lightbox-wrapper { } } - .topic-details-column-2 { + .topic-column.featured-image-column { .topic-featured-image img { max-width: 200px; max-height: 100px; diff --git a/app/views/embed/topics.html.erb b/app/views/embed/topics.html.erb index 5a4660165a..2aaf326796 100644 --- a/app/views/embed/topics.html.erb +++ b/app/views/embed/topics.html.erb @@ -4,8 +4,8 @@
<%- if @template == "complete" %>