mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
Release updated activitypub notifications (#22712)
ref https://linear.app/ghost/issue/AP-1005 Removed `notificationsV2` feature flag so that the new notifications section is live in `admin-x-activitypub` as well as removing redundant methods associated with the old notifications implementation
This commit is contained in:
parent
de17e5546e
commit
d8a47162d4
7 changed files with 117 additions and 699 deletions
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@tryghost/admin-x-activitypub",
|
||||
"version": "0.6.24",
|
||||
"version": "0.6.25",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -223,59 +223,6 @@ export class ActivityPubAPI {
|
|||
await this.fetchJSON(url, 'POST');
|
||||
}
|
||||
|
||||
get activitiesApiUrl() {
|
||||
return new URL(`.ghost/activitypub/activities/${this.handle}`, this.apiUrl);
|
||||
}
|
||||
|
||||
async getActivities(
|
||||
includeOwn: boolean = false,
|
||||
includeReplies: boolean = false,
|
||||
filter: {type?: string[]} | null = null,
|
||||
limit: number = 50,
|
||||
cursor?: string
|
||||
): Promise<{data: Activity[], next: string | null}> {
|
||||
const url = new URL(this.activitiesApiUrl);
|
||||
|
||||
url.searchParams.set('limit', limit.toString());
|
||||
|
||||
if (includeOwn) {
|
||||
url.searchParams.set('includeOwn', includeOwn.toString());
|
||||
}
|
||||
if (includeReplies) {
|
||||
url.searchParams.set('includeReplies', includeReplies.toString());
|
||||
}
|
||||
if (filter) {
|
||||
url.searchParams.set('filter', JSON.stringify(filter));
|
||||
}
|
||||
if (cursor) {
|
||||
url.searchParams.set('cursor', cursor);
|
||||
}
|
||||
|
||||
const json = await this.fetchJSON(url);
|
||||
|
||||
if (json === null) {
|
||||
return {
|
||||
data: [],
|
||||
next: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!('items' in json)) {
|
||||
return {
|
||||
data: [],
|
||||
next: null
|
||||
};
|
||||
}
|
||||
|
||||
const data = Array.isArray(json.items) ? json.items : [];
|
||||
const next = 'next' in json && typeof json.next === 'string' ? json.next : null;
|
||||
|
||||
return {
|
||||
data,
|
||||
next
|
||||
};
|
||||
}
|
||||
|
||||
async reply(id: string, content: string): Promise<Activity> {
|
||||
const url = new URL(`.ghost/activitypub/actions/reply/${encodeURIComponent(id)}`, this.apiUrl);
|
||||
const response = await this.fetchJSON(url, 'POST', {content});
|
||||
|
|
|
@ -62,16 +62,6 @@ const QUERY_KEYS = {
|
|||
profileFollowing: (profileHandle: string) => ['profile_following', profileHandle],
|
||||
account: (handle: string) => ['account', handle],
|
||||
accountFollows: (handle: string, type: AccountFollowsType) => ['account_follows', handle, type],
|
||||
activities: (
|
||||
handle: string,
|
||||
key?: string | null,
|
||||
options?: {
|
||||
includeOwn?: boolean,
|
||||
includeReplies?: boolean,
|
||||
filter?: {type?: string[]} | null,
|
||||
limit?: number,
|
||||
}
|
||||
) => ['activities', handle, key, options].filter(value => value !== undefined),
|
||||
searchResults: (query: string) => ['search_results', query],
|
||||
suggestedProfiles: (limit: number) => ['suggested_profiles', limit],
|
||||
exploreProfiles: (handle: string) => ['explore_profiles', handle],
|
||||
|
@ -572,7 +562,7 @@ export function useFollowMutationForUser(handle: string, onSuccess: () => void,
|
|||
if (!currentAccount) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
|
||||
const newFollower = {
|
||||
actor: {
|
||||
id: currentAccount.id,
|
||||
|
@ -603,70 +593,6 @@ export function useFollowMutationForUser(handle: string, onSuccess: () => void,
|
|||
});
|
||||
}
|
||||
|
||||
export const GET_ACTIVITIES_QUERY_KEY_INBOX = 'inbox';
|
||||
export const GET_ACTIVITIES_QUERY_KEY_FEED = 'feed';
|
||||
export const GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS = 'notifications';
|
||||
|
||||
export function useActivitiesForUser({
|
||||
handle,
|
||||
includeOwn = false,
|
||||
includeReplies = false,
|
||||
filter = null,
|
||||
limit = undefined,
|
||||
key = null
|
||||
}: {
|
||||
handle: string;
|
||||
includeOwn?: boolean;
|
||||
includeReplies?: boolean;
|
||||
filter?: {type?: string[]} | null;
|
||||
limit?: number;
|
||||
key?: string | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = QUERY_KEYS.activities(handle, key, {includeOwn, includeReplies, filter});
|
||||
|
||||
const getActivitiesQuery = useInfiniteQuery({
|
||||
queryKey,
|
||||
staleTime: 5 * 60 * 1000, // 5m
|
||||
async queryFn({pageParam}: {pageParam?: string}) {
|
||||
const siteUrl = await getSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
|
||||
return api.getActivities(includeOwn, includeReplies, filter, limit, pageParam);
|
||||
},
|
||||
getNextPageParam(prevPage) {
|
||||
return prevPage.next;
|
||||
}
|
||||
});
|
||||
|
||||
const updateActivity = (id: string, updated: Partial<Activity>) => {
|
||||
// Update the activity stored in the activities query cache
|
||||
queryClient.setQueryData(queryKey, (current: {pages: {data: Activity[]}[]} | undefined) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
pages: current.pages.map((page: {data: Activity[]}) => {
|
||||
return {
|
||||
...page,
|
||||
data: page.data.map((item: Activity) => {
|
||||
if (item.id === id) {
|
||||
return {...item, ...updated};
|
||||
}
|
||||
|
||||
return item;
|
||||
})
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {getActivitiesQuery, updateActivity};
|
||||
}
|
||||
|
||||
export function useSearchForUser(handle: string, query: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = QUERY_KEYS.searchResults(query);
|
||||
|
|
|
@ -3,7 +3,6 @@ import {useLocation} from '@tryghost/admin-x-framework';
|
|||
|
||||
// Define all available feature flags here
|
||||
export const FEATURE_FLAGS = [
|
||||
'notificationsV2',
|
||||
'feed-routes'
|
||||
] as const;
|
||||
|
||||
|
|
|
@ -1,107 +1,82 @@
|
|||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import {LucideIcon, Skeleton} from '@tryghost/shade';
|
||||
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import {Activity, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, LoadingIndicator} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import APAvatar from '@components/global/APAvatar';
|
||||
import ArticleModal from '@components/feed/ArticleModal';
|
||||
import NotificationItem from '@components/activities/NotificationItem';
|
||||
import Separator from '@components/global/Separator';
|
||||
|
||||
import ArticleModal from '@src/components/feed/ArticleModal';
|
||||
import Layout from '@components/layout';
|
||||
import getUsername from '@utils/get-username';
|
||||
import truncate from '@utils/truncate';
|
||||
import {EmptyViewIcon, EmptyViewIndicator} from '@src/components/global/EmptyViewIndicator';
|
||||
import {
|
||||
GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS,
|
||||
useActivitiesForUser,
|
||||
useUserDataForUser
|
||||
} from '@hooks/use-activity-pub-queries';
|
||||
import {type NotificationType} from '@components/activities/NotificationIcon';
|
||||
import {Notification} from '@src/api/activitypub';
|
||||
import {handleProfileClick} from '@utils/handle-profile-click';
|
||||
import {stripHtml} from '@src/utils/content-formatters';
|
||||
import {useNotificationsForUser} from '@hooks/use-activity-pub-queries';
|
||||
|
||||
interface NotificationsProps {}
|
||||
|
||||
enum ACTIVITY_TYPE {
|
||||
CREATE = 'Create',
|
||||
LIKE = 'Like',
|
||||
FOLLOW = 'Follow',
|
||||
REPOST = 'Announce'
|
||||
}
|
||||
|
||||
interface GroupedActivity {
|
||||
type: ACTIVITY_TYPE;
|
||||
actors: ActorProperties[];
|
||||
object: ObjectProperties;
|
||||
id?: string;
|
||||
interface NotificationGroup {
|
||||
id: string;
|
||||
type: Notification['type'];
|
||||
actors: Notification['actor'][];
|
||||
post: Notification['post'];
|
||||
inReplyTo: Notification['inReplyTo'];
|
||||
}
|
||||
|
||||
interface NotificationGroupDescriptionProps {
|
||||
group: GroupedActivity;
|
||||
group: NotificationGroup;
|
||||
}
|
||||
|
||||
const getActivityBadge = (activity: GroupedActivity): NotificationType => {
|
||||
switch (activity.type) {
|
||||
case ACTIVITY_TYPE.CREATE:
|
||||
return 'reply';
|
||||
case ACTIVITY_TYPE.FOLLOW:
|
||||
return 'follow';
|
||||
case ACTIVITY_TYPE.LIKE:
|
||||
return 'like';
|
||||
case ACTIVITY_TYPE.REPOST:
|
||||
return 'repost';
|
||||
}
|
||||
};
|
||||
function groupNotifications(notifications: Notification[]): NotificationGroup[] {
|
||||
const groups: {
|
||||
[key: string]: NotificationGroup
|
||||
} = {};
|
||||
|
||||
const groupActivities = (activities: Activity[]): GroupedActivity[] => {
|
||||
const groups: {[key: string]: GroupedActivity} = {};
|
||||
|
||||
// Activities are already sorted by time from the API
|
||||
activities.forEach((activity) => {
|
||||
notifications.forEach((notification) => {
|
||||
let groupKey = '';
|
||||
|
||||
switch (activity.type) {
|
||||
case ACTIVITY_TYPE.FOLLOW:
|
||||
// Group follows that are next to each other in the array
|
||||
groupKey = `follow_${activity.type}`;
|
||||
break;
|
||||
case ACTIVITY_TYPE.LIKE:
|
||||
if (activity.object?.id) {
|
||||
switch (notification.type) {
|
||||
case 'like':
|
||||
if (notification.post?.id) {
|
||||
// Group likes by the target object
|
||||
groupKey = `like_${activity.object.id}`;
|
||||
groupKey = `like_${notification.post.id}`;
|
||||
}
|
||||
break;
|
||||
case ACTIVITY_TYPE.REPOST:
|
||||
if (activity.object?.id) {
|
||||
case 'reply':
|
||||
// Don't group replies
|
||||
groupKey = `reply_${notification.id}`;
|
||||
break;
|
||||
case 'repost':
|
||||
if (notification.post?.id) {
|
||||
// Group reposts by the target object
|
||||
groupKey = `announce_${activity.object.id}`;
|
||||
groupKey = `repost_${notification.post.id}`;
|
||||
}
|
||||
break;
|
||||
case ACTIVITY_TYPE.CREATE:
|
||||
// Don't group creates/replies
|
||||
groupKey = `create_${activity.id}`;
|
||||
case 'follow':
|
||||
// Group follows that are next to each other in the array
|
||||
groupKey = `follow_${notification.type}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = {
|
||||
type: activity.type as ACTIVITY_TYPE,
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
actors: [],
|
||||
object: activity.object,
|
||||
id: activity.id
|
||||
post: notification.post,
|
||||
inReplyTo: notification.inReplyTo
|
||||
};
|
||||
}
|
||||
|
||||
// Add actor if not already in the group
|
||||
if (!groups[groupKey].actors.find(a => a.id === activity.actor.id)) {
|
||||
groups[groupKey].actors.push(activity.actor);
|
||||
if (!groups[groupKey].actors.find(a => a.id === notification.actor.id)) {
|
||||
groups[groupKey].actors.push(notification.actor);
|
||||
}
|
||||
});
|
||||
|
||||
// Return in same order as original activities
|
||||
return Object.values(groups);
|
||||
};
|
||||
|
||||
|
@ -115,15 +90,19 @@ const NotificationGroupDescription: React.FC<NotificationGroupDescriptionProps>
|
|||
<>
|
||||
<span
|
||||
className={actorClass}
|
||||
onClick={e => handleProfileClick(firstActor, e)}
|
||||
>{firstActor.name}</span>
|
||||
onClick={e => handleProfileClick(firstActor.handle, e)}
|
||||
>
|
||||
{firstActor.name}
|
||||
</span>
|
||||
{secondActor && (
|
||||
<>
|
||||
{hasOthers ? ', ' : ' and '}
|
||||
<span
|
||||
className={actorClass}
|
||||
onClick={e => handleProfileClick(secondActor, e)}
|
||||
>{secondActor.name}</span>
|
||||
onClick={e => handleProfileClick(secondActor.handle, e)}
|
||||
>
|
||||
{secondActor.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{hasOthers && ' and others'}
|
||||
|
@ -131,31 +110,29 @@ const NotificationGroupDescription: React.FC<NotificationGroupDescriptionProps>
|
|||
);
|
||||
|
||||
switch (group.type) {
|
||||
case ACTIVITY_TYPE.FOLLOW:
|
||||
case 'follow':
|
||||
return <>{actorText} started following you</>;
|
||||
case ACTIVITY_TYPE.LIKE:
|
||||
return <>{actorText} liked your {group.object?.type === 'Article' ? 'post' : 'note'} <span className='font-semibold'>{group.object?.name || ''}</span></>;
|
||||
case ACTIVITY_TYPE.REPOST:
|
||||
return <>{actorText} reposted your {group.object?.type === 'Article' ? 'post' : 'note'} <span className='font-semibold'>{group.object?.name || ''}</span></>;
|
||||
case ACTIVITY_TYPE.CREATE:
|
||||
if (group.object?.inReplyTo && typeof group.object?.inReplyTo !== 'string') {
|
||||
let content = stripHtml(group.object.inReplyTo.content || '');
|
||||
case 'like':
|
||||
return <>{actorText} liked your {group.post?.type === 'article' ? 'post' : 'note'} <span className='font-semibold'>{group.post?.title || ''}</span></>;
|
||||
case 'repost':
|
||||
return <>{actorText} reposted your {group.post?.type === 'article' ? 'post' : 'note'} <span className='font-semibold'>{group.post?.title || ''}</span></>;
|
||||
case 'reply':
|
||||
if (group.inReplyTo && typeof group.inReplyTo !== 'string') {
|
||||
let content = stripHtml(group.inReplyTo.content || '');
|
||||
|
||||
// If the post has a name, use that instead of the content (short
|
||||
// form posts do not have a name)
|
||||
if (group.object.inReplyTo.name) {
|
||||
content = stripHtml(group.object.inReplyTo.name);
|
||||
// If the post has a title, use that instead of the content (notes do not have a title)
|
||||
if (group.inReplyTo.title) {
|
||||
content = stripHtml(group.inReplyTo.title);
|
||||
}
|
||||
|
||||
return <>{actorText} replied to your post <span className='font-semibold'>{truncate(content, 80)}</span></>;
|
||||
return <>{actorText} replied to your {group.post?.type === 'article' ? 'post' : 'note'} <span className='font-semibold'>{truncate(content, 80)}</span></>;
|
||||
}
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const Notifications: React.FC<NotificationsProps> = () => {
|
||||
const user = 'index';
|
||||
|
||||
const Notifications: React.FC = () => {
|
||||
const [openStates, setOpenStates] = React.useState<{[key: string]: boolean}>({});
|
||||
|
||||
const toggleOpen = (groupId: string) => {
|
||||
|
@ -167,78 +144,14 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
|
||||
const maxAvatars = 5;
|
||||
|
||||
const {data: userProfile, isLoading: isLoadingProfile} = useUserDataForUser(user) as {data: ActorProperties | null, isLoading: boolean};
|
||||
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = useNotificationsForUser('index');
|
||||
|
||||
const {getActivitiesQuery} = useActivitiesForUser({
|
||||
handle: user,
|
||||
includeOwn: true,
|
||||
includeReplies: true,
|
||||
filter: {
|
||||
type: ['Follow', 'Like', `Create:Note`, `Announce:Note`, `Announce:Article`]
|
||||
},
|
||||
limit: 120,
|
||||
key: GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS
|
||||
});
|
||||
|
||||
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading: isLoadingActivities} = getActivitiesQuery;
|
||||
|
||||
const isLoading = isLoadingProfile === true || isLoadingActivities === true;
|
||||
|
||||
const groupedActivities = (data?.pages.flatMap((page) => {
|
||||
const filtered = page.data
|
||||
// Remove duplicates
|
||||
.filter(
|
||||
(activity, index, self) => index === self.findIndex(a => a.id === activity.id)
|
||||
)
|
||||
// Remove our own likes
|
||||
.filter((activity) => {
|
||||
if (activity.type === ACTIVITY_TYPE.LIKE && activity.actor?.id === userProfile?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
// Remove follower likes if they are not for our own posts
|
||||
.filter((activity) => {
|
||||
if (activity.type === ACTIVITY_TYPE.LIKE && activity.object?.attributedTo?.id !== userProfile?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
// Remove reposts that are not for our own posts
|
||||
.filter((activity) => {
|
||||
if (activity.type === ACTIVITY_TYPE.REPOST && activity.object?.attributedTo?.id !== userProfile?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
// Remove create activities that are not replies to our own posts
|
||||
.filter((activity) => {
|
||||
if (
|
||||
activity.type === ACTIVITY_TYPE.CREATE &&
|
||||
activity.object?.inReplyTo?.attributedTo?.id !== userProfile?.id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
// Remove our own create activities
|
||||
.filter((activity) => {
|
||||
if (
|
||||
activity.type === ACTIVITY_TYPE.CREATE &&
|
||||
activity.actor?.id === userProfile?.id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return groupActivities(filtered);
|
||||
}) ?? Array(5).fill({actors: [{}]}));
|
||||
const notificationGroups = (
|
||||
data?.pages.flatMap((page) => {
|
||||
return groupNotifications(page.notifications);
|
||||
})
|
||||
// If no notifications, return 5 empty groups for the loading state
|
||||
?? Array(5).fill({actors: [{}]}));
|
||||
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -265,72 +178,41 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
};
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const [showLoadingMessage, setShowLoadingMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutID: number;
|
||||
|
||||
if (isLoading) {
|
||||
timeoutID = setTimeout(() => {
|
||||
setShowLoadingMessage(true);
|
||||
}, 3000);
|
||||
} else {
|
||||
setShowLoadingMessage(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutID);
|
||||
};
|
||||
}, [isLoading]);
|
||||
|
||||
const handleActivityClick = (group: GroupedActivity, index: number) => {
|
||||
const handleNotificationClick = (group: NotificationGroup, index: number) => {
|
||||
switch (group.type) {
|
||||
case ACTIVITY_TYPE.CREATE:
|
||||
case 'like':
|
||||
NiceModal.show(ArticleModal, {
|
||||
activityId: group.object.id,
|
||||
object: group.object,
|
||||
actor: group.actors[0],
|
||||
focusReplies: true,
|
||||
width: typeof group.object?.inReplyTo === 'object' && group.object?.inReplyTo?.type === 'Article' ? 'wide' : 'narrow'
|
||||
remotePostId: group.post?.id || '',
|
||||
width: group.post?.type === 'article' ? 'wide' : 'narrow'
|
||||
});
|
||||
break;
|
||||
case ACTIVITY_TYPE.LIKE:
|
||||
case 'reply':
|
||||
NiceModal.show(ArticleModal, {
|
||||
activityId: group.id,
|
||||
object: group.object,
|
||||
actor: group.object.attributedTo as ActorProperties,
|
||||
width: group.object?.type === 'Article' ? 'wide' : 'narrow'
|
||||
remotePostId: group.post?.id || '',
|
||||
width: group.inReplyTo?.type === 'article' ? 'wide' : 'narrow'
|
||||
});
|
||||
break;
|
||||
case ACTIVITY_TYPE.FOLLOW:
|
||||
case 'repost':
|
||||
NiceModal.show(ArticleModal, {
|
||||
remotePostId: group.post?.id || '',
|
||||
width: group.post?.type === 'article' ? 'wide' : 'narrow'
|
||||
});
|
||||
break;
|
||||
case 'follow':
|
||||
if (group.actors.length > 1) {
|
||||
toggleOpen(group.id || `${group.type}_${index}`);
|
||||
} else {
|
||||
handleProfileClick(group.actors[0]);
|
||||
handleProfileClick(group.actors[0].handle);
|
||||
}
|
||||
break;
|
||||
case ACTIVITY_TYPE.REPOST:
|
||||
NiceModal.show(ArticleModal, {
|
||||
activityId: group.id,
|
||||
object: group.object,
|
||||
actor: group.object.attributedTo as ActorProperties,
|
||||
width: group.object?.type === 'Article' ? 'wide' : 'narrow'
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{isLoading && showLoadingMessage && (
|
||||
<div className='absolute bottom-8 left-8 right-[calc(292px+64px)] flex animate-fade-in items-start justify-center rounded-md bg-grey-100 px-3 py-2 font-medium backdrop-blur-md'>
|
||||
<LucideIcon.Gauge className='mr-1.5 min-w-5 text-purple' size={20} strokeWidth={1.5} />
|
||||
Notifications are a little slow at the moment, we're working on improving the performance.
|
||||
</div>
|
||||
)}
|
||||
<div className='z-0 flex w-full flex-col items-center'>
|
||||
{
|
||||
isLoading === false && groupedActivities.length === 0 && (
|
||||
isLoading === false && notificationGroups.length === 0 && (
|
||||
<EmptyViewIndicator>
|
||||
<EmptyViewIcon><LucideIcon.Bell /></EmptyViewIcon>
|
||||
Quiet for now, but not for long! When someone likes, boosts, or replies to you, you'll find it here.
|
||||
|
@ -338,24 +220,32 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
)
|
||||
}
|
||||
{
|
||||
(groupedActivities.length > 0) && (
|
||||
(notificationGroups.length > 0) && (
|
||||
<>
|
||||
<div className='my-8 flex w-full max-w-[620px] flex-col'>
|
||||
{groupedActivities.map((group, index) => (
|
||||
{notificationGroups.map((group, index) => (
|
||||
<React.Fragment key={group.id || `${group.type}_${index}`}>
|
||||
<NotificationItem
|
||||
className='hover:bg-gray-75 dark:hover:bg-gray-950'
|
||||
onClick={() => handleActivityClick(group, index)}
|
||||
onClick={() => handleNotificationClick(group, index)}
|
||||
>
|
||||
{!isLoading ? <NotificationItem.Icon type={getActivityBadge(group)} /> : <Skeleton className='rounded-full' containerClassName='flex h-10 w-10' />}
|
||||
{isLoading ?
|
||||
<Skeleton className='rounded-full' containerClassName='flex h-10 w-10' /> :
|
||||
<NotificationItem.Icon type={group.type} />
|
||||
}
|
||||
<NotificationItem.Avatars>
|
||||
<div className='flex flex-col'>
|
||||
<div className='mt-0.5 flex items-center gap-1.5'>
|
||||
{!openStates[group.id || `${group.type}_${index}`] && group.actors.slice(0, maxAvatars).map((actor: ActorProperties) => (
|
||||
<APAvatar
|
||||
key={actor.id}
|
||||
author={actor}
|
||||
isLoading={isLoading}
|
||||
author={{
|
||||
icon: {
|
||||
url: actor.avatarUrl || ''
|
||||
},
|
||||
name: actor.name,
|
||||
handle: actor.handle
|
||||
}}
|
||||
size='notification'
|
||||
/>
|
||||
))}
|
||||
|
@ -388,11 +278,17 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
<div
|
||||
key={actor.id}
|
||||
className='flex items-center hover:opacity-80'
|
||||
onClick={e => handleProfileClick(actor, e)}
|
||||
onClick={e => handleProfileClick(actor.handle, e)}
|
||||
>
|
||||
<APAvatar author={actor} size='xs' />
|
||||
<APAvatar author={{
|
||||
icon: {
|
||||
url: actor.avatarUrl || ''
|
||||
},
|
||||
name: actor.name,
|
||||
handle: actor.handle
|
||||
}} size='xs' />
|
||||
<span className='ml-2 text-base font-semibold dark:text-white'>{actor.name}</span>
|
||||
<span className='ml-1 text-base text-gray-700 dark:text-gray-600'>{getUsername(actor)}</span>
|
||||
<span className='ml-1 text-base text-gray-700 dark:text-gray-600'>{actor.handle}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -402,27 +298,27 @@ const Notifications: React.FC<NotificationsProps> = () => {
|
|||
</NotificationItem.Avatars>
|
||||
<NotificationItem.Content>
|
||||
<div className='line-clamp-2 text-pretty text-black dark:text-white'>
|
||||
{!isLoading ?
|
||||
<NotificationGroupDescription group={group} /> :
|
||||
{isLoading ?
|
||||
<>
|
||||
<Skeleton />
|
||||
<Skeleton className='w-full max-w-60' />
|
||||
</>
|
||||
</> :
|
||||
<NotificationGroupDescription group={group} />
|
||||
}
|
||||
</div>
|
||||
{(
|
||||
(group.type === ACTIVITY_TYPE.CREATE && group.object?.inReplyTo) ||
|
||||
(group.type === ACTIVITY_TYPE.LIKE && !group.object?.name && group.object?.content) ||
|
||||
(group.type === ACTIVITY_TYPE.REPOST && !group.object?.name && group.object?.content)
|
||||
(group.type === 'reply' && group.inReplyTo) ||
|
||||
(group.type === 'like' && !group.post?.name && group.post?.content) ||
|
||||
(group.type === 'repost' && !group.post?.name && group.post?.content)
|
||||
) && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{__html: stripHtml(group.object?.content || '')}}
|
||||
dangerouslySetInnerHTML={{__html: stripHtml(group.post?.content || '')}}
|
||||
className='ap-note-content mt-1 line-clamp-2 text-pretty text-gray-700 dark:text-gray-600'
|
||||
/>
|
||||
)}
|
||||
</NotificationItem.Content>
|
||||
</NotificationItem>
|
||||
{index < groupedActivities.length - 1 && <Separator />}
|
||||
{index < notificationGroups.length - 1 && <Separator />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,339 +0,0 @@
|
|||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import {LucideIcon, Skeleton} from '@tryghost/shade';
|
||||
|
||||
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, LoadingIndicator} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import APAvatar from '@components/global/APAvatar';
|
||||
import NotificationItem from '@components/activities/NotificationItem';
|
||||
import Separator from '@components/global/Separator';
|
||||
|
||||
import ArticleModal from '@src/components/feed/ArticleModal';
|
||||
import Layout from '@components/layout';
|
||||
import truncate from '@utils/truncate';
|
||||
import {EmptyViewIcon, EmptyViewIndicator} from '@src/components/global/EmptyViewIndicator';
|
||||
import {Notification} from '@src/api/activitypub';
|
||||
import {handleProfileClick} from '@utils/handle-profile-click';
|
||||
import {stripHtml} from '@src/utils/content-formatters';
|
||||
import {useNotificationsForUser} from '@hooks/use-activity-pub-queries';
|
||||
|
||||
interface NotificationGroup {
|
||||
id: string;
|
||||
type: Notification['type'];
|
||||
actors: Notification['actor'][];
|
||||
post: Notification['post'];
|
||||
inReplyTo: Notification['inReplyTo'];
|
||||
}
|
||||
|
||||
interface NotificationGroupDescriptionProps {
|
||||
group: NotificationGroup;
|
||||
}
|
||||
|
||||
function groupNotifications(notifications: Notification[]): NotificationGroup[] {
|
||||
const groups: {
|
||||
[key: string]: NotificationGroup
|
||||
} = {};
|
||||
|
||||
notifications.forEach((notification) => {
|
||||
let groupKey = '';
|
||||
|
||||
switch (notification.type) {
|
||||
case 'like':
|
||||
if (notification.post?.id) {
|
||||
// Group likes by the target object
|
||||
groupKey = `like_${notification.post.id}`;
|
||||
}
|
||||
break;
|
||||
case 'reply':
|
||||
// Don't group replies
|
||||
groupKey = `reply_${notification.id}`;
|
||||
break;
|
||||
case 'repost':
|
||||
if (notification.post?.id) {
|
||||
// Group reposts by the target object
|
||||
groupKey = `repost_${notification.post.id}`;
|
||||
}
|
||||
break;
|
||||
case 'follow':
|
||||
// Group follows that are next to each other in the array
|
||||
groupKey = `follow_${notification.type}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = {
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
actors: [],
|
||||
post: notification.post,
|
||||
inReplyTo: notification.inReplyTo
|
||||
};
|
||||
}
|
||||
|
||||
// Add actor if not already in the group
|
||||
if (!groups[groupKey].actors.find(a => a.id === notification.actor.id)) {
|
||||
groups[groupKey].actors.push(notification.actor);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(groups);
|
||||
};
|
||||
|
||||
const NotificationGroupDescription: React.FC<NotificationGroupDescriptionProps> = ({group}) => {
|
||||
const [firstActor, secondActor, ...otherActors] = group.actors;
|
||||
const hasOthers = otherActors.length > 0;
|
||||
|
||||
const actorClass = 'cursor-pointer font-semibold hover:underline';
|
||||
|
||||
const actorText = (
|
||||
<>
|
||||
<span
|
||||
className={actorClass}
|
||||
onClick={e => handleProfileClick(firstActor.handle, e)}
|
||||
>
|
||||
{firstActor.name}
|
||||
</span>
|
||||
{secondActor && (
|
||||
<>
|
||||
{hasOthers ? ', ' : ' and '}
|
||||
<span
|
||||
className={actorClass}
|
||||
onClick={e => handleProfileClick(secondActor.handle, e)}
|
||||
>
|
||||
{secondActor.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{hasOthers && ' and others'}
|
||||
</>
|
||||
);
|
||||
|
||||
switch (group.type) {
|
||||
case 'follow':
|
||||
return <>{actorText} started following you</>;
|
||||
case 'like':
|
||||
return <>{actorText} liked your {group.post?.type === 'article' ? 'post' : 'note'} <span className='font-semibold'>{group.post?.title || ''}</span></>;
|
||||
case 'repost':
|
||||
return <>{actorText} reposted your {group.post?.type === 'article' ? 'post' : 'note'} <span className='font-semibold'>{group.post?.title || ''}</span></>;
|
||||
case 'reply':
|
||||
if (group.inReplyTo && typeof group.inReplyTo !== 'string') {
|
||||
let content = stripHtml(group.inReplyTo.content || '');
|
||||
|
||||
// If the post has a title, use that instead of the content (notes do not have a title)
|
||||
if (group.inReplyTo.title) {
|
||||
content = stripHtml(group.inReplyTo.title);
|
||||
}
|
||||
|
||||
return <>{actorText} replied to your {group.post?.type === 'article' ? 'post' : 'note'} <span className='font-semibold'>{truncate(content, 80)}</span></>;
|
||||
}
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const NotificationsV2: React.FC = () => {
|
||||
const [openStates, setOpenStates] = React.useState<{[key: string]: boolean}>({});
|
||||
|
||||
const toggleOpen = (groupId: string) => {
|
||||
setOpenStates(prev => ({
|
||||
...prev,
|
||||
[groupId]: !prev[groupId]
|
||||
}));
|
||||
};
|
||||
|
||||
const maxAvatars = 5;
|
||||
|
||||
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = useNotificationsForUser('index');
|
||||
|
||||
const notificationGroups = (
|
||||
data?.pages.flatMap((page) => {
|
||||
return groupNotifications(page.notifications);
|
||||
})
|
||||
// If no notifications, return 5 empty groups for the loading state
|
||||
?? Array(5).fill({actors: [{}]}));
|
||||
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
});
|
||||
|
||||
if (loadMoreRef.current) {
|
||||
observerRef.current.observe(loadMoreRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const handleNotificationClick = (group: NotificationGroup, index: number) => {
|
||||
switch (group.type) {
|
||||
case 'like':
|
||||
NiceModal.show(ArticleModal, {
|
||||
remotePostId: group.post?.id || '',
|
||||
width: group.post?.type === 'article' ? 'wide' : 'narrow'
|
||||
});
|
||||
break;
|
||||
case 'reply':
|
||||
NiceModal.show(ArticleModal, {
|
||||
remotePostId: group.post?.id || '',
|
||||
width: group.inReplyTo?.type === 'article' ? 'wide' : 'narrow'
|
||||
});
|
||||
break;
|
||||
case 'repost':
|
||||
NiceModal.show(ArticleModal, {
|
||||
remotePostId: group.post?.id || '',
|
||||
width: group.post?.type === 'article' ? 'wide' : 'narrow'
|
||||
});
|
||||
break;
|
||||
case 'follow':
|
||||
if (group.actors.length > 1) {
|
||||
toggleOpen(group.id || `${group.type}_${index}`);
|
||||
} else {
|
||||
handleProfileClick(group.actors[0].handle);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className='z-0 flex w-full flex-col items-center'>
|
||||
{
|
||||
isLoading === false && notificationGroups.length === 0 && (
|
||||
<EmptyViewIndicator>
|
||||
<EmptyViewIcon><LucideIcon.Bell /></EmptyViewIcon>
|
||||
Quiet for now, but not for long! When someone likes, boosts, or replies to you, you'll find it here.
|
||||
</EmptyViewIndicator>
|
||||
)
|
||||
}
|
||||
{
|
||||
(notificationGroups.length > 0) && (
|
||||
<>
|
||||
<div className='my-8 flex w-full max-w-[620px] flex-col'>
|
||||
{notificationGroups.map((group, index) => (
|
||||
<React.Fragment key={group.id || `${group.type}_${index}`}>
|
||||
<NotificationItem
|
||||
className='hover:bg-gray-75 dark:hover:bg-gray-950'
|
||||
onClick={() => handleNotificationClick(group, index)}
|
||||
>
|
||||
{isLoading ?
|
||||
<Skeleton className='rounded-full' containerClassName='flex h-10 w-10' /> :
|
||||
<NotificationItem.Icon type={group.type} />
|
||||
}
|
||||
<NotificationItem.Avatars>
|
||||
<div className='flex flex-col'>
|
||||
<div className='mt-0.5 flex items-center gap-1.5'>
|
||||
{!openStates[group.id || `${group.type}_${index}`] && group.actors.slice(0, maxAvatars).map((actor: ActorProperties) => (
|
||||
<APAvatar
|
||||
key={actor.id}
|
||||
author={{
|
||||
icon: {
|
||||
url: actor.avatarUrl || ''
|
||||
},
|
||||
name: actor.name,
|
||||
handle: actor.handle
|
||||
}}
|
||||
size='notification'
|
||||
/>
|
||||
))}
|
||||
{group.actors.length > maxAvatars && (!openStates[group.id || `${group.type}_${index}`]) && (
|
||||
<div
|
||||
className='flex h-9 w-5 items-center justify-center text-sm text-gray-700'
|
||||
>
|
||||
{`+${group.actors.length - maxAvatars}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.actors.length > 1 && (
|
||||
<Button
|
||||
className={`transition-color flex h-9 items-center rounded-full bg-transparent text-gray-700 hover:opacity-60 dark:text-gray-600 ${openStates[group.id || `${group.type}_${index}`] ? 'w-full justify-start pl-1' : '-ml-2 w-9 justify-center'}`}
|
||||
hideLabel={!openStates[group.id || `${group.type}_${index}`]}
|
||||
icon='chevron-down'
|
||||
iconColorClass={`w-[12px] h-[12px] ${openStates[group.id || `${group.type}_${index}`] ? 'rotate-180' : ''}`}
|
||||
label={`${openStates[group.id || `${group.type}_${index}`] ? 'Hide' : 'Show all'}`}
|
||||
unstyled
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation();
|
||||
toggleOpen(group.id || `${group.type}_${index}`);
|
||||
}}/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openStates[group.id || `${group.type}_${index}`] ? 'mb-2 max-h-[1384px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
{openStates[group.id || `${group.type}_${index}`] && group.actors.length > 1 && (
|
||||
<div className='flex flex-col gap-2 pt-4'>
|
||||
{group.actors.map((actor: ActorProperties) => (
|
||||
<div
|
||||
key={actor.id}
|
||||
className='flex items-center hover:opacity-80'
|
||||
onClick={e => handleProfileClick(actor.handle, e)}
|
||||
>
|
||||
<APAvatar author={{
|
||||
icon: {
|
||||
url: actor.avatarUrl || ''
|
||||
},
|
||||
name: actor.name,
|
||||
handle: actor.handle
|
||||
}} size='xs' />
|
||||
<span className='ml-2 text-base font-semibold dark:text-white'>{actor.name}</span>
|
||||
<span className='ml-1 text-base text-gray-700 dark:text-gray-600'>{actor.handle}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NotificationItem.Avatars>
|
||||
<NotificationItem.Content>
|
||||
<div className='line-clamp-2 text-pretty text-black dark:text-white'>
|
||||
{isLoading ?
|
||||
<>
|
||||
<Skeleton />
|
||||
<Skeleton className='w-full max-w-60' />
|
||||
</> :
|
||||
<NotificationGroupDescription group={group} />
|
||||
}
|
||||
</div>
|
||||
{(
|
||||
(group.type === 'reply' && group.inReplyTo) ||
|
||||
(group.type === 'like' && !group.post?.name && group.post?.content) ||
|
||||
(group.type === 'repost' && !group.post?.name && group.post?.content)
|
||||
) && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{__html: stripHtml(group.post?.content || '')}}
|
||||
className='ap-note-content mt-1 line-clamp-2 text-pretty text-gray-700 dark:text-gray-600'
|
||||
/>
|
||||
)}
|
||||
</NotificationItem.Content>
|
||||
</NotificationItem>
|
||||
{index < notificationGroups.length - 1 && <Separator />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div ref={loadMoreRef} className='h-1'></div>
|
||||
{isFetchingNextPage && (
|
||||
<div className='flex flex-col items-center justify-center space-y-4 text-center'>
|
||||
<LoadingIndicator size='md' />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsV2;
|
|
@ -1,12 +1 @@
|
|||
import {useFeatureFlags} from '@src/lib/feature-flags';
|
||||
|
||||
import NotificationsV1 from './Notifications';
|
||||
import NotificationsV2 from './NotificationsV2';
|
||||
|
||||
const Notifications = () => {
|
||||
const {isEnabled} = useFeatureFlags();
|
||||
|
||||
return isEnabled('notificationsV2') ? <NotificationsV2 /> : <NotificationsV1 />;
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
export {default} from './Notifications';
|
||||
|
|
Loading…
Add table
Reference in a new issue