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.
This commit is contained in:
Simon Backx 2023-03-01 12:15:29 +01:00 committed by GitHub
parent 22a2f194aa
commit 81c4b46977
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 176 additions and 126 deletions

View File

@ -1,28 +0,0 @@
<section class="gh-dashboard-section gh-dashboard-mentions" {{did-insert this.loadData}}>
<article class="gh-dashboard-box">
{{#if (feature 'webmentions')}}
{{!-- Recent mentions --}}
<h3 class="gh-dashboard-mentions-header">Mentions</h3>
<div class="gh-dashboard-mentions-list">
{{#if this.mentions}}
{{#each this.mentions as |mention|}}
<a href="{{mention.source}}" class="gh-dashboard-mention" rel="noreferrer noopener" target="_blank">
<img src="{{mention.sourceFavicon}}" alt="{{mention.sourceSiteTitle}}" class="w5 h5 mr2 flex-shrink-0" />
<span class="gh-dashboard-mention-title">{{if mention.sourceTitle mention.sourceTitle mention.source}}</span>
<span class="gh-dashboard-mention-timestamp">{{moment-from-now mention.timestamp}}</span>
</a>
{{/each}}
{{else}}
<div>
<p>No mentions yet.</p>
</div>
{{/if}}
</div>
{{#if this.mentions}}
<div class="gh-dashboard-list-footer">
<LinkTo @route="mentions">View all mentions &rarr;</LinkTo>
</div>
{{/if}}
{{/if}}
</article>
</section>

View File

@ -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'});
}
}

View File

@ -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

View File

@ -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();

View File

@ -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
});
}

View File

@ -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;
}
}

View File

@ -7,87 +7,52 @@
</h2>
{{#unless this.isTotalMembersZero}}
<div class="gh-dashboard-select">
<div>
<div {{did-insert this.loadMentions}}>
{{#if (feature 'webmentions')}}
{{!-- Mentions widget using a GhBasicDropdown component --}}
{{!-- <GhBasicDropdown @horizontalPosition="left" @verticalPosition="above" @calculatePosition={{this.userDropdownPosition}} as |dropdown|>
<GhBasicDropdown @horizontalPosition="right" @verticalPosition="below" @onOpen={{this.markMentionsRead}} as |dropdown|>
<dropdown.Trigger class="outline-0 pointer">
<a href="#" class="gh-dashboard-mentions-icon">
{{svg-jar "notification-bell"}}
</a>
<button type="button" class="gh-dashboard-mentions-icon">
{{#if this.hasNewMentions}}
{{svg-jar "notification-bell-indicator"}}
{{else}}
{{svg-jar "notification-bell"}}
{{/if}}
</button>
</dropdown.Trigger>
<dropdown.Content class="">
<div class="gh-dashboard-mentions">
<div class="gh-dashboard-mentions-header">
<h2 class="gh-dashboard-mentions-heading">Mentions</h2>
<a href="" class="gh-dashboard-mentions-see-all">View all mentions &rarr;</a>
<LinkTo @route="mentions" class="gh-dashboard-mentions-see-all">View all mentions &rarr;</LinkTo>
</div>
<div>
<a href="#" class="gh-dashboard-mention" rel="noreferrer noopener" target="_blank">
<div class="gh-dashboard-mention-content">
<img src="assets/img/orb-squircle.png" alt="{{mention.sourceSiteTitle}}" class="w5 h5 mr2 flex-shrink-0" />
<span class="gh-dashboard-mention-title">Lever News</span>
<span>mentioned</span>
<span class="gh-dashboard-mention-target">3 links</span>
</div>
<span class="gh-dashboard-mention-timestamp">{{moment-from-now mention.timestamp}}</span>
</a>
<a href="#" class="gh-dashboard-mention" rel="noreferrer noopener" target="_blank">
<div class="gh-dashboard-mention-content">
<img src="assets/img/orb-squircle.png" alt="{{mention.sourceSiteTitle}}" class="w5 h5 mr2 flex-shrink-0" />
<span class="gh-dashboard-mention-title">Lever News</span>
<span>mentioned</span>
<span class="gh-dashboard-mention-target">3 links</span>
</div>
<span class="gh-dashboard-mention-timestamp">{{moment-from-now mention.timestamp}}</span>
</a>
{{#each this.mentions as |mention|}}
<a href="{{mention.source}}" class="gh-dashboard-mention" rel="noreferrer noopener" target="_blank">
<div class="gh-dashboard-mention-content">
<img src="{{ or mention.sourceFavicon 'assets/img/orb-squircle.png'}}" alt="{{mention.sourceSiteTitle}}" class="w5 h5 mr2 flex-shrink-0">
<span class="gh-dashboard-mention-title">{{or mention.sourceTitle mention.sourceSiteTitle}}</span>
<span>mentioned</span>
{{#if (gt mention.mentions.length 1) }}
<span class="gh-dashboard-mention-target">{{mention.mentions.length}} links</span>
{{else}}
<span class="gh-dashboard-mention-target">{{if mention.resource mention.resource.name 'a link'}}</span>
{{/if}}
</div>
<span class="gh-dashboard-mention-timestamp">{{moment-from-now mention.timestamp}}</span>
</a>
{{/each}}
</div>
</div>
</dropdown.Content>
</GhBasicDropdown> --}}
</GhBasicDropdown>
{{!-- Mentions widget using a popover component --}}
<a href="#" class="gh-dashboard-mentions-icon">
{{!-- State: No new mentions --}}
{{!-- {{svg-jar "notification-bell"}} --}}
{{!-- State: at least one new mention --}}
{{svg-jar "notification-bell-indicator"}}
</a>
<EmberPopover @tooltipClass="popover" @spacing={{30}} @arrowClass="popover-arrow" @side="down">
<div class="gh-dashboard-mentions">
<div class="gh-dashboard-mentions-header">
<h2 class="gh-dashboard-mentions-heading">Mentions</h2>
<a href="" class="gh-dashboard-mentions-see-all">View all mentions &rarr;</a>
</div>
<div>
<a href="#" class="gh-dashboard-mention" rel="noreferrer noopener" target="_blank">
<div class="gh-dashboard-mention-content">
<img src="assets/img/orb-squircle.png" alt="" role="none" class="w5 h5 mr2 flex-shrink-0" />
<span class="gh-dashboard-mention-title">Lever News</span>
<span>mentioned</span>
<span class="gh-dashboard-mention-target">3 links</span>
</div>
<span class="gh-dashboard-mention-timestamp">2 days ago</span>
</a>
<a href="#" class="gh-dashboard-mention" rel="noreferrer noopener" target="_blank">
<div class="gh-dashboard-mention-content">
<img src="assets/img/orb-squircle.png" alt="" role="none" class="w5 h5 mr2 flex-shrink-0" />
<span class="gh-dashboard-mention-title">Lever News</span>
<span>mentioned</span>
<span class="gh-dashboard-mention-target">3 links</span>
</div>
<span class="gh-dashboard-mention-timestamp">2 days ago</span>
</a>
</div>
</div>
</EmberPopover>
{{/if}}
</div>
<PowerSelect
@selected={{this.selectedDaysOption}}
@options={{this.daysOptions}}

View File

@ -49,28 +49,34 @@
<img src="{{mention.sourceFavicon}}" alt="{{mention.sourceSiteTitle}}" class="gh-mention-icon">
{{/if}}
<div class="gh-mention-publisher">{{mention.sourceSiteTitle}}</div>
{{#unless this.post }}
{{#if (gt mention.mentions.length 1) }}
<div class="gh-mention-link-icon">
{{svg-jar "twitter-link"}}
</div>
<div>
{{!-- TODO: Add logic so when there are multiple links, this span gets a .has-multiple-links class --}}
<span class="gh-mention-your-link">{{if mention.resource mention.resource.name mention.target}}</span>
<EmberPopover @tooltipClass="popover" @spacing={{30}} @arrowClass="popover-arrow" @side="down">
<span class="gh-mention-your-link has-multiple-links">{{mention.mentions.length}} links</span>
<EmberPopover @tooltipClass="popover" @spacing={{15}} @arrowClass="popover-arrow" @side="top-start">
<ul class="gh-mention-multiple-links">
<li>Weekly Links - Week #13</li>
<li>Welcome to my blog!</li>
<li>Introducing: New Tool I Just Built</li>
<li>Introducing: Even Newer Tool I Just Built For You Guys</li>
{{#each mention.mentions as |submention|}}
<li>{{if submention.resource submention.resource.name submention.target}}</li>
{{/each}}
</ul>
</EmberPopover>
</div>
{{/unless}}
{{else}}
{{#unless this.post }}
<div class="gh-mention-link-icon">
{{svg-jar "twitter-link"}}
</div>
<span class="gh-mention-your-link">{{if mention.resource mention.resource.name mention.target}}</span>
{{/unless}}
{{/if}}
<span class="gh-mention-timestamp" title={{gh-format-post-time mention.timestamp}}>{{moment-from-now mention.timestamp}}</span>
</div>
<div class="gh-mention-content">
<div class="gh-mention-source">
<h3 class="gh-mention-title">{{if mention.sourceTitle mention.sourceTitle mention.target}}</h3>
<h3 class="gh-mention-title">{{or mention.sourceTitle mention.sourceSiteTitle mention.source}}</h3>
{{#if mention.sourceExcerpt}}
<p class="gh-mention-description">{{mention.sourceExcerpt}}</p>
{{/if}}

View File

@ -9,7 +9,8 @@ module.exports = {
'limit',
'order',
'page',
'debug'
'debug',
'unique'
],
permissions: true,
query(frame) {

View File

@ -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 = {

View File

@ -65,7 +65,18 @@ module.exports = class BookshelfMentionRepository {
* @returns {Promise<Page<import('@tryghost/webmentions/lib/Mention')>>}
*/
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))),

View File

@ -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) => {

View File

@ -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
};
}