mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-10-04 08:47:41 +03: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
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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,6 +91,8 @@ 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:
|
||||||
@ -79,20 +102,69 @@ const getActivityBadge = (activity: Activity): AvatarBadge => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
||||||
|
// 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);
|
return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type);
|
||||||
})
|
})
|
||||||
.reverse(); // Endpoint currently returns items oldest-newest
|
// API endpoint currently returns items oldest-newest, so reverse them
|
||||||
const {data: followerData} = useFollowersForUser(user);
|
// to show the most recent activities first
|
||||||
const followers = followerData || [];
|
.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();
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user