diff --git a/app/assets/javascripts/discourse/app/components/relative-date.gjs b/app/assets/javascripts/discourse/app/components/relative-date.gjs new file mode 100644 index 00000000000..7f7770ea8c5 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/relative-date.gjs @@ -0,0 +1,32 @@ +import Component from "@glimmer/component"; +import { longDate, relativeAge } from "discourse/lib/formatter"; + +export default class RelativeDate extends Component { + get datetime() { + if (this.memoizedDatetime) { + return this.memoizedDatetime; + } + + this.memoizedDatetime = new Date(this.args.date); + return this.memoizedDatetime; + } + + get title() { + return longDate(this.datetime); + } + + get time() { + return this.datetime.getTime(); + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/user-menu/menu-item.hbs b/app/assets/javascripts/discourse/app/components/user-menu/menu-item.hbs index bd3ca7038c4..5c7c25ac939 100644 --- a/app/assets/javascripts/discourse/app/components/user-menu/menu-item.hbs +++ b/app/assets/javascripts/discourse/app/components/user-menu/menu-item.hbs @@ -24,5 +24,11 @@ {{/if}} + + + {{#if this.endComponent}} + + {{/if}} + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/user-menu/menu-item.js b/app/assets/javascripts/discourse/app/components/user-menu/menu-item.js index b6a728fc70d..b8481864f7d 100644 --- a/app/assets/javascripts/discourse/app/components/user-menu/menu-item.js +++ b/app/assets/javascripts/discourse/app/components/user-menu/menu-item.js @@ -57,6 +57,10 @@ export default class UserMenuItem extends Component { return this.#item.iconComponentArgs; } + get endComponent() { + return this.#item.endComponent; + } + get #item() { return this.args.item; } diff --git a/app/assets/javascripts/discourse/app/components/user-notifications-large.js b/app/assets/javascripts/discourse/app/components/user-notifications-large.js deleted file mode 100644 index a12f68bad67..00000000000 --- a/app/assets/javascripts/discourse/app/components/user-notifications-large.js +++ /dev/null @@ -1,23 +0,0 @@ -import MountWidget from "discourse/components/mount-widget"; -import { observes } from "discourse-common/utils/decorators"; - -export default MountWidget.extend({ - widget: "user-notifications-large", - notifications: null, - args: null, - - init() { - this._super(...arguments); - - this.args = { notifications: this.notifications }; - }, - - @observes("notifications.length", "notifications.@each.read") - _triggerRefresh() { - this.set("args", { - notifications: this.notifications, - }); - - this.queueRerender(); - }, -}); diff --git a/app/assets/javascripts/discourse/app/controllers/user-notifications.gjs b/app/assets/javascripts/discourse/app/controllers/user-notifications.gjs new file mode 100644 index 00000000000..aef0a45b3eb --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/user-notifications.gjs @@ -0,0 +1,107 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import DismissNotificationConfirmationModal from "discourse/components/modal/dismiss-notification-confirmation"; +import RelativeDate from "discourse/components/relative-date"; +import { ajax } from "discourse/lib/ajax"; +import UserMenuNotificationItem from "discourse/lib/user-menu/notification-item"; +import getURL from "discourse-common/lib/get-url"; +import { iconHTML } from "discourse-common/lib/icon-library"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "discourse-i18n"; + +export default class UserNotificationsController extends Controller { + @service modal; + @service appEvents; + @service currentUser; + @service site; + @service siteSettings; + + queryParams = ["filter"]; + filter = "all"; + + get listContainerClassNames() { + return `user-notifications-list ${ + this.siteSettings.show_user_menu_avatars ? "show-avatars" : "" + }`; + } + + @discourseComputed("filter") + isFiltered() { + return this.filter && this.filter !== "all"; + } + + @discourseComputed("model.content.@each") + items() { + return this.model.map((notification) => { + const props = { + appEvents: this.appEvents, + currentUser: this.currentUser, + siteSettings: this.siteSettings, + site: this.site, + notification, + endComponent: , + }; + return new UserMenuNotificationItem(props); + }); + } + + @discourseComputed("model.content.@each.read") + allNotificationsRead() { + return !this.get("model.content").some( + (notification) => !notification.get("read") + ); + } + + @discourseComputed("isFiltered", "model.content.length") + doesNotHaveNotifications(isFiltered, contentLength) { + return !isFiltered && contentLength === 0; + } + + @discourseComputed("isFiltered", "model.content.length") + nothingFound(isFiltered, contentLength) { + return isFiltered && contentLength === 0; + } + + @discourseComputed() + emptyStateBody() { + return htmlSafe( + I18n.t("user.no_notifications_page_body", { + preferencesUrl: getURL("/my/preferences/notifications"), + icon: iconHTML("bell"), + }) + ); + } + + async markRead() { + await ajax("/notifications/mark-read", { type: "PUT" }); + this.model.forEach((notification) => notification.set("read", true)); + } + + @action + async resetNew() { + if (this.currentUser.unread_high_priority_notifications > 0) { + this.modal.show(DismissNotificationConfirmationModal, { + model: { + confirmationMessage: I18n.t( + "notifications.dismiss_confirmation.body.default", + { + count: this.currentUser.unread_high_priority_notifications, + } + ), + dismissNotifications: () => this.markRead(), + }, + }); + } else { + this.markRead(); + } + } + + @action + loadMore() { + this.model.loadMore(); + } +} diff --git a/app/assets/javascripts/discourse/app/controllers/user-notifications.js b/app/assets/javascripts/discourse/app/controllers/user-notifications.js deleted file mode 100644 index 13958a47a47..00000000000 --- a/app/assets/javascripts/discourse/app/controllers/user-notifications.js +++ /dev/null @@ -1,76 +0,0 @@ -import Controller from "@ember/controller"; -import { inject as service } from "@ember/service"; -import { htmlSafe } from "@ember/template"; -import DismissNotificationConfirmationModal from "discourse/components/modal/dismiss-notification-confirmation"; -import { ajax } from "discourse/lib/ajax"; -import getURL from "discourse-common/lib/get-url"; -import { iconHTML } from "discourse-common/lib/icon-library"; -import discourseComputed from "discourse-common/utils/decorators"; -import I18n from "discourse-i18n"; - -export default Controller.extend({ - modal: service(), - queryParams: ["filter"], - filter: "all", - - @discourseComputed("filter") - isFiltered() { - return this.filter && this.filter !== "all"; - }, - - @discourseComputed("model.content.@each.read") - allNotificationsRead() { - return !this.get("model.content").some( - (notification) => !notification.get("read") - ); - }, - - @discourseComputed("isFiltered", "model.content.length") - doesNotHaveNotifications(isFiltered, contentLength) { - return !isFiltered && contentLength === 0; - }, - - @discourseComputed("isFiltered", "model.content.length") - nothingFound(isFiltered, contentLength) { - return isFiltered && contentLength === 0; - }, - - @discourseComputed() - emptyStateBody() { - return htmlSafe( - I18n.t("user.no_notifications_page_body", { - preferencesUrl: getURL("/my/preferences/notifications"), - icon: iconHTML("bell"), - }) - ); - }, - - async markRead() { - await ajax("/notifications/mark-read", { type: "PUT" }); - this.model.forEach((n) => n.set("read", true)); - }, - - actions: { - async resetNew() { - if (this.currentUser.unread_high_priority_notifications > 0) { - this.modal.show(DismissNotificationConfirmationModal, { - model: { - confirmationMessage: I18n.t( - "notifications.dismiss_confirmation.body.default", - { - count: this.currentUser.unread_high_priority_notifications, - } - ), - dismissNotifications: () => this.markRead(), - }, - }); - } else { - this.markRead(); - } - }, - - loadMore() { - this.model.loadMore(); - }, - }, -}); diff --git a/app/assets/javascripts/discourse/app/lib/user-menu/notification-item.js b/app/assets/javascripts/discourse/app/lib/user-menu/notification-item.js index caec6036122..55b299778e4 100644 --- a/app/assets/javascripts/discourse/app/lib/user-menu/notification-item.js +++ b/app/assets/javascripts/discourse/app/lib/user-menu/notification-item.js @@ -5,11 +5,19 @@ import UserMenuBaseItem from "discourse/lib/user-menu/base-item"; import getURL from "discourse-common/lib/get-url"; export default class UserMenuNotificationItem extends UserMenuBaseItem { - constructor({ notification, appEvents, currentUser, siteSettings, site }) { + constructor({ + notification, + endComponent, + appEvents, + currentUser, + siteSettings, + site, + }) { super(...arguments); this.appEvents = appEvents; - this.notification = notification; this.currentUser = currentUser; + this.endComponent = endComponent; + this.notification = notification; this.siteSettings = siteSettings; this.site = site; diff --git a/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs b/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs index a342f041a09..a70cd4372fa 100644 --- a/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs @@ -22,7 +22,11 @@ {{#if this.nothingFound}}
{{i18n "notifications.empty"}}
{{else}} - - +
+ {{#each this.items as |item|}} + + {{/each}} + +
{{/if}} {{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/widgets/admin-problems-notification-item.js b/app/assets/javascripts/discourse/app/widgets/admin-problems-notification-item.js deleted file mode 100644 index 97b006fb6dc..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/admin-problems-notification-item.js +++ /dev/null @@ -1,19 +0,0 @@ -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import getURL from "discourse-common/lib/get-url"; -import { iconNode } from "discourse-common/lib/icon-library"; -import I18n from "discourse-i18n"; - -createWidgetFrom(DefaultNotificationItem, "admin-problems-notification-item", { - text() { - return I18n.t("notifications.admin_problems"); - }, - - url() { - return getURL("/admin"); - }, - - icon() { - return iconNode("gift"); - }, -}); diff --git a/app/assets/javascripts/discourse/app/widgets/bookmark-reminder-notification-item.js b/app/assets/javascripts/discourse/app/widgets/bookmark-reminder-notification-item.js deleted file mode 100644 index 1d50d21b91b..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/bookmark-reminder-notification-item.js +++ /dev/null @@ -1,34 +0,0 @@ -import { formatUsername } from "discourse/lib/utilities"; -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import I18n from "discourse-i18n"; - -createWidgetFrom( - DefaultNotificationItem, - "bookmark-reminder-notification-item", - { - text(notificationName, data) { - const username = formatUsername(data.display_username); - const description = this.description(data); - - return I18n.t("notifications.bookmark_reminder", { - description, - username, - }); - }, - - notificationTitle(notificationName, data) { - if (notificationName) { - if (data.bookmark_name) { - return I18n.t(`notifications.titles.${notificationName}_with_name`, { - name: data.bookmark_name, - }); - } else { - return I18n.t(`notifications.titles.${notificationName}`); - } - } else { - return ""; - } - }, - } -); diff --git a/app/assets/javascripts/discourse/app/widgets/custom-notification-item.js b/app/assets/javascripts/discourse/app/widgets/custom-notification-item.js deleted file mode 100644 index 531710d5977..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/custom-notification-item.js +++ /dev/null @@ -1,22 +0,0 @@ -import { formatUsername } from "discourse/lib/utilities"; -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import { iconNode } from "discourse-common/lib/icon-library"; -import I18n from "discourse-i18n"; - -createWidgetFrom(DefaultNotificationItem, "custom-notification-item", { - notificationTitle(notificationName, data) { - return data.title ? I18n.t(data.title) : ""; - }, - - text(notificationName, data) { - const username = formatUsername(data.display_username); - const description = this.description(data); - - return I18n.t(data.message, { description, username }); - }, - - icon(notificationName, data) { - return iconNode(`notification.${data.message}`); - }, -}); diff --git a/app/assets/javascripts/discourse/app/widgets/group-message-summary-notification-item.js b/app/assets/javascripts/discourse/app/widgets/group-message-summary-notification-item.js deleted file mode 100644 index f02b0cc45f7..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/group-message-summary-notification-item.js +++ /dev/null @@ -1,19 +0,0 @@ -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import I18n from "discourse-i18n"; - -createWidgetFrom( - DefaultNotificationItem, - "group-message-summary-notification-item", - { - text(notificationName, data) { - const count = data.inbox_count; - const group_name = data.group_name; - - return I18n.t("notifications.group_message_summary", { - count, - group_name, - }); - }, - } -); diff --git a/app/assets/javascripts/discourse/app/widgets/invitee-accepted-notification-item.js b/app/assets/javascripts/discourse/app/widgets/invitee-accepted-notification-item.js deleted file mode 100644 index d405a84c779..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/invitee-accepted-notification-item.js +++ /dev/null @@ -1,13 +0,0 @@ -import { userPath } from "discourse/lib/url"; -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; - -createWidgetFrom( - DefaultNotificationItem, - "invitee-accepted-notification-item", - { - url(data) { - return userPath(data.display_username); - }, - } -); diff --git a/app/assets/javascripts/discourse/app/widgets/liked-consolidated-notification-item.js b/app/assets/javascripts/discourse/app/widgets/liked-consolidated-notification-item.js deleted file mode 100644 index 5d42987ea83..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/liked-consolidated-notification-item.js +++ /dev/null @@ -1,31 +0,0 @@ -import { isEmpty } from "@ember/utils"; -import { userPath } from "discourse/lib/url"; -import { escapeExpression } from "discourse/lib/utilities"; -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import I18n from "discourse-i18n"; - -createWidgetFrom( - DefaultNotificationItem, - "liked-consolidated-notification-item", - { - url(data) { - return userPath( - `${ - this.attrs.username || this.currentUser.username - }/notifications/likes-received?acting_username=${data.display_username}` - ); - }, - - description(data) { - const description = I18n.t( - "notifications.liked_consolidated_description", - { - count: parseInt(data.count, 10), - } - ); - - return isEmpty(description) ? "" : escapeExpression(description); - }, - } -); diff --git a/app/assets/javascripts/discourse/app/widgets/liked-notification-item.js b/app/assets/javascripts/discourse/app/widgets/liked-notification-item.js deleted file mode 100644 index fdcb2337493..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/liked-notification-item.js +++ /dev/null @@ -1,32 +0,0 @@ -import { formatUsername } from "discourse/lib/utilities"; -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import I18n from "discourse-i18n"; - -createWidgetFrom(DefaultNotificationItem, "liked-notification-item", { - text(notificationName, data) { - const username = formatUsername(data.display_username); - const description = this.description(data); - - if (data.count > 1) { - const count = data.count - 1; - const username2 = formatUsername(data.username2); - - if (count === 0) { - return I18n.t("notifications.liked_2", { - description, - username: `${username}`, - username2: `${username2}`, - }); - } else { - return I18n.t("notifications.liked_many", { - description, - username: `${username}`, - count, - }); - } - } - - return I18n.t("notifications.liked", { description, username }); - }, -}); diff --git a/app/assets/javascripts/discourse/app/widgets/membership-request-accepted-notification-item.js b/app/assets/javascripts/discourse/app/widgets/membership-request-accepted-notification-item.js deleted file mode 100644 index 973db4bacf8..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/membership-request-accepted-notification-item.js +++ /dev/null @@ -1,20 +0,0 @@ -import { groupPath } from "discourse/lib/url"; -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import I18n from "discourse-i18n"; - -createWidgetFrom( - DefaultNotificationItem, - "membership-request-accepted-notification-item", - { - url(data) { - return groupPath(data.group_name); - }, - - text(notificationName, data) { - return I18n.t(`notifications.${notificationName}`, { - group_name: data.group_name, - }); - }, - } -); diff --git a/app/assets/javascripts/discourse/app/widgets/membership-request-consolidated-notification-item.js b/app/assets/javascripts/discourse/app/widgets/membership-request-consolidated-notification-item.js deleted file mode 100644 index 2af849fa98e..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/membership-request-consolidated-notification-item.js +++ /dev/null @@ -1,23 +0,0 @@ -import { userPath } from "discourse/lib/url"; -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import I18n from "discourse-i18n"; - -createWidgetFrom( - DefaultNotificationItem, - "membership-request-consolidated-notification-item", - { - url() { - return userPath( - `${this.attrs.username || this.currentUser.username}/messages` - ); - }, - - text(notificationName, data) { - return I18n.t("notifications.membership_request_consolidated", { - group_name: data.group_name, - count: parseInt(data.count, 10), - }); - }, - } -); diff --git a/app/assets/javascripts/discourse/app/widgets/new-features-notification-item.js b/app/assets/javascripts/discourse/app/widgets/new-features-notification-item.js deleted file mode 100644 index 2eb74e2b835..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/new-features-notification-item.js +++ /dev/null @@ -1,19 +0,0 @@ -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import getURL from "discourse-common/lib/get-url"; -import { iconNode } from "discourse-common/lib/icon-library"; -import I18n from "discourse-i18n"; - -createWidgetFrom(DefaultNotificationItem, "new-features-notification-item", { - text() { - return I18n.t("notifications.new_features"); - }, - - url() { - return getURL("/admin"); - }, - - icon() { - return iconNode("gift"); - }, -}); diff --git a/app/assets/javascripts/discourse/app/widgets/user-notifications-large.js b/app/assets/javascripts/discourse/app/widgets/user-notifications-large.js deleted file mode 100644 index 115871c256c..00000000000 --- a/app/assets/javascripts/discourse/app/widgets/user-notifications-large.js +++ /dev/null @@ -1,48 +0,0 @@ -import { dasherize } from "@ember/string"; -import { h } from "virtual-dom"; -import { dateNode } from "discourse/helpers/node"; -import { createWidget } from "discourse/widgets/widget"; - -createWidget("large-notification-item", { - tagName: "li", - - buildClasses(attrs) { - const result = ["item", "notification", "large-notification"]; - if (!attrs.get("read")) { - result.push("unread"); - } - return result; - }, - - html(attrs) { - const notificationName = - this.site.notificationLookup[attrs.notification_type]; - - return [ - this.attach( - `${dasherize(notificationName)}-notification-item`, - attrs, - {}, - { - fallbackWidgetName: "default-notification-item", - tagName: "div", - } - ), - h("span.time", dateNode(attrs.created_at)), - ]; - }, -}); - -export default createWidget("user-notifications-large", { - tagName: "ul.notifications.large-notifications", - - html(attrs) { - const notifications = attrs.notifications; - const username = notifications.findArgs.username; - - return notifications.map((n) => { - n.username = username; - return this.attach("large-notification-item", n); - }); - }, -}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/notifications-filter-test.js b/app/assets/javascripts/discourse/tests/acceptance/notifications-filter-test.js deleted file mode 100644 index a23aa200a51..00000000000 --- a/app/assets/javascripts/discourse/tests/acceptance/notifications-filter-test.js +++ /dev/null @@ -1,34 +0,0 @@ -import { visit } from "@ember/test-helpers"; -import { test } from "qunit"; -import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; -import selectKit from "discourse/tests/helpers/select-kit-helper"; - -acceptance("Notifications filter", function (needs) { - needs.user(); - - test("Notifications filter true", async function (assert) { - await visit("/u/eviltrout/notifications"); - - assert.ok(exists(".large-notification")); - }); - - test("Notifications filter read", async function (assert) { - await visit("/u/eviltrout/notifications"); - - const dropdown = selectKit(".notifications-filter"); - await dropdown.expand(); - await dropdown.selectRowByValue("read"); - - assert.ok(exists(".large-notification")); - }); - - test("Notifications filter unread", async function (assert) { - await visit("/u/eviltrout/notifications"); - - const dropdown = selectKit(".notifications-filter"); - await dropdown.expand(); - await dropdown.selectRowByValue("unread"); - - assert.ok(exists(".large-notification")); - }); -}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-test.js index 4d28c77012b..cfb5bc63af9 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-test.js @@ -78,12 +78,10 @@ acceptance("User Routes", function (needs) { "has the body class" ); - const $links = queryAll(".item.notification a"); + const $links = queryAll(".notification a"); assert.ok( - $links[2].href.includes( - "/u/eviltrout/notifications/likes-received?acting_username=aquaman" - ) + $links[2].href.includes("/u/eviltrout/notifications/likes-received") ); updateCurrentUser({ moderator: true, admin: false }); diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index ed90ad22c2e..debdc020969 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -163,40 +163,6 @@ justify-content: space-between; box-sizing: border-box; min-width: 0; // makes sure menu tabs don't go off screen - - .double-user, - .multi-user { - white-space: unset; - } - - .item-label { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - color: var(--primary); - } - - li { - background-color: var(--secondary); - - &.unread, - &.pending { - background-color: var(--tertiary-low); - } - - &:hover { - background-color: var(--d-hover); - outline: none; - } - - &:focus-within { - background: var(--d-hover); - a { - // we don't need the link focus because we're styling the parent - outline: 0; - } - } - } } #quick-access-profile { @@ -380,185 +346,218 @@ } } -.user-menu { - .quick-access-panel { - width: 100%; +// Panel / user-notification-list styles. **not** menu panel sizing styles +.user-menu .quick-access-panel, +.user-notifications-list { + width: 100%; + display: flex; + flex-direction: column; + min-height: 0; + max-height: 100%; + border-top: 1px solid var(--primary-low); + padding-top: 0.75em; + margin-top: -1px; + + &:focus { + outline: none; + } + + .double-user, + .multi-user { + white-space: unset; + } + + .item-label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--primary); + } + + h3 { + padding: 0 0.4em; + font-weight: bold; + margin: 0.5em 0; + } + + .d-icon, + &:hover .d-icon { + color: var(--primary-medium); + } + .icon { + color: var(--primary-high); + } + + .btn-primary { + .d-icon { + color: var(--secondary); + } + } + + ul { display: flex; - flex-direction: column; - min-height: 0; + flex-flow: column wrap; + overflow: hidden; max-height: 100%; - border-top: 1px solid var(--primary-low); - padding-top: 0.75em; - margin-top: -1px; - &:focus { + } + + li { + background-color: var(--secondary); + box-sizing: border-box; + list-style-type: none; + + &.unread, + &.pending { + background-color: var(--tertiary-low); + } + &:hover { + background-color: var(--d-hover); outline: none; } - h3 { - padding: 0 0.4em; - font-weight: bold; - margin: 0.5em 0; - } - .d-icon, - &:hover .d-icon { - color: var(--primary-medium); - } - .icon { - color: var(--primary-high); - } - - .btn-primary { - .d-icon { - color: var(--secondary); + &:focus-within { + background: var(--d-hover); + a { + // we don't need the link focus because we're styling the parent + outline: 0; } } - ul { + // This is until other languages remove the HTML from within + // notifications. It can then be removed + div .fa { + display: none; + } + + span.double-user, + // e.g., "username, username2" + span.multi-user + // e.g., "username and n others" + { + display: inline; + max-width: 100%; + align-items: baseline; + white-space: nowrap; + } + + span.multi-user + // e.g., "username, username2, and n others" + { + span.multi-username:nth-of-type(2) { + // margin between username2 and "and n others" + margin-right: 0.25em; + } + } + + // truncate when usernames are very long + span.multi-username { + @include ellipsis; + flex: 0 1 auto; + min-width: 1.2em; + max-width: 10em; + &:nth-of-type(2) { + // margin for comma between username and username2 + margin-left: 0.25em; + } + } + + &:hover { + background-color: var(--d-hover); + outline: none; + } + + &:focus-within { + background: var(--d-hover); + a { + // we don't need the link focus because we're styling the parent + outline: 0; + } + .btn-flat:focus { + // undo default btn-flat style + background: transparent; + } + } + + a, + .profile-tab-btn { display: flex; - flex-flow: column wrap; + margin: 0.25em; + padding: 0em 0.25em; + } + + button { + padding: 0.25em 0.5em; + } + + a, + button { + > div { + overflow: hidden; // clears the text from wrapping below icons + overflow-wrap: anywhere; + @supports not (overflow-wrap: anywhere) { + word-break: break-word; + } + + // Truncate items with more than 2 lines. + @include line-clamp(2); + } + } + + p { + margin: 0; overflow: hidden; - max-height: 100%; } - - li { - background-color: var(--d-selected); - box-sizing: border-box; - list-style-type: none; - - // This is until other languages remove the HTML from within - // notifications. It can then be removed - div .fa { - display: none; - } - - span.double-user, - // e.g., "username, username2" - span.multi-user - // e.g., "username and n others" - { - display: inline; - max-width: 100%; - align-items: baseline; - white-space: nowrap; - } - - span.multi-user - // e.g., "username, username2, and n others" - { - span.multi-username:nth-of-type(2) { - // margin between username2 and "and n others" - margin-right: 0.25em; - } - } - - // truncate when usernames are very long - span.multi-username { - @include ellipsis; - flex: 0 1 auto; - min-width: 1.2em; - max-width: 10em; - &:nth-of-type(2) { - // margin for comma between username and username2 - margin-left: 0.25em; - } - } - - &:hover { - background-color: var(--d-hover); - outline: none; - } - - &:focus-within { - background: var(--d-hover); - a { - // we don't need the link focus because we're styling the parent - outline: 0; - } - .btn-flat:focus { - // undo default btn-flat style - background: transparent; - } - } - - a, - .profile-tab-btn { - display: flex; - margin: 0.25em; - padding: 0em 0.25em; - } - - button { - padding: 0.25em 0.5em; - } - - a, - button { - > div { - overflow: hidden; // clears the text from wrapping below icons - overflow-wrap: anywhere; - @supports not (overflow-wrap: anywhere) { - word-break: break-word; - } - - // Truncate items with more than 2 lines. - @include line-clamp(2); - } - } - - p { - margin: 0; - overflow: hidden; - } - } - li:not(.show-all) { - padding: 0; - align-self: flex-start; - width: 100%; - - .d-icon { - padding-top: 0.2em; - margin-right: 0.5em; - } - } - .is-warning { - .d-icon-envelope { - color: var(--danger); - } - } - .read { - background-color: var(--secondary); - } - .none { - padding-top: 5px; - } - .spinner-container { - min-height: 2em; - } - .spinner { - width: 20px; - height: 20px; - border-width: 2px; - margin: 0 auto; - } - .show-all a { - width: 100%; - display: flex; - justify-content: center; - align-items: center; - min-height: 30px; - color: var(--primary-med-or-secondary-high); - background: var(--blend-primary-secondary-5); - &:hover { - color: var(--primary); - background: var(--primary-low); - } - } - /* as a big ol' click target, don't let text inside be selected */ - @include unselectable; } + li:not(.show-all) { + padding: 0; + align-self: flex-start; + width: 100%; + + .d-icon { + padding-top: 0.2em; + margin-right: 0.5em; + } + } + .is-warning { + .d-icon-envelope { + color: var(--danger); + } + } + .read { + background-color: var(--secondary); + } + .none { + padding-top: 5px; + } + .spinner-container { + min-height: 2em; + } + .spinner { + width: 20px; + height: 20px; + border-width: 2px; + margin: 0 auto; + } + .show-all a { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + min-height: 30px; + color: var(--primary-med-or-secondary-high); + background: var(--blend-primary-secondary-5); + &:hover { + color: var(--primary); + background: var(--primary-low); + } + } + /* as a big ol' click target, don't let text inside be selected */ + @include unselectable; } -.user-menu.show-avatars { +// Styles to have user avatar positioned and sized correctly +.user-menu.show-avatars, +.user-notifications-list.show-avatars { li { a { .icon-avatar { diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index ccd0be6f304..83e15c83eb1 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -765,38 +765,6 @@ } } -.large-notifications { - margin: 0; -} - -.large-notification { - display: flex; - align-items: center; - - a { - display: flex; - align-items: center; - - .d-icon { - margin-right: 0.5em; - } - - span:first-child { - color: var(--primary); - } - - // Can remove this once other languages have removed html from i18n values - div { - .fa { - display: none; - } - p { - margin: 0; - } - } - } -} - .second-factor { .second-factor-item { width: 100%; diff --git a/app/assets/stylesheets/common/components/user-stream-item.scss b/app/assets/stylesheets/common/components/user-stream-item.scss index a1fef7daa2c..461462cdc74 100644 --- a/app/assets/stylesheets/common/components/user-stream-item.scss +++ b/app/assets/stylesheets/common/components/user-stream-item.scss @@ -46,7 +46,7 @@ color: var(--primary); } - .time, + .relative-date, .delete-info, .draft-type { line-height: var(--line-height-small); @@ -69,9 +69,32 @@ } } - .notification .time { - margin-left: auto; - float: none; + .user-notifications-list { + padding-top: 0; + + li.notification { + padding: 0.25em 0; + border-bottom: 1px solid var(--primary-low); + + a { + align-items: center; + } + .relative-date { + margin-left: auto; + padding-top: 0; + float: none; + } + } + &:not(.show-avatars) { + li.notification { + padding: 0.75em 0; + + .d-icon { + padding-top: 0; + font-size: var(--font-up-2); + } + } + } } .expand-item, @@ -102,27 +125,6 @@ float: right; } - .notification { - li { - display: inline-block; - } - - p { - display: inline-block; - - span:first-child { - color: var(--primary); - } - } - - // common/base/header.scss - .fa, - .icon { - color: var(--primary-med-or-secondary-med); - font-size: var(--font-up-4); - } - } - .excerpt { margin: 1em 0 0 0; font-size: var(--font-0); diff --git a/app/assets/stylesheets/desktop/components/_index.scss b/app/assets/stylesheets/desktop/components/_index.scss index 72479eca716..83b8bf56112 100644 --- a/app/assets/stylesheets/desktop/components/_index.scss +++ b/app/assets/stylesheets/desktop/components/_index.scss @@ -2,4 +2,3 @@ @import "sidebar/edit-navigation-menu/tags-modal"; @import "user-card"; @import "user-info"; -@import "user-stream-item"; diff --git a/app/assets/stylesheets/desktop/components/user-stream-item.scss b/app/assets/stylesheets/desktop/components/user-stream-item.scss deleted file mode 100644 index d094f02a64b..00000000000 --- a/app/assets/stylesheets/desktop/components/user-stream-item.scss +++ /dev/null @@ -1,8 +0,0 @@ -// Desktop styles for "user-stream-item" component -.user-stream { - .notification { - &.unread { - background-color: var(--tertiary-low); - } - } -} diff --git a/app/assets/stylesheets/mobile/components/user-stream-item.scss b/app/assets/stylesheets/mobile/components/user-stream-item.scss index 676c8258227..7a941286971 100644 --- a/app/assets/stylesheets/mobile/components/user-stream-item.scss +++ b/app/assets/stylesheets/mobile/components/user-stream-item.scss @@ -4,12 +4,6 @@ vertical-align: middle; } - .notification { - &.unread { - background-color: var(--tertiary-low); - } - } - .group-member-info { .name { vertical-align: inherit; diff --git a/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js b/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js deleted file mode 100644 index 74c71ea5bb4..00000000000 --- a/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js +++ /dev/null @@ -1,50 +0,0 @@ -import { h } from "virtual-dom"; -import { formatUsername } from "discourse/lib/utilities"; -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import RawHtml from "discourse/widgets/raw-html"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import { iconNode } from "discourse-common/lib/icon-library"; -import I18n from "discourse-i18n"; -import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; - -createWidgetFrom(DefaultNotificationItem, "chat-invitation-notification-item", { - services: ["chat", "router"], - - text(data) { - const username = formatUsername(data.invited_by_username); - return I18n.t("notifications.chat_invitation_html", { username }); - }, - - html(attrs) { - const notificationType = attrs.notification_type; - const lookup = this.site.get("notificationLookup"); - const notificationName = lookup[notificationType]; - const { data } = attrs; - const text = this.text(data); - const title = this.notificationTitle(notificationName, data); - const html = new RawHtml({ html: `
${text}
` }); - const contents = [iconNode("link"), html]; - const href = this.url(data); - - return h( - "a", - { attributes: { title, href, "data-auto-route": true } }, - contents - ); - }, - - url(data) { - const slug = slugifyChannel({ - title: data.chat_channel_title, - slug: data.chat_channel_slug, - }); - - let url = `/chat/c/${slug || "-"}/${data.chat_channel_id}`; - - if (data.chat_message_id) { - url += `/${data.chat_message_id}`; - } - - return url; - }, -}); diff --git a/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js b/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js deleted file mode 100644 index f7d0db7dbfb..00000000000 --- a/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js +++ /dev/null @@ -1,72 +0,0 @@ -import { h } from "virtual-dom"; -import { formatUsername } from "discourse/lib/utilities"; -import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; -import RawHtml from "discourse/widgets/raw-html"; -import { createWidgetFrom } from "discourse/widgets/widget"; -import { iconNode } from "discourse-common/lib/icon-library"; -import I18n from "discourse-i18n"; -import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; - -const chatNotificationItem = { - services: ["chat", "router"], - - text(notificationName, data) { - const username = formatUsername(data.mentioned_by_username); - const identifier = data.identifier ? `@${data.identifier}` : null; - const i18nPrefix = data.is_direct_message_channel - ? "notifications.popup.direct_message_chat_mention" - : "notifications.popup.chat_mention"; - const i18nSuffix = identifier ? "other_html" : "direct_html"; - - return I18n.t(`${i18nPrefix}.${i18nSuffix}`, { - username, - identifier, - channel: data.chat_channel_title, - }); - }, - - html(attrs) { - const notificationType = attrs.notification_type; - const lookup = this.site.get("notificationLookup"); - const notificationName = lookup[notificationType]; - const { data } = attrs; - const title = this.notificationTitle(notificationName, data); - const text = this.text(notificationName, data); - const html = new RawHtml({ html: `
${text}
` }); - const contents = [iconNode("d-chat"), html]; - const href = this.url(data); - - return h( - "a", - { attributes: { title, href, "data-auto-route": true } }, - contents - ); - }, - - url(data) { - const slug = slugifyChannel({ - title: data.chat_channel_title, - slug: data.chat_channel_slug, - }); - - let notificationRoute = `/chat/c/${slug || "-"}/${data.chat_channel_id}`; - if (data.chat_thread_id) { - notificationRoute += `/t/${data.chat_thread_id}`; - } else { - notificationRoute += `/${data.chat_message_id}`; - } - - return notificationRoute; - }, -}; - -createWidgetFrom( - DefaultNotificationItem, - "chat-mention-notification-item", - chatNotificationItem -); -createWidgetFrom( - DefaultNotificationItem, - "chat-group-mention-notification-item", - chatNotificationItem -); diff --git a/plugins/chat/test/javascripts/widgets/chat-invitation-notification-item-test.js b/plugins/chat/test/javascripts/widgets/chat-invitation-notification-item-test.js deleted file mode 100644 index 5d0948ab683..00000000000 --- a/plugins/chat/test/javascripts/widgets/chat-invitation-notification-item-test.js +++ /dev/null @@ -1,52 +0,0 @@ -import { render } from "@ember/test-helpers"; -import hbs from "htmlbars-inline-precompile"; -import { module, test } from "qunit"; -import Notification from "discourse/models/notification"; -import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types"; -import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { query } from "discourse/tests/helpers/qunit-helpers"; -import { deepMerge } from "discourse-common/lib/object"; -import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; - -function getNotification(overrides = {}) { - return Notification.create( - deepMerge( - { - id: 11, - notification_type: NOTIFICATION_TYPES.chat_invitation, - read: false, - data: { - message: "chat.invitation_notification", - invited_by_username: "eviltrout", - chat_channel_id: 9, - chat_message_id: 2, - chat_channel_title: "Site", - }, - }, - overrides - ) - ); -} - -module( - "Discourse Chat | Widget | chat-invitation-notification-item", - function (hooks) { - setupRenderingTest(hooks); - - test("notification url", async function (assert) { - this.set("args", getNotification()); - - await render( - hbs`` - ); - - const data = this.args.data; - assert.strictEqual( - query(".chat-invitation a").getAttribute("href"), - `/chat/c/${slugifyChannel({ - title: data.chat_channel_title, - })}/${data.chat_channel_id}/${data.chat_message_id}` - ); - }); - } -); diff --git a/plugins/chat/test/javascripts/widgets/chat-mention-notification-item-test.js b/plugins/chat/test/javascripts/widgets/chat-mention-notification-item-test.js deleted file mode 100644 index 0912cd0cac4..00000000000 --- a/plugins/chat/test/javascripts/widgets/chat-mention-notification-item-test.js +++ /dev/null @@ -1,139 +0,0 @@ -import { render } from "@ember/test-helpers"; -import hbs from "htmlbars-inline-precompile"; -import { module, test } from "qunit"; -import Notification from "discourse/models/notification"; -import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types"; -import { setupRenderingTest } from "discourse/tests/helpers/component-test"; -import { query } from "discourse/tests/helpers/qunit-helpers"; -import { deepMerge } from "discourse-common/lib/object"; -import I18n from "discourse-i18n"; -import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; - -function getNotification(overrides = {}) { - return Notification.create( - deepMerge( - { - id: 11, - notification_type: NOTIFICATION_TYPES.chat_invitation, - read: false, - data: { - message: "chat.mention_notification", - mentioned_by_username: "eviltrout", - chat_channel_id: 9, - chat_message_id: 2, - chat_channel_title: "Site", - }, - }, - overrides - ) - ); -} - -module( - "Discourse Chat | Widget | chat-mention-notification-item", - function (hooks) { - setupRenderingTest(hooks); - - test("generated link", async function (assert) { - this.set("args", getNotification()); - const data = this.args.data; - await render( - hbs`` - ); - - assert.strictEqual( - query(".chat-invitation a div").innerHTML.trim(), - I18n.t("notifications.popup.chat_mention.direct_html", { - username: "eviltrout", - identifier: null, - channel: "Site", - }) - ); - - assert.strictEqual( - query(".chat-invitation a").getAttribute("href"), - `/chat/c/${slugifyChannel({ - title: data.chat_channel_title, - })}/${data.chat_channel_id}/${data.chat_message_id}` - ); - }); - } -); - -module( - "Discourse Chat | Widget | chat-group-mention-notification-item", - function (hooks) { - setupRenderingTest(hooks); - - test("generated link", async function (assert) { - this.set( - "args", - getNotification({ - data: { - mentioned_by_username: "eviltrout", - identifier: "moderators", - }, - }) - ); - const data = this.args.data; - await render( - hbs`` - ); - - assert.strictEqual( - query(".chat-invitation a div").innerHTML.trim(), - I18n.t("notifications.popup.chat_mention.other_html", { - username: "eviltrout", - identifier: "@moderators", - channel: "Site", - }) - ); - - assert.strictEqual( - query(".chat-invitation a").getAttribute("href"), - `/chat/c/${slugifyChannel({ - title: data.chat_channel_title, - })}/${data.chat_channel_id}/${data.chat_message_id}` - ); - }); - } -); - -module( - "Discourse Chat | Widget | chat-group-mention-notification-item (@all)", - function (hooks) { - setupRenderingTest(hooks); - - test("generated link", async function (assert) { - this.set( - "args", - getNotification({ - data: { - mentioned_by_username: "eviltrout", - identifier: "all", - }, - }) - ); - const data = this.args.data; - await render( - hbs`` - ); - - assert.strictEqual( - query(".chat-invitation a div").innerHTML.trim(), - I18n.t("notifications.popup.chat_mention.other_html", { - username: "eviltrout", - identifier: "@all", - channel: "Site", - }) - ); - - assert.strictEqual( - query(".chat-invitation a").getAttribute("href"), - `/chat/c/${slugifyChannel({ - title: data.chat_channel_title, - })}/${data.chat_channel_id}/${data.chat_message_id}` - ); - }); - } -); diff --git a/spec/system/page_objects/pages/user_notifications.rb b/spec/system/page_objects/pages/user_notifications.rb new file mode 100644 index 00000000000..4500c05ff0f --- /dev/null +++ b/spec/system/page_objects/pages/user_notifications.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class UserNotifications < PageObjects::Pages::Base + def visit(user) + page.visit("/u/#{user.username}/notifications") + self + end + + def filter_dropdown + PageObjects::Components::SelectKit.new(".notifications-filter") + end + + def set_filter_value(value) + filter_dropdown.select_row_by_value(value) + end + + def has_selected_filter_value?(value) + expect(filter_dropdown).to have_selected_value(value) + end + + def has_notification?(notification) + page.has_css?(".notification a[href='#{notification.url}']") + end + + def has_no_notification?(notification) + page.has_no_css?(".notification a[href='#{notification.url}']") + end + end + end +end diff --git a/spec/system/user_page/user_notifications_spec.rb b/spec/system/user_page/user_notifications_spec.rb new file mode 100644 index 00000000000..4a2d5d59be0 --- /dev/null +++ b/spec/system/user_page/user_notifications_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +describe "User notifications", type: :system do + fab!(:user) + let(:user_notifications_page) { PageObjects::Pages::UserNotifications.new } + + fab!(:read_notification) { Fabricate(:notification, user: user, read: true) } + fab!(:unread_notification) { Fabricate(:notification, user: user, read: false) } + + before { sign_in(user) } + + describe "filtering" do + it "saves custom picture and system assigned pictures" do + user_notifications_page.visit(user) + user_notifications_page.filter_dropdown + expect(user_notifications_page).to have_selected_filter_value("all") + expect(user_notifications_page).to have_notification(read_notification) + expect(user_notifications_page).to have_notification(unread_notification) + + user_notifications_page.set_filter_value("read") + + expect(user_notifications_page).to have_notification(read_notification) + expect(user_notifications_page).to have_no_notification(unread_notification) + + user_notifications_page.set_filter_value("unread") + + expect(user_notifications_page).to have_no_notification(read_notification) + expect(user_notifications_page).to have_notification(unread_notification) + end + end +end