diff --git a/apps/admin-x-settings/src/api/mentions.ts b/apps/admin-x-settings/src/api/mentions.ts new file mode 100644 index 0000000000..238ca92e54 --- /dev/null +++ b/apps/admin-x-settings/src/api/mentions.ts @@ -0,0 +1,27 @@ +import {Meta, createQuery} from '../utils/apiRequests'; + +export type Mention = { + id: string; + source: string; + source_title: string|null; + source_site_title: string|null; + source_excerpt: string|null; + source_author: string|null; + source_featured_image: string|null; + source_favicon: string|null; + target: string; + verified: boolean; + created_at: string; +}; + +export interface MentionsResponseType { + meta?: Meta + mentions: Mention[]; +} + +const dataType = 'MentionsResponseType'; + +export const useBrowseMentions = createQuery({ + dataType, + path: '/mentions/' +}); diff --git a/apps/admin-x-settings/src/components/settings/site/Recommendations.tsx b/apps/admin-x-settings/src/components/settings/site/Recommendations.tsx index 974e7610a5..cac1ed9ad7 100644 --- a/apps/admin-x-settings/src/components/settings/site/Recommendations.tsx +++ b/apps/admin-x-settings/src/components/settings/site/Recommendations.tsx @@ -1,4 +1,5 @@ import Button from '../../../admin-x-ds/global/Button'; +import IncomingRecommendations from './recommendations/IncomingRecommendations'; import Link from '../../../admin-x-ds/global/Link'; import React, {useState} from 'react'; import RecommendationList from './recommendations/RecommendationList'; @@ -35,13 +36,12 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => { id: 'your-recommendations', title: 'Your recommendations', contents: () + }, + { + id: 'recommending-you', + title: 'Recommending you', + contents: () } - // TODO: Show "Recommending you" tab once we hook it up - // { - // id: 'recommending-you', - // title: 'Recommending you', - // contents: () - // } ]; const groupDescription = ( diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/IncomingRecommendationList.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/IncomingRecommendationList.tsx new file mode 100644 index 0000000000..f6dc5f9931 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/IncomingRecommendationList.tsx @@ -0,0 +1,49 @@ +import NoValueLabel from '../../../../admin-x-ds/global/NoValueLabel'; +import React from 'react'; +import Table from '../../../../admin-x-ds/global/Table'; +import TableCell from '../../../../admin-x-ds/global/TableCell'; +import TableRow from '../../../../admin-x-ds/global/TableRow'; +import {Mention} from '../../../../api/mentions'; + +interface IncomingRecommendationListProps { + mentions: Mention[] +} + +const IncomingRecommendationItem: React.FC<{mention: Mention}> = ({mention}) => { + const cleanedSource = mention.source.replace('/.well-known/recommendations.json', ''); + + const showDetails = () => { + // Open url + window.open(cleanedSource, '_blank'); + }; + + return ( + + +
+
+
+ {mention.source_favicon && {mention.source_title} + {mention.source_title || mention.source_site_title || cleanedSource} +
+ {mention.source_excerpt || cleanedSource} +
+
+
+
+ ); +}; + +const IncomingRecommendationList: React.FC = ({mentions}) => { + if (mentions.length) { + return + {mentions.map(mention => )} +
; + } else { + return + No sites are recommending you yet. + ; + } +}; + +export default IncomingRecommendationList; diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/IncomingRecommendations.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/IncomingRecommendations.tsx new file mode 100644 index 0000000000..9f45c340dc --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/IncomingRecommendations.tsx @@ -0,0 +1,15 @@ +import IncomingRecommendationList from './IncomingRecommendationList'; +import {useBrowseMentions} from '../../../../api/mentions'; + +const IncomingRecommendations: React.FC = () => { + const {data: {mentions} = {}} = useBrowseMentions({ + searchParams: { + filter: `source:~$'/.well-known/recommendations.json'+verified:true`, + order: 'created_at desc' + } + }); + + return (); +}; + +export default IncomingRecommendations; diff --git a/ghost/core/core/server/services/mentions/WebmentionMetadata.js b/ghost/core/core/server/services/mentions/WebmentionMetadata.js index 9f6388d9a6..42df7eef5f 100644 --- a/ghost/core/core/server/services/mentions/WebmentionMetadata.js +++ b/ghost/core/core/server/services/mentions/WebmentionMetadata.js @@ -1,12 +1,41 @@ const oembedService = require('../oembed'); module.exports = class WebmentionMetadata { + /** + * Helpers that change the URL for which metadata for a given external resource is fetched. Return undefined to now handle the URL. + * @type {((url: URL) => URL|undefined)[]} + */ + #mappers = []; + + /** + * @param {(url: URL) => URL|undefined} mapper + */ + addMapper(mapper) { + this.#mappers.push(mapper); + } + + /** + * + * @param {URL} url + */ + #getMappedUrl(url) { + for (const mapper of this.#mappers) { + const mappedUrl = mapper(url); + if (mappedUrl) { + return this.#getMappedUrl(mappedUrl); + } + } + return url; + } + /** * @param {URL} url * @returns {Promise} */ async fetch(url) { - const data = await oembedService.fetchOembedDataFromUrl(url.href, 'mention'); + const mappedUrl = this.#getMappedUrl(url); + const data = await oembedService.fetchOembedDataFromUrl(mappedUrl.href, 'mention'); + const result = { siteTitle: data.metadata.publisher, title: data.metadata.title, @@ -17,6 +46,14 @@ module.exports = class WebmentionMetadata { body: data.body, contentType: data.contentType }; + + if (mappedUrl.href !== url.href) { + // Still need to fetch body and contentType separately now + // For verification + const {body, contentType} = await oembedService.fetchPageHtml(url); + result.body = body; + result.contentType = contentType; + } return result; } }; diff --git a/ghost/core/core/server/services/mentions/service.js b/ghost/core/core/server/services/mentions/service.js index 1690948f1e..04dca0fc7b 100644 --- a/ghost/core/core/server/services/mentions/service.js +++ b/ghost/core/core/server/services/mentions/service.js @@ -28,6 +28,7 @@ module.exports = { /** @type {import('@tryghost/webmentions/lib/MentionsAPI')} */ api: null, controller: new MentionController(), + metadata: new WebmentionMetadata(), /** @type {import('@tryghost/webmentions/lib/MentionSendingService')} */ sendingService: null, didInit: false, @@ -40,7 +41,7 @@ module.exports = { MentionModel: models.Mention, DomainEvents }); - const webmentionMetadata = new WebmentionMetadata(); + const webmentionMetadata = this.metadata; const discoveryService = new MentionDiscoveryService({externalRequest}); const resourceService = new ResourceService({ urlUtils, diff --git a/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js b/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js index cee6ae13c8..f674a8a155 100644 --- a/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js +++ b/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js @@ -51,6 +51,17 @@ class RecommendationServiceWrapper { // eslint-disable-next-line no-console this.service.init().catch(console.error); + + // Add mapper to WebmentionMetadata + mentions.metadata.addMapper((url) => { + const p = '/.well-known/recommendations.json'; + if (url.pathname.endsWith(p)) { + // Strip p + const newUrl = new URL(url.toString()); + newUrl.pathname = newUrl.pathname.slice(0, -p.length); + return newUrl; + } + }); } } diff --git a/ghost/recommendations/src/RecommendationService.ts b/ghost/recommendations/src/RecommendationService.ts index c376c7b260..4614984ac3 100644 --- a/ghost/recommendations/src/RecommendationService.ts +++ b/ghost/recommendations/src/RecommendationService.ts @@ -43,7 +43,7 @@ export class RecommendationService { async addRecommendation(addRecommendation: AddRecommendation) { const recommendation = Recommendation.create(addRecommendation); - this.repository.save(recommendation); + await this.repository.save(recommendation); await this.updateWellknown(); // Only send an update for the mentioned URL