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:
parent
22a2f194aa
commit
81c4b46977
@ -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 →</LinkTo>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</article>
|
||||
</section>
|
@ -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'});
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
15
ghost/admin/app/services/mention-utils.js
Normal file
15
ghost/admin/app/services/mention-utils.js
Normal 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;
|
||||
}
|
||||
}
|
@ -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 →</a>
|
||||
<LinkTo @route="mentions" class="gh-dashboard-mentions-see-all">View all mentions →</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 →</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}}
|
||||
|
@ -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}}
|
||||
|
@ -9,7 +9,8 @@ module.exports = {
|
||||
'limit',
|
||||
'order',
|
||||
'page',
|
||||
'debug'
|
||||
'debug',
|
||||
'unique'
|
||||
],
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
|
@ -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 = {
|
||||
|
@ -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))),
|
||||
|
@ -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) => {
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user