0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Improved grouped notifications UI

ref https://linear.app/ghost/issue/AP-625/implement-notification-grouping-for-follows-and-likes

- Improved handling for notification clicks of various types: single follower notification opens that follower in the drawer, multiple followers expands the followers list, liked post opens the article in the wide drawer, liked note opens the note in the narrow drawer
- Improved hover and click states for profile names, usernames and avatars. Now it's more obvious what's clickable, and clicking on any of these elements in any context opens that profile in the drawer.
- Created a handleProfileClick utility since we're using it in a lot of places.
- Removed unnecessary types
- Made the HTML structure more semantic
This commit is contained in:
Djordje Vlaisavljevic 2024-12-06 17:00:19 +00:00
parent de6efba68a
commit 4597abddff
10 changed files with 113 additions and 102 deletions

View file

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

View file

@ -9,14 +9,13 @@ import ArticleModal from './feed/ArticleModal';
import MainNavigation from './navigation/MainNavigation';
import NotificationItem from './activities/NotificationItem';
import Separator from './global/Separator';
import ViewProfileModal from './modals/ViewProfileModal';
import getUsername from '../utils/get-username';
import stripHtml from '../utils/strip-html';
import truncate from '../utils/truncate';
import {GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS, useActivitiesForUser} from '../hooks/useActivityPubQueries';
import {type NotificationType} from './activities/NotificationIcon';
import {useRouting} from '@tryghost/admin-x-framework/routing';
import {handleProfileClick} from '../utils/handle-profile-click';
interface ActivitiesProps {}
@ -39,15 +38,15 @@ const getExtendedDescription = (activity: GroupedActivity): JSX.Element | null =
if (Boolean(activity.type === ACTIVITY_TYPE.CREATE && activity.object?.inReplyTo)) {
return (
<div
dangerouslySetInnerHTML={{__html: activity.object?.content || ''}}
className='mt-1 line-clamp-2 text-pretty text-grey-700'
dangerouslySetInnerHTML={{__html: stripHtml(activity.object?.content || '')}}
className='ap-note-content mt-1 line-clamp-2 text-pretty text-grey-700'
/>
);
} else if (activity.type === ACTIVITY_TYPE.LIKE && !activity.object?.name && activity.object?.content) {
return (
<div
dangerouslySetInnerHTML={{__html: activity.object?.content || ''}}
className='mt-1 line-clamp-2 text-pretty text-grey-700'
dangerouslySetInnerHTML={{__html: stripHtml(activity.object?.content || '')}}
className='ap-note-content mt-1 line-clamp-2 text-pretty text-grey-700'
></div>
);
}
@ -114,53 +113,39 @@ const groupActivities = (activities: Activity[]): GroupedActivity[] => {
};
const getGroupDescription = (group: GroupedActivity): JSX.Element => {
const actorNames = group.actors.map(actor => actor.name);
const [firstActor, secondActor, ...otherActors] = actorNames;
const [firstActor, secondActor, ...otherActors] = group.actors;
const hasOthers = otherActors.length > 0;
let actorText = <></>;
const actorClass = 'cursor-pointer font-semibold hover:underline';
const actorText = (
<>
<span
className={actorClass}
onClick={e => handleProfileClick(firstActor, e)}
>{firstActor.name}</span>
{secondActor && (
<>
{hasOthers ? ', ' : ' and '}
<span
className={actorClass}
onClick={e => handleProfileClick(secondActor, e)}
>{secondActor.name}</span>
</>
)}
{hasOthers && ' and others'}
</>
);
switch (group.type) {
case ACTIVITY_TYPE.FOLLOW:
actorText = (
<>
<span className='font-semibold'>{firstActor}</span>
{secondActor && ` and `}
{secondActor && <span className='font-semibold'>{secondActor}</span>}
{hasOthers && ' and others'}
</>
);
return (
<>
{actorText} started following you
</>
);
return <>{actorText} started following you</>;
case ACTIVITY_TYPE.LIKE:
const postType = group.object?.type === 'Article' ? 'post' : 'note';
actorText = (
<>
<span className='font-semibold'>{firstActor}</span>
{secondActor && (
<>
{hasOthers ? ', ' : ' and '}
<span className='font-semibold'>{secondActor}</span>
</>
)}
{hasOthers && ' and others'}
</>
);
return (
<>
{actorText} liked your {postType}{' '}
<span className='font-semibold'>{group.object?.name || ''}</span>
</>
);
return <>{actorText} liked your post <span className='font-semibold'>{group.object?.name || ''}</span></>;
case ACTIVITY_TYPE.CREATE:
if (group.object?.inReplyTo && typeof group.object?.inReplyTo !== 'string') {
const content = stripHtml(group.object.inReplyTo.name);
return <><span className='font-semibold'>{group.actors[0].name}</span> replied to your post <span className='font-semibold'>{truncate(content, 80)}</span></>;
return <>{actorText} replied to your post <span className='font-semibold'>{truncate(content, 80)}</span></>;
}
}
return <></>;
@ -179,8 +164,6 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
const maxAvatars = 5;
const {updateRoute} = useRouting();
const {getActivitiesQuery} = useActivitiesForUser({
handle: user,
includeOwn: true,
@ -190,6 +173,7 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
},
key: GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS
});
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
const groupedActivities = (data?.pages.flatMap((page) => {
const filtered = page.data.filter((activity, index, self) => index === self.findIndex(a => a.id === activity.id));
@ -222,7 +206,7 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleActivityClick = (group: GroupedActivity) => {
const handleActivityClick = (group: GroupedActivity, index: number) => {
switch (group.type) {
case ACTIVITY_TYPE.CREATE:
NiceModal.show(ArticleModal, {
@ -238,16 +222,14 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
activityId: group.id,
object: group.object,
actor: group.object.attributedTo as ActorProperties,
width: 'wide'
width: group.object?.type === 'Article' ? 'wide' : 'narrow'
});
break;
case ACTIVITY_TYPE.FOLLOW:
if (group.actors.length > 1) {
updateRoute('profile');
toggleOpen(group.id || `${group.type}_${index}`);
} else {
NiceModal.show(ViewProfileModal, {
profile: getUsername(group.actors[0])
});
handleProfileClick(group.actors[0]);
}
break;
}
@ -278,7 +260,7 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
<React.Fragment key={group.id || `${group.type}_${index}`}>
<NotificationItem
className='hover:bg-gray-100'
onClick={() => handleActivityClick(group)}
onClick={() => handleActivityClick(group, index)}
>
<NotificationItem.Icon type={getActivityBadge(group)} />
<NotificationItem.Avatars>
@ -301,7 +283,7 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
{group.actors.length > 1 && (
<Button
className={`transition-color flex h-9 items-center rounded-full bg-transparent text-grey-700 ${openStates[group.id || `${group.type}_${index}`] ? 'w-full justify-start pl-1' : '-ml-2 w-9 justify-center'}`}
className={`transition-color flex h-9 items-center rounded-full bg-transparent text-grey-700 hover:opacity-60 ${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' : ''}`}
@ -313,11 +295,15 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
}}/>
)}
</div>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openStates[group.id || `${group.type}_${index}`] ? 'mb-2 max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}`}>
<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 => (
<div key={actor.id} className='flex items-center'>
<div
key={actor.id}
className='flex items-center hover:opacity-80'
onClick={e => handleProfileClick(actor, e)}
>
<APAvatar author={actor} size='xs' />
<span className='ml-2 text-base font-semibold'>{actor.name}</span>
<span className='ml-1 text-base text-grey-700'>{getUsername(actor)}</span>

View file

@ -7,7 +7,6 @@ import NewPostModal from './modals/NewPostModal';
import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useRef} from 'react';
import Separator from './global/Separator';
import ViewProfileModal from './modals/ViewProfileModal';
import getName from '../utils/get-name';
import getUsername from '../utils/get-username';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
@ -19,6 +18,7 @@ import {
useSuggestedProfiles,
useUserDataForUser
} from '../hooks/useActivityPubQueries';
import {handleProfileClick} from '../utils/handle-profile-click';
import {handleViewContent} from '../utils/content-handlers';
import {useRouting} from '@tryghost/admin-x-framework/routing';
@ -149,9 +149,9 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
return (
<React.Fragment key={actor.id}>
<li key={actor.id}>
<ActivityItem url={actor.url} onClick={() => NiceModal.show(ViewProfileModal, {
profile: getUsername(actor)
})}>
<ActivityItem
onClick={() => handleProfileClick(actor)}
>
<APAvatar author={actor} />
<div className='flex min-w-0 flex-col'>
<span className='block w-full truncate font-bold text-black'>{getName(actor)}</span>

View file

@ -194,7 +194,6 @@ const FollowingTab: React.FC = () => {
<React.Fragment key={item.id}>
<ActivityItem
key={item.id}
url={item.url}
onClick={() => handleUserClick(item)}
>
<APAvatar author={item} />
@ -231,7 +230,6 @@ const FollowersTab: React.FC = () => {
<React.Fragment key={item.id}>
<ActivityItem
key={item.id}
url={item.url}
onClick={() => handleUserClick(item)}
>
<APAvatar author={item} />

View file

@ -4,7 +4,7 @@ import {Icon} from '@tryghost/admin-x-design-system';
export type NotificationType = 'like' | 'follow' | 'reply';
interface NotificationIconProps {
notificationType: 'like' | 'follow' | 'reply';
notificationType: NotificationType;
className?: string;
}
@ -32,7 +32,7 @@ const NotificationIcon: React.FC<NotificationIconProps> = ({notificationType, cl
}
return (
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${badgeColor} ${className}`}>
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${badgeColor} ${className && className}`}>
<Icon colorClass={iconColor} name={icon} size='sm' />
</div>
);

View file

@ -20,9 +20,12 @@ interface NotificationItemProps {
const NotificationItem = ({children, onClick, url, className}: NotificationItemProps) => {
return (
<NotificationContext.Provider value={{onClick, url}}>
<button className={`relative -mx-4 -my-px grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 rounded-lg p-4 text-left hover:bg-grey-75 ${className}`} type='button' onClick={onClick}>
<div className={`relative -mx-4 -my-px grid cursor-pointer grid-cols-[auto_1fr] gap-x-3 gap-y-2 rounded-lg p-4 text-left hover:bg-grey-75 ${className}`}
role='button'
onClick={onClick}
>
{children}
</button>
</div>
</NotificationContext.Provider>
);
};

View file

@ -10,6 +10,7 @@ import getReadingTime from '../../utils/get-reading-time';
import getRelativeTimestamp from '../../utils/get-relative-timestamp';
import getUsername from '../../utils/get-username';
import stripHtml from '../../utils/strip-html';
import {handleProfileClick} from '../../utils/handle-profile-click';
import {renderTimestamp} from '../../utils/render-timestamp';
function getAttachment(object: ObjectProperties) {
@ -241,9 +242,18 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<div className='relative z-30 flex min-w-0 items-center gap-3'>
<APAvatar author={author}/>
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='min-w-0 truncate break-all font-semibold leading-[normal]' data-test-activity-heading>{author.name}</span>
<span className='min-w-0 truncate break-all font-semibold leading-[normal] hover:underline'
data-test-activity-heading
onClick={e => handleProfileClick(actor, e)}
>
{author.name}
</span>
<div className='flex w-full text-grey-700'>
<span className='truncate leading-tight'>{getUsername(author)}</span>
<span className='truncate leading-tight hover:underline'
onClick={e => handleProfileClick(actor, e)}
>
{getUsername(author)}
</span>
<div className='ml-1 leading-tight before:mr-1 before:content-["·"]' title={`${timestamp}`}>{renderTimestamp(object)}</div>
</div>
</div>
@ -400,7 +410,12 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<div className='min-w-0'>
<div className='z-10 mb-1.5 flex w-full min-w-0 items-center gap-1.5 text-sm group-hover/article:border-transparent'>
<APAvatar author={author} size='2xs'/>
<span className='min-w-0 truncate break-all font-semibold text-grey-900' title={getUsername(author)} data-test-activity-heading>{author.name}</span>
<span className='min-w-0 truncate break-all font-semibold text-grey-900 hover:underline'
title={getUsername(author)}
data-test-activity-heading
onClick={e => handleProfileClick(actor, e)}
>{author.name}
</span>
<span className='shrink-0 whitespace-nowrap text-grey-600 before:mr-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
</div>
<div className='flex'>

View file

@ -1,40 +1,30 @@
import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useState} from 'react';
import ViewProfileModal from '../modals/ViewProfileModal';
import clsx from 'clsx';
import getUsername from '../../utils/get-username';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Icon} from '@tryghost/admin-x-design-system';
type AvatarSize = '2xs' | 'xs' | 'sm' | 'lg' | 'notification';
export type AvatarBadge = 'user-fill' | 'heart-fill' | 'comment-fill' | undefined;
interface APAvatarProps {
author?: ActorProperties;
author: ActorProperties | undefined;
size?: AvatarSize;
badge?: AvatarBadge;
}
const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
const APAvatar: React.FC<APAvatarProps> = ({author, size}) => {
let iconSize = 18;
let containerClass = 'shrink-0 items-center justify-center relative z-10 flex';
let containerClass = `shrink-0 items-center justify-center relative cursor-pointer z-10 flex ${size === 'lg' ? '' : 'hover:opacity-80'}`;
let imageClass = 'z-10 rounded-md w-10 h-10 object-cover';
const badgeClass = `w-6 h-6 z-20 rounded-full absolute -bottom-2 -right-[0.6rem] border-2 border-white content-box flex items-center justify-center`;
let badgeColor = '';
const [iconUrl, setIconUrl] = useState(author?.icon?.url);
useEffect(() => {
setIconUrl(author?.icon?.url);
}, [author?.icon?.url]);
switch (badge) {
case 'user-fill':
badgeColor = 'bg-blue-500';
break;
case 'heart-fill':
badgeColor = 'bg-red-500';
break;
case 'comment-fill':
badgeColor = 'bg-purple-500';
break;
if (!author) {
return null;
}
switch (size) {
@ -69,37 +59,42 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
containerClass = clsx(containerClass, 'bg-grey-100');
}
const BadgeElement = badge && (
<div className={clsx(badgeClass, badgeColor)}>
<Icon
colorClass='text-white'
name={badge}
size='xs'
/>
</div>
);
const onClick = (e: React.MouseEvent) => {
e.stopPropagation();
NiceModal.show(ViewProfileModal, {
profile: getUsername(author as ActorProperties)
});
};
const title = `${author?.name} ${getUsername(author as ActorProperties)}`;
if (iconUrl) {
return (
<a className={containerClass} href={author?.url} rel='noopener noreferrer' target='_blank' title={`${author?.name} ${getUsername(author as ActorProperties)}`}>
<div
className={containerClass}
title={title}
onClick={onClick}
>
<img
className={imageClass}
src={iconUrl}
onError={() => setIconUrl(undefined)}
/>
{BadgeElement}
</a>
</div>
);
}
return (
<div className={containerClass} title={author?.name}>
<div
className={containerClass}
title={title}
onClick={onClick}
>
<Icon
colorClass='text-grey-600'
name='user'
size={iconSize}
/>
{BadgeElement}
</div>
);
};

View file

@ -16,6 +16,7 @@ import FollowButton from '../global/FollowButton';
import Separator from '../global/Separator';
import getName from '../../utils/get-name';
import getUsername from '../../utils/get-username';
import {handleProfileClick} from '../../utils/handle-profile-click';
const noop = () => {};
@ -83,7 +84,9 @@ const ActorList: React.FC<ActorListProps> = ({
{actors.map(({actor, isFollowing}, index) => {
return (
<React.Fragment key={actor.id}>
<ActivityItem key={actor.id} url={actor.url}>
<ActivityItem key={actor.id}
onClick={() => handleProfileClick(actor)}
>
<APAvatar author={actor} />
<div>
<div className='text-grey-600'>

View file

@ -0,0 +1,11 @@
import NiceModal from '@ebay/nice-modal-react';
import ViewProfileModal from '../components/modals/ViewProfileModal';
import getUsername from './get-username';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
export const handleProfileClick = (actor: ActorProperties, e?: React.MouseEvent) => {
e?.stopPropagation();
NiceModal.show(ViewProfileModal, {
profile: getUsername(actor)
});
};