0
Fork 0
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:
Michael Barrett 2025-03-31 14:38:12 +01:00 committed by GitHub
parent de17e5546e
commit d8a47162d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 117 additions and 699 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@tryghost/admin-x-activitypub",
"version": "0.6.24",
"version": "0.6.25",
"license": "MIT",
"repository": {
"type": "git",

View file

@ -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});

View file

@ -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);

View file

@ -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;

View file

@ -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&apos;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&apos;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>

View file

@ -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&apos;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;

View file

@ -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';