Added reply handling to the notifications tab of the admin-x-activitypub app (#20956)

refs
[AP-279](https://linear.app/tryghost/issue/AP-279/handle-incoming-replies)

Added reply handling to the notifications tab of the admin-x-activitypub
app so that replies to posts can be viewed by the user
This commit is contained in:
Michael Barrett 2024-09-11 17:36:02 +01:00 committed by GitHub
parent 028c1a6929
commit cbacea418f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 134 additions and 26 deletions

View File

@ -24,6 +24,23 @@ export function useBrowseInboxForUser(handle: string) {
}); });
} }
export function useBrowseOutboxForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`outbox:${handle}`],
async queryFn() {
return api.getOutbox();
}
});
}
export function useFollowersForUser(handle: string) { export function useFollowersForUser(handle: string) {
const site = useBrowseSite(); const site = useBrowseSite();
const siteData = site.data?.site; const siteData = site.data?.site;

View File

@ -53,6 +53,24 @@ export class ActivityPubAPI {
return []; return [];
} }
get outboxApiUrl() {
return new URL(`.ghost/activitypub/outbox/${this.handle}`, this.apiUrl);
}
async getOutbox(): Promise<Activity[]> {
const json = await this.fetchJSON(this.outboxApiUrl);
if (json === null) {
return [];
}
if ('orderedItems' in json) {
return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems];
}
if ('items' in json) {
return Array.isArray(json.items) ? json.items : [json.items];
}
return [];
}
get followingApiUrl() { get followingApiUrl() {
return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl); return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl);
} }

View File

@ -1,14 +1,18 @@
import React from 'react';
import APAvatar, {AvatarBadge} from './global/APAvatar'; import APAvatar, {AvatarBadge} from './global/APAvatar';
import ActivityItem from './activities/ActivityItem'; import ActivityItem from './activities/ActivityItem';
import MainNavigation from './navigation/MainNavigation'; import MainNavigation from './navigation/MainNavigation';
import React from 'react';
import {Button} from '@tryghost/admin-x-design-system'; import {Button} from '@tryghost/admin-x-design-system';
import {useBrowseInboxForUser, useFollowersForUser} from '../MainContent';
import getUsername from '../utils/get-username';
import {useBrowseInboxForUser, useBrowseOutboxForUser, useFollowersForUser} from '../MainContent';
interface ActivitiesProps {} interface ActivitiesProps {}
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
enum ACTVITY_TYPE { enum ACTVITY_TYPE {
CREATE = 'Create',
LIKE = 'Like', LIKE = 'Like',
FOLLOW = 'Follow' FOLLOW = 'Follow'
} }
@ -23,6 +27,8 @@ type Actor = {
type ActivityObject = { type ActivityObject = {
name: string name: string
url: string url: string
inReplyTo: string | null
content: string
} }
type Activity = { type Activity = {
@ -32,15 +38,16 @@ type Activity = {
actor: Actor actor: Actor
} }
const getActorUsername = (actor: Actor): string => { const getActivityDescription = (activity: Activity, activityObjectsMap: Map<string, ActivityObject>): string => {
const url = new URL(actor.url);
const domain = url.hostname;
return `@${actor.preferredUsername}@${domain}`;
};
const getActivityDescription = (activity: Activity): string => {
switch (activity.type) { switch (activity.type) {
case ACTVITY_TYPE.CREATE:
const object = activityObjectsMap.get(activity.object?.inReplyTo || '');
if (object?.name) {
return `Commented on your article "${object.name}"`;
}
return '';
case ACTVITY_TYPE.FOLLOW: case ACTVITY_TYPE.FOLLOW:
return 'Followed you'; return 'Followed you';
case ACTVITY_TYPE.LIKE: case ACTVITY_TYPE.LIKE:
@ -52,6 +59,20 @@ const getActivityDescription = (activity: Activity): string => {
return ''; return '';
}; };
const getExtendedDescription = (activity: Activity): JSX.Element | null => {
// If the activity is a reply
if (Boolean(activity.type === ACTVITY_TYPE.CREATE && activity.object?.inReplyTo)) {
return (
<div
dangerouslySetInnerHTML={{__html: activity.object?.content || ''}}
className='ml-2 mt-2 text-sm text-grey-600'
/>
);
}
return null;
};
const getActivityUrl = (activity: Activity): string | null => { const getActivityUrl = (activity: Activity): string | null => {
if (activity.object) { if (activity.object) {
return activity.object.url; return activity.object.url;
@ -70,29 +91,80 @@ const getActorUrl = (activity: Activity): string | null => {
const getActivityBadge = (activity: Activity): AvatarBadge => { const getActivityBadge = (activity: Activity): AvatarBadge => {
switch (activity.type) { switch (activity.type) {
case ACTVITY_TYPE.CREATE:
return 'user-fill'; // TODO: Change this
case ACTVITY_TYPE.FOLLOW: case ACTVITY_TYPE.FOLLOW:
return 'user-fill'; return 'user-fill';
case ACTVITY_TYPE.LIKE: case ACTVITY_TYPE.LIKE:
if (activity.object) { if (activity.object) {
return 'heart-fill'; return 'heart-fill';
} }
} }
};
const isFollower = (id: string, followerIds: string[]): boolean => {
return followerIds.includes(id);
}; };
const Activities: React.FC<ActivitiesProps> = ({}) => { const Activities: React.FC<ActivitiesProps> = ({}) => {
const user = 'index'; const user = 'index';
const {data: activityData} = useBrowseInboxForUser(user);
const activities = (activityData || []) // Retrieve activities from the inbox AND the outbox
.filter((activity) => { // Why the need for the outbox? The outbox contains activities that the user
return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type); // has performed, and we sometimes need information about the object
}) // associated with the activity (i.e when displaying the name of an article
.reverse(); // Endpoint currently returns items oldest-newest // that a reply was made to)
const {data: followerData} = useFollowersForUser(user); const {data: inboxActivities = []} = useBrowseInboxForUser(user);
const followers = followerData || []; const {data: outboxActivities = []} = useBrowseOutboxForUser(user);
// Create a map of activity objects from activities in the inbox and outbox.
// This allows us to quickly look up an object associated with an activity
// We could just make a http request to get the object, but this is more
// efficient seeming though we already have the data in the inbox and outbox
const activityObjectsMap = new Map<string, ActivityObject>();
outboxActivities.forEach((activity) => {
if (activity.object) {
activityObjectsMap.set(activity.object.id, activity.object);
}
});
inboxActivities.forEach((activity) => {
if (activity.object) {
activityObjectsMap.set(activity.object.id, activity.object);
}
});
// Filter the activities to show
const activities = inboxActivities.filter((activity) => {
// Only show "Create" activities that are replies to a post created
// by the user
if (activity.type === ACTVITY_TYPE.CREATE) {
const replyToObject = activityObjectsMap.get(activity.object?.inReplyTo || '');
// If the reply object is not found, or it doesn't have a URL or
// name, do not show the activity
if (!replyToObject || !replyToObject.url || !replyToObject.name) {
return false;
}
// Verify that the reply is to a post created by the user by
// checking that the hostname associated with the reply object
// is the same as the hostname of the site. This is not a bullet
// proof check, but it's a good enough for now
const hostname = new URL(window.location.href).hostname;
const replyToObjectHostname = new URL(replyToObject.url).hostname;
return hostname === replyToObjectHostname;
}
return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type);
})
// API endpoint currently returns items oldest-newest, so reverse them
// to show the most recent activities first
.reverse();
// Retrieve followers for the user
const {data: followers = []} = useFollowersForUser(user);
const isFollower = (id: string): boolean => {
return followers.includes(id);
};
return ( return (
<> <>
@ -109,11 +181,12 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
<div> <div>
<div className='text-grey-600'> <div className='text-grey-600'>
<span className='mr-1 font-bold text-black'>{activity.actor.name}</span> <span className='mr-1 font-bold text-black'>{activity.actor.name}</span>
{getActorUsername(activity.actor)} {getUsername(activity.actor)}
</div> </div>
<div className='text-sm'>{getActivityDescription(activity)}</div> <div className='text-sm'>{getActivityDescription(activity, activityObjectsMap)}</div>
{getExtendedDescription(activity)}
</div> </div>
{isFollower(activity.actor.id, followers) === false && ( {isFollower(activity.actor.id) === false && (
<Button className='ml-auto' label='Follow' link onClick={(e) => { <Button className='ml-auto' label='Follow' link onClick={(e) => {
e?.preventDefault(); e?.preventDefault();