mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
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:
parent
028c1a6929
commit
cbacea418f
3 changed files with 134 additions and 26 deletions
apps/admin-x-activitypub/src
|
@ -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) {
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
|
|
|
@ -53,6 +53,24 @@ export class ActivityPubAPI {
|
|||
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() {
|
||||
return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl);
|
||||
}
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import React from 'react';
|
||||
|
||||
import APAvatar, {AvatarBadge} from './global/APAvatar';
|
||||
import ActivityItem from './activities/ActivityItem';
|
||||
import MainNavigation from './navigation/MainNavigation';
|
||||
import React from 'react';
|
||||
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 {}
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
enum ACTVITY_TYPE {
|
||||
CREATE = 'Create',
|
||||
LIKE = 'Like',
|
||||
FOLLOW = 'Follow'
|
||||
}
|
||||
|
@ -23,6 +27,8 @@ type Actor = {
|
|||
type ActivityObject = {
|
||||
name: string
|
||||
url: string
|
||||
inReplyTo: string | null
|
||||
content: string
|
||||
}
|
||||
|
||||
type Activity = {
|
||||
|
@ -32,15 +38,16 @@ type Activity = {
|
|||
actor: Actor
|
||||
}
|
||||
|
||||
const getActorUsername = (actor: Actor): string => {
|
||||
const url = new URL(actor.url);
|
||||
const domain = url.hostname;
|
||||
|
||||
return `@${actor.preferredUsername}@${domain}`;
|
||||
};
|
||||
|
||||
const getActivityDescription = (activity: Activity): string => {
|
||||
const getActivityDescription = (activity: Activity, activityObjectsMap: Map<string, ActivityObject>): string => {
|
||||
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:
|
||||
return 'Followed you';
|
||||
case ACTVITY_TYPE.LIKE:
|
||||
|
@ -52,6 +59,20 @@ const getActivityDescription = (activity: Activity): string => {
|
|||
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 => {
|
||||
if (activity.object) {
|
||||
return activity.object.url;
|
||||
|
@ -70,29 +91,80 @@ const getActorUrl = (activity: Activity): string | null => {
|
|||
|
||||
const getActivityBadge = (activity: Activity): AvatarBadge => {
|
||||
switch (activity.type) {
|
||||
case ACTVITY_TYPE.CREATE:
|
||||
return 'user-fill'; // TODO: Change this
|
||||
case ACTVITY_TYPE.FOLLOW:
|
||||
return 'user-fill';
|
||||
case ACTVITY_TYPE.LIKE:
|
||||
if (activity.object) {
|
||||
return 'heart-fill';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isFollower = (id: string, followerIds: string[]): boolean => {
|
||||
return followerIds.includes(id);
|
||||
}
|
||||
};
|
||||
|
||||
const Activities: React.FC<ActivitiesProps> = ({}) => {
|
||||
const user = 'index';
|
||||
const {data: activityData} = useBrowseInboxForUser(user);
|
||||
const activities = (activityData || [])
|
||||
.filter((activity) => {
|
||||
return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type);
|
||||
})
|
||||
.reverse(); // Endpoint currently returns items oldest-newest
|
||||
const {data: followerData} = useFollowersForUser(user);
|
||||
const followers = followerData || [];
|
||||
|
||||
// Retrieve activities from the inbox AND the outbox
|
||||
// Why the need for the outbox? The outbox contains activities that the user
|
||||
// has performed, and we sometimes need information about the object
|
||||
// associated with the activity (i.e when displaying the name of an article
|
||||
// that a reply was made to)
|
||||
const {data: inboxActivities = []} = useBrowseInboxForUser(user);
|
||||
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 (
|
||||
<>
|
||||
|
@ -109,11 +181,12 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
|
|||
<div>
|
||||
<div className='text-grey-600'>
|
||||
<span className='mr-1 font-bold text-black'>{activity.actor.name}</span>
|
||||
{getActorUsername(activity.actor)}
|
||||
{getUsername(activity.actor)}
|
||||
</div>
|
||||
<div className='text-sm'>{getActivityDescription(activity)}</div>
|
||||
<div className='text-sm'>{getActivityDescription(activity, activityObjectsMap)}</div>
|
||||
{getExtendedDescription(activity)}
|
||||
</div>
|
||||
{isFollower(activity.actor.id, followers) === false && (
|
||||
{isFollower(activity.actor.id) === false && (
|
||||
<Button className='ml-auto' label='Follow' link onClick={(e) => {
|
||||
e?.preventDefault();
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue