diff --git a/.github/workflows/feature-screenshots.yml b/.github/workflows/feature-screenshots.yml index 845d219..3b0511c 100644 --- a/.github/workflows/feature-screenshots.yml +++ b/.github/workflows/feature-screenshots.yml @@ -40,6 +40,9 @@ jobs: PARALLEL_TEST_PROCESSORS: 1 LOAD_PLUGINS: 1 CAPYBARA_DEFAULT_MAX_WAIT_TIME: 10 + # Opts the pop-up gallery specs in — they skip themselves in the main + # parallel system_tests job (to keep it lean) and only run here. + JTECH_SCREENSHOT_GALLERY: "1" steps: - name: Set working directory owner @@ -130,7 +133,10 @@ jobs: --format documentation \ plugins/${{ env.PLUGIN_NAME }}/spec/system/feature_screenshots_spec.rb \ plugins/${{ env.PLUGIN_NAME }}/spec/system/review_queue_click_through_spec.rb \ - plugins/${{ env.PLUGIN_NAME }}/spec/system/notifications_type_filter_spec.rb + plugins/${{ env.PLUGIN_NAME }}/spec/system/notifications_type_filter_spec.rb \ + plugins/${{ env.PLUGIN_NAME }}/spec/system/popup_notifications_screenshots_spec.rb \ + plugins/${{ env.PLUGIN_NAME }}/spec/system/popup_notifications_stacking_screenshots_spec.rb \ + plugins/${{ env.PLUGIN_NAME }}/spec/system/popup_notifications_click_through_spec.rb continue-on-error: true - name: Upload feature screenshots (always) diff --git a/README.md b/README.md index ff3884d..1d7c659 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,26 @@ One combined Discourse plugin. Bundles seven previously-separate plugins under a | Dumbcourse | `DiscourseDumbcourse` | `dumbcourse_*` | `dumbcourse_enabled` | | Translator-tweaks | *(patches `DiscourseTranslator`)* | *(none — gated by translator's own settings)* | `translator_enabled` (upstream) | | Smart search | `DiscourseSmartSearch` | `smart_search_*` | `smart_search_enabled` | +| Desktop pop-ups | `DiscoursePopupNotifications` | `popup_notifications_*` | `popup_notifications_enabled` | The bundle is gated by `jtech_enabled`; each sub-plugin is independently gated by its own setting above. +### Desktop pop-up notifications + +A Jelly-style toast card that appears in the top-right corner (just below the header search) when a new notification arrives, modelled on the [Jelly](https://github.com/lubabs770/Jelly) macOS notifier's look and delivery. + +- **Purely additive.** It subscribes to the same `/notification/:user_id` MessageBus channel that already drives the bell counter and the notifications dropdown, and does nothing else — the bell, the dropdown, and read-state are untouched. Turning it off simply stops the card from appearing. +- **Desktop only.** Never mounts on mobile (`site.mobileView`). +- **Opt-in per user, off by default.** Each user turns it on via a **Desktop Pop Up Notifications** On/Off dropdown on their account page (`/u/:username/preferences/account`), stored in the `jtech_popup_notifications_enabled` user custom field. `popup_notifications_default_enabled` (default `false`) controls the default for users who haven't chosen. +- **Card layout:** the acting user's name on top, their avatar on the left, the topic title in bold, then a short preview of their message (fetched from the source post). +- **Interaction:** clicking the card routes to the post (same as clicking the row in the dropdown); clicking anywhere else — or waiting `popup_notifications_timeout_seconds` (default 20) — dismisses it. + +| Setting | Default | Purpose | +| --- | --- | --- | +| `popup_notifications_enabled` | `true` | Master switch. Off ⇒ no card for anyone, per-user preference hidden. | +| `popup_notifications_default_enabled` | `false` | Default for users who haven't set the account-page preference. | +| `popup_notifications_timeout_seconds` | `20` | Seconds the card stays before auto-dismissing. | + ### Mod-categories — staff-event notifications Mod-categories ships a notification fan-out for five staff-event streams in addition to its original topic-level moderator notes. Whenever a moderator performs one of the actions below, every OTHER staff member gets a high-priority bell notification + live MessageBus pop-up alert, AND the event surfaces in the shield-tab user menu alongside topic notes. diff --git a/assets/javascripts/discourse/components/jtech-popup-notification.gjs b/assets/javascripts/discourse/components/jtech-popup-notification.gjs new file mode 100644 index 0000000..54e6ea0 --- /dev/null +++ b/assets/javascripts/discourse/components/jtech-popup-notification.gjs @@ -0,0 +1,344 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { cancel } from "@ember/runloop"; +import { service } from "@ember/service"; +import icon from "discourse/helpers/d-icon"; +import { ajax } from "discourse/lib/ajax"; +import { getURLWithCDN } from "discourse/lib/get-url"; +import discourseLater from "discourse/lib/later"; +import DiscourseURL from "discourse/lib/url"; +import { i18n } from "discourse-i18n"; + +// Desktop-only, Jelly-style pop-up "toast". Purely ADDITIVE — it renders a +// card when a new notification is published on the current user's +// `/notification/:id` MessageBus channel (the same channel that already +// drives the bell counter and the notifications dropdown) and does nothing +// else. Core notifications, the bell, the dropdown, and read-state are all +// untouched; turning the feature off simply stops the cards from appearing. +// +// Multiple notifications STACK one below another: the newest card sits at the +// top-right (just below the header search) and older ones are pushed down, +// each with its own auto-dismiss timer, up to MAX_TOASTS at once. A further +// notification drops the oldest (bottom) card so the newest can take its +// place. Clicking a card opens it (routes like the dropdown row); clicking +// anywhere else dismisses them all. +// +// Card layout: the acting user's avatar on the left (with a small type-icon +// badge on its corner), then a heading line "Name — Action" (e.g. +// "pat — Liked your post"), the topic title in bold, and a short preview of +// the message. +// +// Fires for every notification the user receives, including the plugin's +// own `custom` notifications — moderator whispers, flag notes, and +// queued/pending-post approvals/rejections — decoded via their `data` +// markers below. Never mounts on mobile or for users who have not opted in. +const AVATAR_SIZE = 48; +const EXCERPT_LENGTH = 120; +const STALE_MS = 10000; +const MAX_TOASTS = 3; // stack up to 3; a 4th drops the oldest (bottom) card +const CUSTOM_TYPE = 14; // Notification.types[:custom] + +// notification_type (core enum, stable) → icon + action i18n key suffix. +const CORE_TYPES = { + 1: { icon: "at", action: "mentioned" }, + 2: { icon: "reply", action: "replied" }, + 3: { icon: "quote-right", action: "quoted" }, + 4: { icon: "pencil", action: "edited" }, + 5: { icon: "heart", action: "liked" }, + 6: { icon: "envelope", action: "messaged" }, + 7: { icon: "envelope", action: "messaged" }, + 9: { icon: "reply", action: "posted" }, + 11: { icon: "link", action: "linked" }, + 12: { icon: "certificate", action: "badge" }, + 15: { icon: "at", action: "mentioned" }, + 17: { icon: "reply", action: "posted" }, + 19: { icon: "heart", action: "liked" }, + 20: { icon: "check", action: "post_approved" }, + 25: { icon: "heart", action: "liked" }, +}; + +// This plugin's `custom` notifications, keyed by their `data.mod_note_kind`. +const MOD_NOTE_KINDS = { + post_deleted: { icon: "trash-can", action: "post_deleted" }, + post_approved: { icon: "check", action: "post_approved" }, + post_rejected: { icon: "xmark", action: "post_rejected" }, + user_note: { icon: "shield-halved", action: "user_note" }, + flag_note: { icon: "flag", action: "flag_note" }, + note: { icon: "shield-halved", action: "note" }, +}; + +const FALLBACK = { icon: "bell", action: "default" }; + +export default class JtechPopupNotification extends Component { + @service currentUser; + @service siteSettings; + @service site; + @service messageBus; + + @tracked toasts = []; + + channel = null; + seen = new Set(); + listening = false; + + constructor() { + super(...arguments); + if ( + !this.currentUser || + this.site.mobileView || + !this.siteSettings.popup_notifications_enabled + ) { + return; + } + this.mountedAt = Date.now(); + this.channel = `/notification/${this.currentUser.id}`; + this.onDocumentClick = this.onDocumentClick.bind(this); + this.messageBus.subscribe(this.channel, this.onMessage); + } + + willDestroy() { + super.willDestroy(...arguments); + this.dismissAll(); + if (this.channel) { + this.messageBus.unsubscribe(this.channel, this.onMessage); + } + } + + // Read live so saving the account-page dropdown (which mirrors the value + // onto currentUser) takes effect without a page reload. + get prefEnabled() { + return !!this.currentUser?.jtech_popup_notifications_enabled; + } + + // Icon + action label for a notification. Core types come from the stable + // enum map; our own `custom` notifications are decoded from their data + // markers (whisper, mod-note kinds — which cover flag notes and + // queued/pending-post approvals and rejections). + metaFor(notification) { + const data = notification.data || {}; + if (notification.notification_type === CUSTOM_TYPE) { + if (data.mod_whisper) { + return { icon: "eye", action: "whispered" }; + } + if (data.mod_note) { + return MOD_NOTE_KINDS[data.mod_note_kind] || MOD_NOTE_KINDS.note; + } + return FALLBACK; + } + return CORE_TYPES[notification.notification_type] || FALLBACK; + } + + @action + async onMessage(payload) { + try { + if (!this.prefEnabled) { + return; + } + const notification = payload?.last_notification?.notification; + if (!notification || notification.read) { + return; + } + // Show each notification at most once (guards MessageBus replays and + // re-adds after dismissal). + if (this.seen.has(notification.id)) { + return; + } + // Ignore MessageBus backlog replayed from before this tab mounted. + const createdAt = Date.parse(notification.created_at); + if (createdAt && createdAt < this.mountedAt - STALE_MS) { + return; + } + this.seen.add(notification.id); + await this.present(notification); + } catch { + // A malformed payload must never break the page. + } + } + + async present(notification) { + const data = notification.data || {}; + const meta = this.metaFor(notification); + const toast = { + key: notification.id, + name: + data.display_username || + data.username || + data.original_username || + data.mentioned_by_username || + i18n("jtech_popup_notifications.someone"), + action: i18n(`jtech_popup_notifications.action.${meta.action}`), + icon: meta.icon, + title: notification.fancy_title || data.topic_title || "", + excerpt: data.excerpt || "", + avatarUrl: null, + url: this.urlFor(notification), + timer: null, + }; + + // Enrich with the acting user's avatar + a preview of their message from + // the source post. Best-effort: the card still shows without it (custom + // notifications such as flag notes have no source post — they render the + // type icon on its own instead of an avatar). + try { + const post = await this.fetchPost(notification, data); + if (post) { + if (post.avatar_template) { + toast.avatarUrl = getURLWithCDN( + post.avatar_template.replace("{size}", AVATAR_SIZE) + ); + } + if (!toast.excerpt && post.cooked) { + toast.excerpt = this.excerptFrom(post.cooked); + } + } + } catch { + // ignore enrichment failure — show what we have + } + + // The preference may have flipped off during the await. + if (!this.prefEnabled) { + return; + } + this.addToast(toast); + } + + // Prepend the newest card; drop the oldest beyond the cap. Each card gets + // its own auto-dismiss timer. + addToast(toast) { + const secs = + parseInt(this.siteSettings.popup_notifications_timeout_seconds, 10) || 20; + toast.timer = discourseLater(this, this.dismiss, toast, secs * 1000); + + const next = [toast, ...this.toasts]; + while (next.length > MAX_TOASTS) { + const dropped = next.pop(); + cancel(dropped.timer); + this.seen.delete(dropped.key); + } + this.toasts = next; + + if (!this.listening) { + document.addEventListener("click", this.onDocumentClick, true); + this.listening = true; + } + } + + fetchPost(notification, data) { + if (data.original_post_id) { + return ajax(`/posts/${data.original_post_id}.json`); + } + if (notification.topic_id && notification.post_number) { + return ajax( + `/posts/by_number/${notification.topic_id}/${notification.post_number}.json` + ); + } + return null; + } + + excerptFrom(cooked) { + const el = document.createElement("div"); + el.innerHTML = cooked; + const text = (el.textContent || "").replace(/\s+/g, " ").trim(); + return text.length > EXCERPT_LENGTH + ? `${text.slice(0, EXCERPT_LENGTH)}…` + : text; + } + + urlFor(notification) { + const data = notification.data || {}; + if (notification.topic_id && notification.slug) { + const suffix = notification.post_number + ? `/${notification.post_number}` + : ""; + return `/t/${notification.slug}/${notification.topic_id}${suffix}`; + } + if (data.url) { + return data.url; + } + return `/u/${this.currentUser.username}/notifications`; + } + + stopListening() { + if (this.listening) { + document.removeEventListener("click", this.onDocumentClick, true); + this.listening = false; + } + } + + onDocumentClick(event) { + // A click anywhere outside every card dismisses them all. Clicks on a + // card are handled by `open` (this capture-phase listener only acts when + // the target is outside). + if (!event.target.closest(".jtech-popup-toast")) { + this.dismissAll(); + } + } + + @action + open(toast) { + const url = toast.url; + this.dismiss(toast); + if (url) { + DiscourseURL.routeTo(url); + } + } + + @action + dismiss(toast) { + cancel(toast.timer); + this.toasts = this.toasts.filter((t) => t !== toast); + if (this.toasts.length === 0) { + this.stopListening(); + } + } + + dismissAll() { + this.toasts.forEach((t) => cancel(t.timer)); + this.toasts = []; + this.stopListening(); + } + + +} diff --git a/assets/javascripts/discourse/connectors/user-preferences-account/jtech-desktop-popup-notifications.gjs b/assets/javascripts/discourse/connectors/user-preferences-account/jtech-desktop-popup-notifications.gjs new file mode 100644 index 0000000..3816799 --- /dev/null +++ b/assets/javascripts/discourse/connectors/user-preferences-account/jtech-desktop-popup-notifications.gjs @@ -0,0 +1,78 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { i18n } from "discourse-i18n"; +import ComboBox from "select-kit/components/combo-box"; + +// "Desktop Pop Up Notifications" On/Off dropdown on the account preferences +// page (/u/:username/preferences/account). Rendered into the +// `user-preferences-account` outlet. +// +// It saves immediately on change with a PUT to /u/:username.json (the +// `jtech_popup_notifications_enabled` custom field is registered editable +// server-side), rather than depending on the account form's "Save Changes" +// button — the account controller does not persist arbitrary custom fields, +// and an instant toggle is the expected UX here anyway. The value is also +// mirrored onto the current user so the running toast subscriber honors the +// change without a reload. +// +// Default is OFF: the current-user serializer resolves the effective value +// from `popup_notifications_default_enabled` (false) until the user opts in, +// so the pop-up never surprises the whole forum. +export default class JtechDesktopPopupNotifications extends Component { + @service siteSettings; + @service currentUser; + + get available() { + return this.siteSettings.popup_notifications_enabled; + } + + get enabled() { + return !!this.currentUser?.jtech_popup_notifications_enabled; + } + + get content() { + return [ + { id: true, name: i18n("jtech_popup_notifications.preference.on") }, + { id: false, name: i18n("jtech_popup_notifications.preference.off") }, + ]; + } + + @action + async onChange(value) { + const previous = this.enabled; + // Optimistic + live gate for the running toast subscriber. + this.currentUser.set("jtech_popup_notifications_enabled", value); + try { + await ajax(`/u/${this.currentUser.username}.json`, { + type: "PUT", + data: { custom_fields: { jtech_popup_notifications_enabled: value } }, + }); + } catch (error) { + this.currentUser.set("jtech_popup_notifications_enabled", previous); + popupAjaxError(error); + } + } + + +} diff --git a/assets/javascripts/discourse/initializers/jtech-popup-notifications.js b/assets/javascripts/discourse/initializers/jtech-popup-notifications.js new file mode 100644 index 0000000..5976703 --- /dev/null +++ b/assets/javascripts/discourse/initializers/jtech-popup-notifications.js @@ -0,0 +1,22 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import JtechPopupNotification from "../components/jtech-popup-notification"; + +// Mounts the desktop pop-up notification host once, globally, into the +// always-present `above-footer` outlet. The component itself gates on +// desktop-only + the per-user preference + the site setting, so mounting +// it is cheap and inert when the feature is off. Skipped entirely when the +// master switch is off so no MessageBus subscription is even created. +export default { + name: "jtech-desktop-popup-notifications", + + initialize(container) { + const siteSettings = container.lookup("service:site-settings"); + if (!siteSettings.popup_notifications_enabled) { + return; + } + + withPluginApi("1.0", (api) => { + api.renderInOutlet("above-footer", JtechPopupNotification); + }); + }, +}; diff --git a/assets/stylesheets/popup-notifications.scss b/assets/stylesheets/popup-notifications.scss new file mode 100644 index 0000000..9e9a81e --- /dev/null +++ b/assets/stylesheets/popup-notifications.scss @@ -0,0 +1,147 @@ +// Desktop pop-up notification toast — a Jelly-style card anchored top-right, +// just below the header (and its search icon). Theme-aware: uses the active +// theme's surface colors so it looks native in both light and dark themes. +// +// Rendered by assets/javascripts/discourse/components/jtech-popup-notification.gjs +// into the always-present `above-footer` outlet; fixed positioning lifts it +// out of the footer to the top-right of the viewport. +// Fixed stack anchored top-right, just below the header. Newest card first; +// older cards flow downward. +.jtech-popup-toasts { + position: fixed; + top: calc(var(--header-offset, 60px) + 10px); + right: 14px; + z-index: 1080; + display: flex; + flex-direction: column; + gap: 10px; + max-width: calc(100vw - 28px); +} + +.jtech-popup-toast { + display: flex; + gap: 12px; + align-items: flex-start; + box-sizing: border-box; + width: 360px; + max-width: 100%; + padding: 14px 16px; + background: var(--secondary); + color: var(--primary); + border: 1px solid var(--primary-low); + border-radius: 12px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22); + cursor: pointer; + animation: jtech-popup-toast-in 160ms ease-out; + + &:hover { + border-color: var(--primary-medium); + } + + // Avatar column, with a small type-icon badge pinned to its top-right + // corner. When there is no source post (e.g. a flag note) there is no + // avatar, so the type icon renders on its own at avatar size instead. + &__avatar { + position: relative; + flex: 0 0 auto; + width: 44px; + height: 44px; + + img { + display: block; + width: 44px; + height: 44px; + border-radius: 50%; + } + } + + &__type-badge { + position: absolute; + top: -3px; + right: -3px; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--tertiary); + color: var(--secondary); + border: 2px solid var(--secondary); + + .d-icon { + width: 0.7em; + height: 0.7em; + font-size: 0.7em; + } + } + + &__type-icon { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--tertiary-low); + + .d-icon { + font-size: 1.5em; + color: var(--tertiary); + } + } + + &__body { + min-width: 0; // allow the ellipsis on the title to work inside flex + } + + // Top line: "Name — Action" (e.g. "pat — Liked your post"). + &__heading { + font-size: var(--font-0); + line-height: 1.2; + } + + &__name { + font-weight: 700; + } + + &__action { + color: var(--primary-medium); + } + + // Middle line: topic title, bold. + &__title { + margin-top: 2px; + font-weight: 700; + font-size: var(--font-up-1); + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // Bottom line: a preview of the message, smaller and not bold. + &__excerpt { + margin-top: 3px; + font-weight: 400; + font-size: var(--font-down-1); + line-height: 1.35; + color: var(--primary-high); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } +} + +@keyframes jtech-popup-toast-in { + from { + opacity: 0; + transform: translateY(-8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d1c5b35..717ffbf 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1,5 +1,34 @@ en: js: + jtech_popup_notifications: + someone: Someone + # Second half of the toast heading line, "Name — ". + action: + replied: Replied + mentioned: Mentioned you + quoted: Quoted you + edited: Edited your post + liked: Liked your post + messaged: Messaged you + posted: Posted + linked: Linked your post + badge: Granted you a badge + whispered: Sent you a whisper + post_deleted: Deleted a post + post_approved: Approved a post + post_rejected: Rejected a post + user_note: Added a note on a user + flag_note: Added a note on a flag + note: Added a moderator note + default: Sent you a notification + preference: + title: Desktop Pop Up Notifications + instructions: Show a small pop-up card in the top-right corner when you + receive a new notification. Desktop only — this never appears on mobile. + # NB: "on"/"off" MUST be quoted — YAML parses bare on/off as booleans, + # which would make these i18n keys unreachable. + "on": "On" + "off": "Off" discourse_mod_categories: precheck: title: Before you post diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 4080e3c..bfd2b2e 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -8,6 +8,7 @@ en: jtech_mod: Jtech — Mod jtech_dumbcourse: Jtech — Dumbcourse jtech_smart_search: Jtech — Smart search + jtech_popup_notifications: Jtech — Desktop pop-ups # ── Jtech (master) ───────────────────────────────────────────────────── jtech_enabled: Enable the Jtech plugin bundle @@ -172,6 +173,21 @@ en: extra SQL query; raising this gives broader matches but slower search. Range 1–5. + # ── Jtech — Desktop pop-ups ──────────────────────────────────────────── + popup_notifications_enabled: Enable desktop pop-up notifications + popup_notifications_enabled_description: Master switch for the Jelly-style + pop-up notification card (top-right, desktop only). When off, no card + appears for anyone and the per-user preference is hidden. This is purely + additive — it never changes the bell counter, the notifications dropdown, + or read state. + popup_notifications_default_enabled: Pop-ups on by default + popup_notifications_default_enabled_description: The default for users who + have not chosen on their account page. Off by design, so enabling the + plugin never surprises the whole forum — each user opts in themselves. + popup_notifications_timeout_seconds: Pop-up auto-dismiss (seconds) + popup_notifications_timeout_seconds_description: How long the card stays on + screen before dismissing itself. Clicking anywhere else also dismisses it. + # ── Jtech — Dumbcourse ───────────────────────────────────────────────── dumbcourse_enabled: Enable Dumbcourse dumbcourse_enabled_description: Serves the Dumbcourse SPA under the diff --git a/config/settings.yml b/config/settings.yml index 995689e..0df0f2e 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -182,6 +182,28 @@ jtech_smart_search: min: 1 max: 5 +# ─────────────────────────────────────────────────────────────────────────── +# Jtech — Desktop pop-up notifications (Jelly-style toast, desktop only) +# ─────────────────────────────────────────────────────────────────────────── +jtech_popup_notifications: + # Master switch. When off, no toast fires for anyone and the per-user + # preference on the account page is hidden. + popup_notifications_enabled: + default: true + client: true + # Default for users who have never set the per-user preference. Off by + # design — the toast only appears for users who opt in on their account + # page, so enabling the plugin never surprises the whole forum. + popup_notifications_default_enabled: + default: false + client: true + # Seconds the toast stays on screen before auto-dismissing. + popup_notifications_timeout_seconds: + default: 20 + client: true + min: 3 + max: 300 + # ─────────────────────────────────────────────────────────────────────────── # Jtech — Dumbcourse (lightweight SPA at /dumb with push notifications) # ─────────────────────────────────────────────────────────────────────────── diff --git a/plugin.rb b/plugin.rb index 1bd0005..60f220f 100644 --- a/plugin.rb +++ b/plugin.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # name: jtech-tools -# about: Jtech — combined Discourse plugin (dislike, another-smtp, mini-mod, mod-categories, dumbcourse, translator-tweaks, smart-search) +# about: Jtech — combined Discourse plugin (dislike, another-smtp, mini-mod, mod-categories, dumbcourse, translator-tweaks, smart-search, popup-notifications) # version: 0.1.1 # authors: TripleU, Shalom_Karr, Ars18 # url: https://github.com/JTech-Forums/JtechTools @@ -36,6 +36,7 @@ dumbcourse translator_tweaks smart_search + popup_notifications ].each do |sub| path = File.expand_path("sub_plugins/#{sub}.rb", __dir__) instance_eval(File.read(path), path, 1) diff --git a/spec/requests/popup_notifications_pref_spec.rb b/spec/requests/popup_notifications_pref_spec.rb new file mode 100644 index 0000000..6227acd --- /dev/null +++ b/spec/requests/popup_notifications_pref_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Backend contract for the "Desktop Pop Up Notifications" preference: the +# per-user custom field is editable, off by default, and surfaced on the +# current-user serializer as an effective (default-aware) boolean. This is +# the server side of the additive feature — it changes nothing about how +# core notifications are created or delivered. +RSpec.describe "Desktop pop-up notification preference" do + fab!(:user) + + let(:field) { DiscoursePopupNotifications::USER_ENABLED_FIELD } + + before do + SiteSetting.popup_notifications_enabled = true + SiteSetting.popup_notifications_default_enabled = false + end + + def current_user_json + sign_in(user) + get "/session/current.json" + expect(response.status).to eq(200) + response.parsed_body["current_user"] + end + + it "defaults to the site default (off) when the user has not chosen" do + expect(current_user_json["jtech_popup_notifications_enabled"]).to eq(false) + end + + it "follows the site default when that default is on" do + SiteSetting.popup_notifications_default_enabled = true + expect(current_user_json["jtech_popup_notifications_enabled"]).to eq(true) + end + + it "reflects an explicit per-user opt-in" do + user.custom_fields[field] = true + user.save_custom_fields(true) + expect(current_user_json["jtech_popup_notifications_enabled"]).to eq(true) + end + + it "reflects an explicit per-user opt-out even when the default is on" do + SiteSetting.popup_notifications_default_enabled = true + user.custom_fields[field] = false + user.save_custom_fields(true) + expect(current_user_json["jtech_popup_notifications_enabled"]).to eq(false) + end + + it "persists the field through a preferences update (registered editable)" do + sign_in(user) + put "/u/#{user.username}.json", params: { custom_fields: { field => true } } + expect(response.status).to eq(200) + expect(user.reload.custom_fields[field]).to eq(true) + end +end diff --git a/spec/system/popup_notifications_click_through_spec.rb b/spec/system/popup_notifications_click_through_spec.rb new file mode 100644 index 0000000..8320dd4 --- /dev/null +++ b/spec/system/popup_notifications_click_through_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Click-through coverage: while the user is on one topic, a notification about +# a post in a DIFFERENT topic pops the toast; clicking the card opens that +# other topic at the post (exactly like clicking the row in the notifications +# dropdown) and the card goes away on click. +# +# 30 examples — 5 notification types across 6 fresh target topics each. Batch +# 1 also captures before/after screenshots (toast on the home topic, then the +# opened target) for the gallery. +# +# Each example loads the home topic fresh and publishes one crafted +# notification (retried until it lands) on the `/notification/:id` channel. +RSpec.describe "Desktop pop-up notification click-through" do + fab!(:author) { Fabricate(:user, username: "poster_pat") } + fab!(:recipient) { Fabricate(:user, username: "reader_rhea") } + fab!(:category) { Fabricate(:category, name: "Flip phones") } + fab!(:home_topic) do + Fabricate(:topic, category: category, user: recipient, title: "The thread I am reading") + end + fab!(:home_op) do + Fabricate( + :post, + topic: home_topic, + user: recipient, + raw: "Where I am when the notification arrives.", + ) + end + + let(:user_field) { DiscoursePopupNotifications::USER_ENABLED_FIELD } + let(:id_seq) { [800_000] } + + # Gallery/behavioral spec: runs in the Feature Screenshots workflow (which + # sets this env). Skipped in the main parallel system_tests run so its 30 + # browser navigations do not weigh that job down — core click-through is + # covered by popup_notifications_spec.rb. + before { skip("screenshot-gallery only") unless ENV["JTECH_SCREENSHOT_GALLERY"] } + + before do + SiteSetting.popup_notifications_enabled = true + SiteSetting.popup_notifications_timeout_seconds = 300 + SiteSetting.auto_silence_fast_typers_on_first_post = false + recipient.custom_fields[user_field] = true + recipient.save_custom_fields(true) + sign_in(recipient) + end + + def shot(name) + begin + Timeout.timeout(8) do + sleep 0.1 until page.evaluate_script("Array.from(document.images).every((i) => i.complete)") + end + rescue Timeout::Error + # Capture anyway rather than fail over a slow image. + end + page.save_screenshot("popup_notifications_#{name}.png") + end + + def push(type:, data:, topic_id: nil, post_number: nil, fancy_title: nil, slug: nil) + id_seq[0] += 1 + MessageBus.publish( + "/notification/#{recipient.id}", + { + unread_notifications: 1, + all_unread_notifications_count: 1, + last_notification: { + notification: { + id: id_seq[0], + user_id: recipient.id, + notification_type: Notification.types[type], + read: false, + created_at: Time.zone.now.iso8601, + post_number: post_number, + topic_id: topic_id, + fancy_title: fancy_title, + slug: slug, + data: data, + }, + }, + }, + user_ids: [recipient.id], + ) + end + + def wait_for_toast + 8.times do + yield + return if page.has_css?(".jtech-popup-toast", wait: 1.5) + end + expect(page).to have_css(".jtech-popup-toast", wait: 5) + end + + def visit_home + visit("/t/#{home_topic.slug}/#{home_topic.id}") + expect(page).to have_css("#post_1", wait: 10) + end + + (1..6).each do |batch| + %i[replied mentioned quoted liked private_message].each do |type| + it "clicking a #{type} toast opens its topic and dismisses (batch #{batch})" do + target = + Fabricate( + :topic, + category: category, + user: recipient, + title: "Another topic #{batch} for a #{type} notification", + ) + Fabricate( + :post, + topic: target, + user: recipient, + raw: "The opening post of the other topic.", + ) + target_reply = + Fabricate( + :post, + topic: target, + user: author, + raw: "The reply on the other topic that triggered the #{type} notification.", + ) + + visit_home + + wait_for_toast do + push( + type: type, + topic_id: target.id, + post_number: target_reply.post_number, + slug: target.slug, + fancy_title: target.fancy_title, + data: { + display_username: author.username, + topic_title: target.title, + original_post_id: target_reply.id, + }, + ) + end + + # It pops while we are still on the home topic. + expect(page).to have_current_path(%r{/t/[^/]+/#{home_topic.id}}) + shot("clickthrough_#{type}_01_toast_on_home") if batch == 1 + + find(".jtech-popup-toast").click + + # Clicking opens the OTHER topic at the post, and the card is gone. + expect(page).to have_current_path(%r{/t/[^/]+/#{target.id}}, wait: 10) + expect(page).to have_css("#post_#{target_reply.post_number}", wait: 10) + expect(page).to have_no_css(".jtech-popup-toast") + shot("clickthrough_#{type}_02_opened_target") if batch == 1 + end + end + end +end diff --git a/spec/system/popup_notifications_screenshots_spec.rb b/spec/system/popup_notifications_screenshots_spec.rb new file mode 100644 index 0000000..9b28e23 --- /dev/null +++ b/spec/system/popup_notifications_screenshots_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Screenshot gallery for the desktop pop-up notification feature — 18 shots +# across the two surfaces the user interacts with: +# +# * the account page where the preference is set (shots 01–04), plus the +# admin master switch (05) and the control hidden when the master is off +# (06); and +# * the page where the notification arrives — the toast itself, across +# notification types (07–11), content shapes (12–14), the off state (15), +# and the plugin's own custom types: whisper, flag, pending (16–18). +# +# Each toast shot loads the page fresh and publishes exactly ONE crafted +# notification on the user's `/notification/:id` MessageBus channel (the same +# channel core uses). One publish per fresh page is the reliable path in the +# parallel system-test runner. The real end-to-end delivery is covered by +# spec/system/popup_notifications_spec.rb. +# +# Screenshots land in tmp/capybara/ and are published as the CI artifact. +RSpec.describe "Desktop pop-up notification screenshots" do + fab!(:author) { Fabricate(:user, username: "poster_pat", name: "Pat Poster") } + fab!(:recipient) { Fabricate(:user, username: "reader_rhea") } + fab!(:admin) { Fabricate(:admin, username: "admin_amy") } + fab!(:category) { Fabricate(:category, name: "Flip phones") } + fab!(:topic) do + Fabricate( + :topic, + category: category, + user: recipient, + title: "Might be the next Qin but better", + ) + end + fab!(:op) do + Fabricate(:post, topic: topic, user: recipient, raw: "What do you all think of this phone?") + end + fab!(:reply_post) do + Fabricate( + :post, + topic: topic, + user: author, + raw: + "Excellent screen quality. Supports 4g volte in Israel with excellent cellular reception.", + ) + end + fab!(:long_reply) do + Fabricate( + :post, + topic: topic, + user: author, + raw: + "Honestly this might be the best budget option out there right now — the build feels " \ + "premium, the screen is bright even outdoors, calls are crystal clear on 4G VoLTE, and " \ + "the battery genuinely lasts a day and a half of heavy use without needing a top-up.", + ) + end + fab!(:long_topic) do + Fabricate( + :topic, + category: category, + user: recipient, + title: + "A remarkably and unnecessarily long topic title that should be truncated with an " \ + "ellipsis inside the pop-up card so it never wraps onto a second line", + ) + end + fab!(:long_topic_reply) do + Fabricate(:post, topic: long_topic, user: author, raw: "See the specs I linked above.") + end + + let(:user_field) { DiscoursePopupNotifications::USER_ENABLED_FIELD } + # Mutable memoized counter (avoids an instance variable) so every crafted + # notification gets a fresh id. + let(:id_seq) { [500_000] } + + # Gallery spec: generates screenshots in the Feature Screenshots workflow + # (which sets this env). Skipped in the main parallel system_tests run so it + # does not weigh that job down — core behavior is covered by + # popup_notifications_spec.rb. + before { skip("screenshot-gallery only") unless ENV["JTECH_SCREENSHOT_GALLERY"] } + + before do + SiteSetting.popup_notifications_enabled = true + SiteSetting.popup_notifications_timeout_seconds = 300 # keep the card up long enough to shoot + SiteSetting.auto_silence_fast_typers_on_first_post = false + recipient.custom_fields[user_field] = true + recipient.save_custom_fields(true) + end + + def shot(name) + begin + Timeout.timeout(8) do + sleep 0.1 until page.evaluate_script("Array.from(document.images).every((i) => i.complete)") + end + rescue Timeout::Error + # Capture anyway rather than fail over a slow avatar image. + end + page.save_screenshot("popup_notifications_#{name}.png") + end + + def set_pref(value) + recipient.custom_fields[user_field] = value + recipient.save_custom_fields(true) + end + + # Publish a crafted notification on the recipient's channel. + def push(type:, data:, topic_id: nil, post_number: nil, fancy_title: nil, slug: nil) + id_seq[0] += 1 + payload = { + unread_notifications: 1, + all_unread_notifications_count: 1, + last_notification: { + notification: { + id: id_seq[0], + user_id: recipient.id, + notification_type: Notification.types[type], + read: false, + high_priority: false, + created_at: Time.zone.now.iso8601, + post_number: post_number, + topic_id: topic_id, + fancy_title: fancy_title, + slug: slug, + data: data, + }, + }, + } + MessageBus.publish("/notification/#{recipient.id}", payload, user_ids: [recipient.id]) + end + + # A reply-shaped notification enriched from a real post (avatar + excerpt), + # varying only the type so each screenshot is a distinct kind. + def push_from_post(type:, post:, into: topic) + push( + type: type, + topic_id: into.id, + post_number: post.post_number, + slug: into.slug, + fancy_title: into.fancy_title, + data: { + display_username: post.user.username, + topic_title: into.title, + original_post_id: post.id, + }, + ) + end + + def visit_topic(into = topic) + visit("/t/#{into.slug}/#{into.id}") + expect(page).to have_css("#post_1", wait: 10) + end + + # After a warm page load `#post_1` can appear before the browser's + # MessageBus poll re-establishes its subscription, so a single publish can + # land before the client is listening and be missed. Re-publish (a fresh id + # each time) until the toast — or a specific icon within it — appears. + def wait_for_toast(selector = ".jtech-popup-toast") + 8.times do + yield + return if page.has_css?(selector, wait: 1.5) + end + expect(page).to have_css(selector, wait: 5) + end + + # One shot = one fresh page + a publish (retried until it lands). + def enriched_toast_shot(name, type:, post: reply_post, into: topic) + visit_topic(into) + wait_for_toast { push_from_post(type: type, post: post, into: into) } + shot(name) + end + + def open_account_preference + sign_in(recipient) + visit("/u/#{recipient.username}/preferences/account") + expect(page).to have_css(".jtech-desktop-popup-notifications", wait: 10) + end + + it "captures the account-page preference control (01–04)" do + open_account_preference + shot("01_settings_account_default_off") + + find(".jtech-desktop-popup-notifications .select-kit-header").click + expect(page).to have_css(".select-kit-collection", wait: 5) + shot("02_settings_dropdown_open") + + find(".select-kit-row", text: "On").click + expect(page).to have_css(".jtech-desktop-popup-notifications") + shot("03_settings_set_on") + + find(".jtech-desktop-popup-notifications .select-kit-header").click + find(".select-kit-row", text: "Off").click + shot("04_settings_set_off") + end + + it "captures the admin master switch (05)" do + sign_in(admin) + visit("/admin/site_settings/category/all_results?filter=popup_notifications") + expect(page).to have_css(".setting", wait: 10) + shot("05_settings_admin_master_switch") + end + + it "hides the account control when the master switch is off (06)" do + SiteSetting.popup_notifications_enabled = false + sign_in(recipient) + visit("/u/#{recipient.username}/preferences/account") + expect(page).to have_css(".user-preferences", wait: 10) + expect(page).to have_no_css(".jtech-desktop-popup-notifications") + shot("06_settings_control_hidden_master_off") + end + + it "captures a toast for each notification type (07–11)" do + sign_in(recipient) + enriched_toast_shot("07_toast_reply", type: :replied) + enriched_toast_shot("08_toast_mention", type: :mentioned) + enriched_toast_shot("09_toast_quote", type: :quoted) + enriched_toast_shot("10_toast_private_message", type: :private_message) + enriched_toast_shot("11_toast_liked", type: :liked) + end + + it "captures content-shape variety: long title, long message, icon fallback (12–14)" do + sign_in(recipient) + + # Long topic title → ellipsis on the bold title line. + enriched_toast_shot( + "12_toast_long_title", + type: :replied, + post: long_topic_reply, + into: long_topic, + ) + + # Long message → the preview line clamps to two lines. + enriched_toast_shot("13_toast_long_message", type: :replied, post: long_reply) + + # No source post → the type icon renders on its own, preview from data. + visit_topic + wait_for_toast(".jtech-popup-toast .d-icon-bell") do + push( + type: :custom, + data: { + display_username: "system_sam", + topic_title: "Scheduled maintenance tonight", + excerpt: "The forum will be briefly unavailable around 2am for a database upgrade.", + url: "/u/#{recipient.username}/notifications", + }, + ) + end + shot("14_toast_fallback_icon") + end + + it "shows nothing extra when the preference is off (15)" do + set_pref(false) + sign_in(recipient) + visit_topic + + push_from_post(type: :replied, post: reply_post) + expect(page).to have_no_css(".jtech-popup-toast", wait: 5) + shot("15_notification_off_no_popup") + end + + it "captures the plugin's custom types: whisper, flag, pending (16–18)" do + sign_in(recipient) + + # Moderator whisper — enriched from a real post, with the eye badge. + visit_topic + wait_for_toast(".jtech-popup-toast .d-icon-eye") do + push( + type: :custom, + topic_id: topic.id, + post_number: reply_post.post_number, + slug: topic.slug, + fancy_title: topic.fancy_title, + data: { + mod_whisper: true, + display_username: author.username, + topic_title: topic.title, + original_post_id: reply_post.id, + }, + ) + end + shot("16_toast_whisper") + + # Flag note — no source post, so the flag type icon renders on its own. + visit_topic + wait_for_toast(".jtech-popup-toast .d-icon-flag") do + push( + type: :custom, + data: { + mod_note: true, + mod_note_kind: "flag_note", + display_username: "mod_mia", + excerpt: "Flagged as spam — please review.", + url: "/review", + }, + ) + end + shot("17_toast_flag") + + # Queued / pending post approved by staff. + visit_topic + wait_for_toast(".jtech-popup-toast .d-icon-check") do + push( + type: :custom, + data: { + mod_note: true, + mod_note_kind: "post_approved", + display_username: "mod_mia", + topic_title: topic.title, + excerpt: "Approved a post that was awaiting review.", + url: "/review", + }, + ) + end + shot("18_toast_pending_approved") + end +end diff --git a/spec/system/popup_notifications_spec.rb b/spec/system/popup_notifications_spec.rb new file mode 100644 index 0000000..821f41f --- /dev/null +++ b/spec/system/popup_notifications_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Behavioral coverage for the desktop pop-up notification toast. +# +# The toast is PURELY ADDITIVE: it subscribes to the same +# `/notification/:id` MessageBus channel core already publishes on and +# renders a card — it never touches core notification code, the bell, the +# dropdown, or read-state. So "regular notifications are unchanged" holds by +# construction; these examples prove the toast itself: +# +# * OFF (the default): a published notification produces NO toast. +# * ON: the same notification pops the toast (name + action + title), and +# clicking it routes to the post like the dropdown row. +# * Clicking elsewhere dismisses it. +# +# Each example loads the page fresh and publishes exactly one crafted +# notification on the channel — the same delivery core uses — which is the +# reliable path in the parallel system-test runner (a real reply would rely +# on PostAlerter running inline, which it does not here). +RSpec.describe "Desktop pop-up notifications" do + fab!(:author) { Fabricate(:user, username: "poster_pat") } + fab!(:recipient) { Fabricate(:user, username: "reader_rhea") } + fab!(:category) + fab!(:topic) do + Fabricate( + :topic, + category: category, + user: recipient, + title: "Might be the next Qin but better", + ) + end + fab!(:op) do + Fabricate(:post, topic: topic, user: recipient, raw: "What do you all think of this phone?") + end + fab!(:reply_post) do + Fabricate( + :post, + topic: topic, + user: author, + raw: + "Excellent screen quality. Supports 4g volte in Israel with excellent cellular reception.", + ) + end + + let(:user_field) { DiscoursePopupNotifications::USER_ENABLED_FIELD } + + before do + SiteSetting.popup_notifications_enabled = true + SiteSetting.popup_notifications_timeout_seconds = 300 + SiteSetting.auto_silence_fast_typers_on_first_post = false + end + + def set_pref(value) + recipient.custom_fields[user_field] = value + recipient.save_custom_fields(true) + end + + # Publish a reply-shaped notification for the recipient — the browser is + # subscribed to this channel, so the toast renders it. + def publish_reply + MessageBus.publish( + "/notification/#{recipient.id}", + { + unread_notifications: 1, + all_unread_notifications_count: 1, + last_notification: { + notification: { + id: 900_001, + user_id: recipient.id, + notification_type: Notification.types[:replied], + read: false, + created_at: Time.zone.now.iso8601, + topic_id: topic.id, + post_number: reply_post.post_number, + slug: topic.slug, + fancy_title: topic.fancy_title, + data: { + display_username: author.username, + topic_title: topic.title, + original_post_id: reply_post.id, + }, + }, + }, + }, + user_ids: [recipient.id], + ) + end + + def open_topic + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#post_1", wait: 10) + end + + # Re-publish (fresh id each time) until the toast appears, in case the + # browser's MessageBus poll is not yet listening when the first publish + # lands. + def publish_reply_until_toast + 8.times do + publish_reply + return if page.has_css?(".jtech-popup-toast", wait: 1.5) + end + expect(page).to have_css(".jtech-popup-toast", wait: 5) + end + + it "pops the toast when the preference is on" do + set_pref(true) + sign_in(recipient) + open_topic + + publish_reply_until_toast + + expect(page).to have_css(".jtech-popup-toast__name", text: author.username) + expect(page).to have_css(".jtech-popup-toast__action", text: "Replied") + expect(page).to have_css(".jtech-popup-toast__title", text: topic.title) + + # Clicking the toast routes to the replied post, like the dropdown row. + find(".jtech-popup-toast").click + expect(page).to have_css("#post_#{reply_post.post_number}", wait: 10) + expect(page).to have_no_css(".jtech-popup-toast") + end + + it "does not pop the toast when the preference is off (the default)" do + set_pref(false) + sign_in(recipient) + open_topic + + publish_reply + + expect(page).to have_no_css(".jtech-popup-toast", wait: 5) + end + + it "dismisses when clicking anywhere else" do + set_pref(true) + sign_in(recipient) + open_topic + + publish_reply_until_toast + + find("#post_1 .cooked").click + expect(page).to have_no_css(".jtech-popup-toast") + end +end diff --git a/spec/system/popup_notifications_stacking_screenshots_spec.rb b/spec/system/popup_notifications_stacking_screenshots_spec.rb new file mode 100644 index 0000000..665ac52 --- /dev/null +++ b/spec/system/popup_notifications_stacking_screenshots_spec.rb @@ -0,0 +1,388 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Screenshot gallery for STACKING — when more than one notification arrives, +# the cards stack one below another (newest on top, just below the header +# search), up to 3 at once; a 4th drops the oldest (bottom) card so the newest +# can take its place. 25 shots across single/double/triple stacks, type mixes, +# content shapes, and the overflow-replaces-oldest behavior. +# +# Each shot is its own example (fresh browser session) so a single flaky +# capture never aborts the others. Within an example the channel is "primed" +# (publish + wait + dismiss) before the stack is built one card at a time, +# waiting for the exact card count each step. +# +# Screenshots land in tmp/capybara/ and are published as the CI artifact. +RSpec.describe "Desktop pop-up notification stacking screenshots" do + fab!(:author) { Fabricate(:user, username: "poster_pat", name: "Pat Poster") } + fab!(:author2) { Fabricate(:user, username: "quinn_quill", name: "Quinn Quill") } + fab!(:recipient) { Fabricate(:user, username: "reader_rhea") } + fab!(:category) { Fabricate(:category, name: "Flip phones") } + fab!(:topic) do + Fabricate( + :topic, + category: category, + user: recipient, + title: "Might be the next Qin but better", + ) + end + fab!(:op) do + Fabricate(:post, topic: topic, user: recipient, raw: "What do you all think of this phone?") + end + fab!(:reply_post) do + Fabricate( + :post, + topic: topic, + user: author, + raw: + "Excellent screen quality. Supports 4g volte in Israel with excellent cellular reception.", + ) + end + fab!(:reply_post2) do + Fabricate(:post, topic: topic, user: author2, raw: "Battery life is genuinely impressive too.") + end + fab!(:long_reply) do + Fabricate( + :post, + topic: topic, + user: author, + raw: + "Honestly this might be the best budget option out there right now — the build feels " \ + "premium, the screen is bright even outdoors, and the battery lasts a day and a half.", + ) + end + fab!(:long_topic) do + Fabricate( + :topic, + category: category, + user: recipient, + title: + "A remarkably and unnecessarily long topic title that should be truncated with an " \ + "ellipsis inside the pop-up card so it never wraps onto a second line", + ) + end + fab!(:long_topic_reply) do + Fabricate(:post, topic: long_topic, user: author2, raw: "See the specs I linked above.") + end + + let(:user_field) { DiscoursePopupNotifications::USER_ENABLED_FIELD } + let(:id_seq) { [700_000] } + + # Gallery spec: generates screenshots in the Feature Screenshots workflow + # (which sets this env). Skipped in the main parallel system_tests run so it + # does not weigh that job down. + before { skip("screenshot-gallery only") unless ENV["JTECH_SCREENSHOT_GALLERY"] } + + before do + SiteSetting.popup_notifications_enabled = true + SiteSetting.popup_notifications_timeout_seconds = 300 + SiteSetting.auto_silence_fast_typers_on_first_post = false + recipient.custom_fields[user_field] = true + recipient.save_custom_fields(true) + sign_in(recipient) + end + + def shot(name) + begin + Timeout.timeout(8) do + sleep 0.1 until page.evaluate_script("Array.from(document.images).every((i) => i.complete)") + end + rescue Timeout::Error + # Capture anyway rather than fail over a slow avatar image. + end + page.save_screenshot("popup_notifications_#{name}.png") + end + + def push(type:, data:, topic_id: nil, post_number: nil, fancy_title: nil, slug: nil) + id_seq[0] += 1 + MessageBus.publish( + "/notification/#{recipient.id}", + { + unread_notifications: 1, + all_unread_notifications_count: 1, + last_notification: { + notification: { + id: id_seq[0], + user_id: recipient.id, + notification_type: Notification.types[type], + read: false, + created_at: Time.zone.now.iso8601, + post_number: post_number, + topic_id: topic_id, + fancy_title: fancy_title, + slug: slug, + data: data, + }, + }, + }, + user_ids: [recipient.id], + ) + end + + # Spec builders for the common notification shapes. + def enriched(type, post: reply_post, into: topic) + { + type: type, + topic_id: into.id, + post_number: post.post_number, + slug: into.slug, + fancy_title: into.fancy_title, + data: { + display_username: post.user.username, + topic_title: into.title, + original_post_id: post.id, + }, + } + end + + def whisper(post: reply_post, into: topic) + enriched(:custom, post: post, into: into).tap { |h| h[:data][:mod_whisper] = true } + end + + def mod_note(kind, username: "mod_mia", excerpt:, title: nil) + data = { + mod_note: true, + mod_note_kind: kind, + display_username: username, + excerpt: excerpt, + url: "/review", + } + data[:topic_title] = title if title + { type: :custom, data: data } + end + + def fallback(username: "system_sam", title:, excerpt:) + { + type: :custom, + data: { + display_username: username, + topic_title: title, + excerpt: excerpt, + url: "/u/#{recipient.username}/notifications", + }, + } + end + + def visit_topic + visit("/t/#{topic.slug}/#{topic.id}") + expect(page).to have_css("#post_1", wait: 10) + end + + # Ensure the browser's MessageBus subscription is live, then clear the stack. + def prime + 8.times do + push(**enriched(:replied)) + break if page.has_css?(".jtech-popup-toast", wait: 1.5) + end + expect(page).to have_css(".jtech-popup-toast", wait: 5) + find("#post_1 .cooked").click + expect(page).to have_no_css(".jtech-popup-toast") + end + + # Build a stack of up to 3 cards, one publish per card, and screenshot it. + def stack_shot(name, *specs) + visit_topic + prime + specs.each_with_index do |spec, index| + push(**spec) + expect(page).to have_css(".jtech-popup-toast", count: index + 1, wait: 10) + end + shot(name) + end + + # Push more than the cap; the stack settles at 3 with `top_icon` at the top. + def overflow_shot(name, specs, top_icon:) + visit_topic + prime + specs.each { |spec| push(**spec) } + expect(page).to have_css(".jtech-popup-toast", count: 3, wait: 10) + expect(page).to have_css(".jtech-popup-toast:first-child #{top_icon}", wait: 10) + shot(name) + end + + it "single reply (01)" do + stack_shot("stack_01_single_reply", enriched(:replied)) + end + + it "reply + like (02)" do + stack_shot("stack_02_reply_like", enriched(:replied), enriched(:liked)) + end + + it "reply + mention (03)" do + stack_shot("stack_03_reply_mention", enriched(:replied), enriched(:mentioned)) + end + + it "reply + quote (04)" do + stack_shot("stack_04_reply_quote", enriched(:replied), enriched(:quoted)) + end + + it "pm + reply (05)" do + stack_shot("stack_05_pm_reply", enriched(:private_message), enriched(:replied)) + end + + it "whisper + reply (06)" do + stack_shot("stack_06_whisper_reply", whisper, enriched(:replied)) + end + + it "flag + pending (07)" do + stack_shot( + "stack_07_flag_pending", + mod_note("flag_note", excerpt: "Flagged as spam — please review."), + mod_note("post_approved", title: topic.title, excerpt: "Approved a queued reply."), + ) + end + + it "badge + reply (08)" do + stack_shot("stack_08_badge_reply", enriched(:granted_badge), enriched(:replied)) + end + + it "edited + linked (09)" do + stack_shot("stack_09_edited_linked", enriched(:edited), enriched(:linked)) + end + + it "reply + like + mention (10)" do + stack_shot( + "stack_10_reply_like_mention", + enriched(:replied), + enriched(:liked), + enriched(:mentioned), + ) + end + + it "reply + quote + pm (11)" do + stack_shot( + "stack_11_reply_quote_pm", + enriched(:replied), + enriched(:quoted), + enriched(:private_message), + ) + end + + it "whisper + flag + pending (12)" do + stack_shot( + "stack_12_whisper_flag_pending", + whisper, + mod_note("flag_note", excerpt: "Flagged as off-topic."), + mod_note("post_approved", title: topic.title, excerpt: "Approved a queued reply."), + ) + end + + it "three likes (13)" do + stack_shot( + "stack_13_like_x3", + enriched(:liked), + enriched(:liked, post: reply_post2), + enriched(:liked), + ) + end + + it "mention + quote + reply (14)" do + stack_shot( + "stack_14_mention_quote_reply", + enriched(:mentioned), + enriched(:quoted), + enriched(:replied), + ) + end + + it "pm + whisper + reply (15)" do + stack_shot("stack_15_pm_whisper_reply", enriched(:private_message), whisper, enriched(:replied)) + end + + it "reply + flag + like (16)" do + stack_shot( + "stack_16_reply_flag_like", + enriched(:replied), + mod_note("flag_note", excerpt: "A new flag needs attention."), + enriched(:liked), + ) + end + + it "fallback + reply + whisper (17)" do + stack_shot( + "stack_17_fallback_reply_whisper", + fallback( + title: "Scheduled maintenance", + excerpt: "The forum will be briefly offline at 2am.", + ), + enriched(:replied), + whisper, + ) + end + + it "long title + reply + like (18)" do + stack_shot( + "stack_18_longtitle_reply_like", + enriched(:replied, post: long_topic_reply, into: long_topic), + enriched(:replied), + enriched(:liked), + ) + end + + it "long message + like + mention (19)" do + stack_shot( + "stack_19_longmessage_like_mention", + enriched(:replied, post: long_reply), + enriched(:liked), + enriched(:mentioned), + ) + end + + it "badge + pm + reply (20)" do + stack_shot( + "stack_20_badge_pm_reply", + enriched(:granted_badge), + enriched(:private_message), + enriched(:replied), + ) + end + + it "edited + linked + quote (21)" do + stack_shot( + "stack_21_edited_linked_quote", + enriched(:edited), + enriched(:linked), + enriched(:quoted), + ) + end + + it "flag + pending + whisper (22)" do + stack_shot( + "stack_22_flag_pending_whisper", + mod_note("flag_note", excerpt: "Flag raised on a reply."), + mod_note("post_rejected", title: topic.title, excerpt: "Rejected a queued reply."), + whisper, + ) + end + + it "three replies, mixed topics (23)" do + stack_shot( + "stack_23_three_replies_mixed", + enriched(:replied), + enriched(:replied, post: reply_post2), + enriched(:replied, post: long_topic_reply, into: long_topic), + ) + end + + it "overflow — a 4th engagement notification replaces the oldest (24)" do + overflow_shot( + "stack_24_overflow_engagement", + [enriched(:replied), enriched(:liked), enriched(:mentioned), whisper], + top_icon: ".d-icon-eye", + ) + end + + it "overflow — a 4th staff notification replaces the oldest (25)" do + overflow_shot( + "stack_25_overflow_staff", + [ + whisper, + mod_note("flag_note", excerpt: "Flagged for review."), + mod_note("post_approved", title: topic.title, excerpt: "Approved a queued reply."), + enriched(:liked), + ], + top_icon: ".d-icon-heart", + ) + end +end diff --git a/sub_plugins/popup_notifications.rb b/sub_plugins/popup_notifications.rb new file mode 100644 index 0000000..0eb6ba4 --- /dev/null +++ b/sub_plugins/popup_notifications.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +# Jtech sub-plugin: desktop pop-up notifications. +# +# Renders an in-browser "toast" card (top-right, just below the header +# search) when a new notification arrives for the current user — modelled +# on the Jelly macOS notifier's look and delivery. Delivery reuses the +# same MessageBus channel Discourse already publishes notification state +# on (`/notification/:user_id`), so no new server push path is needed. +# +# Desktop only: the frontend never subscribes or renders on mobile +# (`site.mobileView`). Gated per-user by an account-page preference +# ("Desktop Pop Up Notifications", on/off) stored in a user custom field, +# and site-wide by `popup_notifications_enabled`. +# +# The backend here is deliberately thin — the whole experience is +# client-side. All this file does is: +# * register the per-user boolean custom field + make it editable, and +# * expose the effective (default-aware) value on the current-user +# serializer so the client can gate on `currentUser. +# jtech_popup_notifications_enabled`. + +register_asset "stylesheets/popup-notifications.scss" + +# Type-badge icons the toast draws on the avatar corner (and the icon-only +# fallback for postless notifications). Registered so they land in the SVG +# sprite; some are core, some are shared with mod-categories. +%w[ + at + reply + quote-right + pencil + heart + envelope + link + certificate + check + xmark + trash-can + flag + eye + shield-halved + bell +].each { |name| register_svg_icon(name) } + +module ::DiscoursePopupNotifications + # Per-user preference. Key PRESENCE + value decides; when the field is + # absent the effective value falls back to + # `SiteSetting.popup_notifications_default_enabled`. + USER_ENABLED_FIELD = "jtech_popup_notifications_enabled" +end + +after_initialize do + register_user_custom_field_type(DiscoursePopupNotifications::USER_ENABLED_FIELD, :boolean) + + # Permit the field through UsersController#update so the account-page + # dropdown can save it with the rest of the preferences form. + register_editable_user_custom_field(DiscoursePopupNotifications::USER_ENABLED_FIELD) + + # Effective, default-aware preference for the client. Returns the stored + # boolean when the user has chosen, otherwise the site-wide default. + add_to_serializer(:current_user, :jtech_popup_notifications_enabled) do + raw = object.custom_fields[DiscoursePopupNotifications::USER_ENABLED_FIELD] + if raw.nil? + SiteSetting.popup_notifications_default_enabled + else + ActiveModel::Type::Boolean.new.cast(raw) + end + end +end