From 81c4b46977fc2eb5199b1427ba6d7b85f9ab60c3 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Wed, 1 Mar 2023 12:15:29 +0100 Subject: [PATCH] Grouped mentions from the same source (#16348) fixes https://github.com/TryGhost/Team/issues/2625 - Adds an unique option to the mentions API. Enabling this will only return the latest mention from each source. - The frontend can fetch the related sources for each page by doing an extra request to the mentions API. --- .../components/dashboard/charts/mentions.hbs | 28 ------ .../components/dashboard/charts/mentions.js | 16 ---- .../components/dashboard/charts/recents.js | 4 +- ghost/admin/app/controllers/dashboard.js | 59 ++++++++++++ ghost/admin/app/routes/mentions.js | 18 +++- ghost/admin/app/services/mention-utils.js | 15 +++ ghost/admin/app/templates/dashboard.hbs | 91 ++++++------------- ghost/admin/app/templates/mentions.hbs | 26 ++++-- .../core/server/api/endpoints/mentions.js | 3 +- ghost/core/core/server/models/mention.js | 13 +++ .../mentions/BookshelfMentionRepository.js | 13 ++- .../services/mentions/MentionController.js | 8 +- ghost/webmentions/lib/MentionsAPI.js | 8 +- 13 files changed, 176 insertions(+), 126 deletions(-) delete mode 100644 ghost/admin/app/components/dashboard/charts/mentions.hbs delete mode 100644 ghost/admin/app/components/dashboard/charts/mentions.js create mode 100644 ghost/admin/app/services/mention-utils.js diff --git a/ghost/admin/app/components/dashboard/charts/mentions.hbs b/ghost/admin/app/components/dashboard/charts/mentions.hbs deleted file mode 100644 index ede0bce08c..0000000000 --- a/ghost/admin/app/components/dashboard/charts/mentions.hbs +++ /dev/null @@ -1,28 +0,0 @@ -
- -
diff --git a/ghost/admin/app/components/dashboard/charts/mentions.js b/ghost/admin/app/components/dashboard/charts/mentions.js deleted file mode 100644 index 2b9fa6886a..0000000000 --- a/ghost/admin/app/components/dashboard/charts/mentions.js +++ /dev/null @@ -1,16 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -export default class Recents extends Component { - @service store; - @service dashboardStats; - - @tracked mentions = []; - - @action - async loadData() { - this.mentions = await this.store.query('mention', {limit: 5, order: 'created_at desc'}); - } -} diff --git a/ghost/admin/app/components/dashboard/charts/recents.js b/ghost/admin/app/components/dashboard/charts/recents.js index da2de67793..362d3f1cfe 100644 --- a/ghost/admin/app/components/dashboard/charts/recents.js +++ b/ghost/admin/app/components/dashboard/charts/recents.js @@ -9,13 +9,11 @@ export default class Recents extends Component { @tracked selected = 'posts'; @tracked posts = []; - @tracked mentions = []; excludedEventTypes = ['aggregated_click_event']; - @action + @action async loadData() { this.posts = await this.store.query('post', {limit: 5, filter: 'status:[published,sent]', order: 'published_at desc'}); - this.mentions = await this.store.query('mention', {limit: 5, order: 'created_at desc'}); } @action diff --git a/ghost/admin/app/controllers/dashboard.js b/ghost/admin/app/controllers/dashboard.js index 7cf59b2c13..b1da2f0005 100644 --- a/ghost/admin/app/controllers/dashboard.js +++ b/ghost/admin/app/controllers/dashboard.js @@ -2,6 +2,7 @@ import Controller from '@ember/controller'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; +import {tracked} from '@glimmer/tracking'; // Options 30 and 90 need an extra day to be able to distribute ticks/gridlines evenly const DAYS_OPTIONS = [{ @@ -18,9 +19,67 @@ const DAYS_OPTIONS = [{ export default class DashboardController extends Controller { @service dashboardStats; @service membersUtils; + @service store; + @service mentionUtils; + @service feature; + + @tracked mentions = []; + @tracked hasNewMentions = false; daysOptions = DAYS_OPTIONS; + @action + async loadMentions() { + if (!this.feature.get('webmentions')) { + return; + } + this.mentions = await this.store.query('mention', {unique: true, limit: 5, order: 'created_at desc'}); + this.hasNewMentions = this.checkHasNewMentions(); + + // Load grouped mentions + await this.mentionUtils.loadGroupedMentions(this.mentions); + } + + checkHasNewMentions() { + if (!this.mentions) { + return false; + } + const firstMention = this.mentions.firstObject; + if (!firstMention) { + return false; + } + + try { + const lastId = localStorage.getItem('lastMentionRead'); + return firstMention.id !== lastId; + } catch (e) { + // localstorage disabled or not supported + } + return true; + } + + @action + markMentionsRead() { + try { + if (this.mentions) { + const firstMention = this.mentions.firstObject; + if (firstMention) { + localStorage.setItem('lastMentionRead', firstMention.id); + } + } + } catch (e) { + // localstorage disabled or not supported + } + + // The opening of the popup breaks if we change hasNewMentions inside the handling (propably due to a rerender, so we need to delay it) + if (this.hasNewMentions) { + setTimeout(() => { + this.hasNewMentions = false; + }, 20); + } + return true; + } + @task *loadSiteStatusTask() { yield this.dashboardStats.loadSiteStatus(); diff --git a/ghost/admin/app/routes/mentions.js b/ghost/admin/app/routes/mentions.js index b716756b5b..bb078c56ef 100644 --- a/ghost/admin/app/routes/mentions.js +++ b/ghost/admin/app/routes/mentions.js @@ -1,7 +1,18 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import InfinityModel from 'ember-infinity/lib/infinity-model'; import RSVP from 'rsvp'; +import classic from 'ember-classic-decorator'; import {inject as service} from '@ember/service'; +@classic +class LoadSourceMentions extends InfinityModel { + @service mentionUtils; + + async afterInfinityModel(mentions) { + return await this.mentionUtils.loadGroupedMentions(mentions); + } +} + export default class MentionsRoute extends AuthenticatedRoute { @service store; @service feature; @@ -25,13 +36,18 @@ export default class MentionsRoute extends AuthenticatedRoute { }; const paginationSettings = {perPage, startingPage: 1, order: 'created_at desc', ...paginationParams}; + let extension = undefined; if (params.post_id) { paginationSettings.filter = `resource_id:${params.post_id}+resource_type:post`; + } else { + // Only return mentions with the same source once + paginationSettings.unique = true; + extension = LoadSourceMentions; } return RSVP.hash({ - mentions: this.infinity.model('mention', paginationSettings), + mentions: this.infinity.model('mention', paginationSettings, extension), post: params.post_id ? this.store.findRecord('post', params.post_id) : null }); } diff --git a/ghost/admin/app/services/mention-utils.js b/ghost/admin/app/services/mention-utils.js new file mode 100644 index 0000000000..be0c985d9a --- /dev/null +++ b/ghost/admin/app/services/mention-utils.js @@ -0,0 +1,15 @@ +import Service, {inject as service} from '@ember/service'; + +export default class MentionUtilsService extends Service { + @service store; + + async loadGroupedMentions(mentions) { + // Fetch mentions with the same source + const sources = mentions.mapBy('source').uniq(); + const sourceMentions = await this.store.query('mention', {filter: `source:[${sources.map(s => `'${s}'`).join(',')}]`}); + mentions.forEach((mention) => { + mention.set('mentions', sourceMentions.filterBy('source', mention.source)); + }); + return mentions; + } +} diff --git a/ghost/admin/app/templates/dashboard.hbs b/ghost/admin/app/templates/dashboard.hbs index 12bf0f550e..89f92d9ac6 100644 --- a/ghost/admin/app/templates/dashboard.hbs +++ b/ghost/admin/app/templates/dashboard.hbs @@ -7,87 +7,52 @@ {{#unless this.isTotalMembersZero}}
-
+
{{#if (feature 'webmentions')}} {{!-- Mentions widget using a GhBasicDropdown component --}} - {{!-- + - - {{svg-jar "notification-bell"}} - + - --}} + - {{!-- Mentions widget using a popover component --}} - - - {{!-- State: No new mentions --}} - {{!-- {{svg-jar "notification-bell"}} --}} - - {{!-- State: at least one new mention --}} - {{svg-jar "notification-bell-indicator"}} - - - - {{/if}}
- + {{/if}}
{{mention.sourceSiteTitle}}
- {{#unless this.post }} + + {{#if (gt mention.mentions.length 1) }}
- {{!-- TODO: Add logic so when there are multiple links, this span gets a .has-multiple-links class --}} - {{if mention.resource mention.resource.name mention.target}} - + {{mention.mentions.length}} links +
- {{/unless}} + {{else}} + {{#unless this.post }} + + {{if mention.resource mention.resource.name mention.target}} + {{/unless}} + {{/if}} {{moment-from-now mention.timestamp}}
-

{{if mention.sourceTitle mention.sourceTitle mention.target}}

+

{{or mention.sourceTitle mention.sourceSiteTitle mention.source}}

{{#if mention.sourceExcerpt}}

{{mention.sourceExcerpt}}

{{/if}} diff --git a/ghost/core/core/server/api/endpoints/mentions.js b/ghost/core/core/server/api/endpoints/mentions.js index a6790ff99a..06cc447150 100644 --- a/ghost/core/core/server/api/endpoints/mentions.js +++ b/ghost/core/core/server/api/endpoints/mentions.js @@ -9,7 +9,8 @@ module.exports = { 'limit', 'order', 'page', - 'debug' + 'debug', + 'unique' ], permissions: true, query(frame) { diff --git a/ghost/core/core/server/models/mention.js b/ghost/core/core/server/models/mention.js index c2e681e280..c5e849594a 100644 --- a/ghost/core/core/server/models/mention.js +++ b/ghost/core/core/server/models/mention.js @@ -9,6 +9,19 @@ const Mention = ghostBookshelf.Model.extend({ enforcedFilters() { return 'deleted:false'; } +}, { + permittedOptions(methodName) { + let options = ghostBookshelf.Model.permittedOptions.call(this, methodName); + const validOptions = { + findPage: ['selectRaw', 'whereRaw'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + } }); module.exports = { diff --git a/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js b/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js index 0804694674..5a9eac7a22 100644 --- a/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js +++ b/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js @@ -65,7 +65,18 @@ module.exports = class BookshelfMentionRepository { * @returns {Promise>} */ async getPage(options) { - const page = await this.#MentionModel.findPage(options); + /** + * @type {GetPageOptions & {whereRaw?: string}} + */ + const _options = { + ...options + }; + delete _options.unique; + if (options.unique) { + _options.whereRaw = 'NOT EXISTS (select id from mentions as m where m.id > mentions.id and m.source = mentions.source)'; + } + + const page = await this.#MentionModel.findPage(_options); return { data: await Promise.all(page.data.map(model => this.#modelToMention(model))), diff --git a/ghost/core/core/server/services/mentions/MentionController.js b/ghost/core/core/server/services/mentions/MentionController.js index e304e874ad..28d03ab67b 100644 --- a/ghost/core/core/server/services/mentions/MentionController.js +++ b/ghost/core/core/server/services/mentions/MentionController.js @@ -80,11 +80,17 @@ module.exports = class MentionController { order = 'created_at asc'; } + let unique; + if (frame.options.unique && (frame.options.unique === 'true' || frame.options.unique === true)) { + unique = true; + } + const mentions = await this.#api.listMentions({ filter: frame.options.filter, order, limit, - page + page, + unique }); const resources = await Promise.all(mentions.data.map((mention) => { diff --git a/ghost/webmentions/lib/MentionsAPI.js b/ghost/webmentions/lib/MentionsAPI.js index 32a5b8f5a4..d3e28aaa5f 100644 --- a/ghost/webmentions/lib/MentionsAPI.js +++ b/ghost/webmentions/lib/MentionsAPI.js @@ -22,6 +22,7 @@ const Mention = require('./Mention'); * @prop {string} [order] * @prop {number} page * @prop {number} limit + * @prop {boolean} [unique] Only return unique mentions by source */ /** @@ -29,6 +30,7 @@ const Mention = require('./Mention'); * @prop {string} [filter] A valid NQL string * @prop {string} [order] * @prop {'all'} limit + * @prop {boolean} [unique] Only return unique mentions by source */ /** @@ -110,14 +112,16 @@ module.exports = class MentionsAPI { pageOptions = { filter: options.filter, limit: options.limit, - order: options.order + order: options.order, + unique: options.unique ?? false }; } else { pageOptions = { filter: options.filter, limit: options.limit, page: options.page, - order: options.order + order: options.order, + unique: options.unique ?? false }; }