diff --git a/apps/activitypub/package.json b/apps/activitypub/package.json index a3606f6d8db..206a0629a55 100644 --- a/apps/activitypub/package.json +++ b/apps/activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/activitypub", - "version": "3.1.35", + "version": "3.1.36", "license": "MIT", "repository": { "type": "git", diff --git a/apps/activitypub/src/components/feed/feed-item.tsx b/apps/activitypub/src/components/feed/feed-item.tsx index f7ecf826586..b6482bb7859 100644 --- a/apps/activitypub/src/components/feed/feed-item.tsx +++ b/apps/activitypub/src/components/feed/feed-item.tsx @@ -12,8 +12,8 @@ import ProfilePreviewHoverCard from '../global/profile-preview-hover-card'; import FeedItemStats from './feed-item-stats'; import clsx from 'clsx'; +import getHandle from '../../utils/get-handle'; import getReadingTime from '../../utils/get-reading-time'; -import getUsername from '../../utils/get-username'; import {handleProfileClick} from '../../utils/handle-profile-click'; import {openLinksInNewTab, sanitizeHtml, stripHtml} from '../../utils/content-formatters'; import {renderTimestamp} from '../../utils/render-timestamp'; @@ -379,7 +379,7 @@ const FeedItem: React.FC = ({ author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor; } - const authorHandle = author ? getUsername(author) : null; + const authorHandle = author ? getHandle(author) : null; const followedByMe = author?.followedByMe || false; @@ -455,7 +455,7 @@ const FeedItem: React.FC = ({
- {!isLoading ? getUsername(author) : } + {!isLoading ? getHandle(author) : }
{!isLoading ? renderTimestamp(object, (isPending === false && !object.authored)) : } @@ -564,7 +564,7 @@ const FeedItem: React.FC = ({
{renderTimestamp(object, !object.authored)}
- {getUsername(author)} + {getHandle(author)}
} @@ -626,7 +626,7 @@ const FeedItem: React.FC = ({
{renderTimestamp(object, (isPending === false && !object.authored))}
- {getUsername(author)} + {getHandle(author)}
diff --git a/apps/activitypub/src/components/global/ap-avatar.tsx b/apps/activitypub/src/components/global/ap-avatar.tsx index 7073ba07e71..03c49dae5f9 100644 --- a/apps/activitypub/src/components/global/ap-avatar.tsx +++ b/apps/activitypub/src/components/global/ap-avatar.tsx @@ -1,6 +1,6 @@ import React, {useEffect, useState} from 'react'; import clsx from 'clsx'; -import getUsername from '@utils/get-username'; +import getHandle from '@utils/get-handle'; import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Skeleton} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; @@ -140,7 +140,7 @@ const APAvatar: React.FC = ({author, size, isLoading = false, dis return ; } - const handle = author?.handle || getUsername(author as ActorProperties); + const handle = getHandle(author as ActorProperties); const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/apps/activitypub/src/components/global/ap-reply-box.tsx b/apps/activitypub/src/components/global/ap-reply-box.tsx index 05561b78d9d..c2faf7b72c9 100644 --- a/apps/activitypub/src/components/global/ap-reply-box.tsx +++ b/apps/activitypub/src/components/global/ap-reply-box.tsx @@ -2,7 +2,7 @@ import React, {HTMLProps, useState} from 'react'; import APAvatar from './ap-avatar'; import NewNoteModal from '@components/modals/new-note-modal'; -import getUsername from '../../utils/get-username'; +import getHandle from '../../utils/get-handle'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {useUserDataForUser} from '@hooks/use-activity-pub-queries'; @@ -29,7 +29,7 @@ const APReplyBox: React.FC = ({ const attributedTo = object.attributedTo as ActorProperties | undefined; let placeholder = 'Reply...'; if (attributedTo?.preferredUsername && attributedTo?.id) { - placeholder = `Reply to ${getUsername(attributedTo)}...`; + placeholder = `Reply to ${getHandle(attributedTo)}...`; } return ( diff --git a/apps/activitypub/src/components/global/profile-preview-hover-card.tsx b/apps/activitypub/src/components/global/profile-preview-hover-card.tsx index 92432cc7615..ba5c39d287a 100644 --- a/apps/activitypub/src/components/global/profile-preview-hover-card.tsx +++ b/apps/activitypub/src/components/global/profile-preview-hover-card.tsx @@ -1,6 +1,6 @@ import FollowButton from './follow-button'; import React, {useEffect, useState} from 'react'; -import getUsername from '../../utils/get-username'; +import getHandle from '../../utils/get-handle'; import {Account} from '@src/api/activitypub'; import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Avatar, AvatarFallback, AvatarImage, Badge, HoverCard, HoverCardContent, HoverCardTrigger, Skeleton} from '@tryghost/shade/components'; @@ -36,7 +36,7 @@ const ProfilePreviewHoverCard: React.FC = ({ let targetHandle = actor?.handle; if (!targetHandle && actor && isActorProperties(actor)) { - targetHandle = getUsername(actor); + targetHandle = getHandle(actor); } const bypassHover = disabled || (!targetHandle && !actor); diff --git a/apps/activitypub/src/components/modals/new-note-modal.tsx b/apps/activitypub/src/components/modals/new-note-modal.tsx index c6a64e187a2..2187970a9ce 100644 --- a/apps/activitypub/src/components/modals/new-note-modal.tsx +++ b/apps/activitypub/src/components/modals/new-note-modal.tsx @@ -1,7 +1,7 @@ import * as FormPrimitive from '@radix-ui/react-form'; import APAvatar from '@components/global/ap-avatar'; import FeedItem from '@components/feed/feed-item'; -import getUsername from '@utils/get-username'; +import getHandle from '@utils/get-handle'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, Input, LoadingIndicator, Skeleton} from '@tryghost/shade/components'; import {ChangeEvent, useCallback, useEffect, useRef, useState} from 'react'; @@ -273,7 +273,7 @@ const NewNoteModal: React.FC = ({children, replyTo, onReply, if (replyTo) { const attributedTo = replyTo.object.attributedTo || {}; if (typeof attributedTo === 'object' && 'preferredUsername' in attributedTo && 'id' in attributedTo) { - placeholder = `Reply to ${getUsername(attributedTo as ActorProperties)}...`; + placeholder = `Reply to ${getHandle(attributedTo as ActorProperties)}...`; } } diff --git a/apps/activitypub/src/utils/get-username.ts b/apps/activitypub/src/utils/get-handle.ts similarity index 59% rename from apps/activitypub/src/utils/get-username.ts rename to apps/activitypub/src/utils/get-handle.ts index b0a4e6efa53..07e93c4516b 100644 --- a/apps/activitypub/src/utils/get-username.ts +++ b/apps/activitypub/src/utils/get-handle.ts @@ -1,4 +1,8 @@ -function getUsername(actor: {preferredUsername: string; id: string|null;}) { +function getHandle(actor: {handle?: string; preferredUsername: string; id: string|null;}) { + if (actor.handle) { + return actor.handle; + } + if (!actor.preferredUsername || !actor.id) { return '@unknown@unknown'; } @@ -9,4 +13,4 @@ function getUsername(actor: {preferredUsername: string; id: string|null;}) { } } -export default getUsername; +export default getHandle; diff --git a/apps/activitypub/src/utils/handle-profile-click.ts b/apps/activitypub/src/utils/handle-profile-click.ts index 6f0e5cae697..a051c585a75 100644 --- a/apps/activitypub/src/utils/handle-profile-click.ts +++ b/apps/activitypub/src/utils/handle-profile-click.ts @@ -1,4 +1,4 @@ -import getUsername from './get-username'; +import getHandle from './get-handle'; import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {useNavigate} from '@tryghost/admin-x-framework'; @@ -7,6 +7,6 @@ export const handleProfileClick = (actor: ActorProperties | string, navigate: Re if (typeof actor === 'string') { navigate(`/profile/${actor}`); } else { - navigate(`/profile/${actor.handle || getUsername(actor)}`); + navigate(`/profile/${getHandle(actor)}`); } }; diff --git a/apps/activitypub/src/utils/posts.ts b/apps/activitypub/src/utils/posts.ts index 59df3ed889f..721aef0d030 100644 --- a/apps/activitypub/src/utils/posts.ts +++ b/apps/activitypub/src/utils/posts.ts @@ -23,6 +23,7 @@ export function mapPostToActivity(post: Post): Activity { url: post.author.avatarUrl }, name: post.author.name, + handle: post.author.handle, preferredUsername: post.author.handle.split('@')[1], followedByMe: post.author.followedByMe, // These are not used but needed to comply with the ActorProperties type @@ -55,6 +56,7 @@ export function mapPostToActivity(post: Post): Activity { url: post.repostedBy.avatarUrl }, name: post.repostedBy.name, + handle: post.repostedBy.handle, preferredUsername: post.repostedBy.handle.split('@')[1], followedByMe: post.repostedBy.followedByMe, // These are not used but needed to comply with the ActorProperties type diff --git a/apps/activitypub/src/views/feed/note.tsx b/apps/activitypub/src/views/feed/note.tsx index c49da4649a1..6b1f8c360cb 100644 --- a/apps/activitypub/src/views/feed/note.tsx +++ b/apps/activitypub/src/views/feed/note.tsx @@ -6,7 +6,7 @@ import Layout from '@src/components/layout/layout'; import ProfilePreviewHoverCard from '@components/global/profile-preview-hover-card'; import React, {useEffect, useRef, useState} from 'react'; import ShowRepliesButton from '@src/components/global/show-replies-button'; -import getUsername from '@src/utils/get-username'; +import getHandle from '@src/utils/get-handle'; import {Activity} from '@tryghost/admin-x-framework/api/activitypub'; import {EmptyViewIcon, EmptyViewIndicator} from '@src/components/global/empty-view-indicator'; import {LoadingIndicator, Skeleton} from '@tryghost/shade/components'; @@ -206,7 +206,7 @@ const Note = () => { {currentPost.actor.name}
- {getUsername(currentPost.actor)} + {getHandle(currentPost.actor)} {renderTimestamp(object, !object.authored)}
diff --git a/apps/activitypub/src/views/inbox/components/reader.tsx b/apps/activitypub/src/views/inbox/components/reader.tsx index 8a65c196162..b64d49efe18 100644 --- a/apps/activitypub/src/views/inbox/components/reader.tsx +++ b/apps/activitypub/src/views/inbox/components/reader.tsx @@ -1,7 +1,7 @@ import Customizer, {COLOR_OPTIONS, type ColorOption, type FontSize, useCustomizerSettings} from './customizer'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import ShowRepliesButton from '@src/components/global/show-replies-button'; -import getUsername from '../../../utils/get-username'; +import getHandle from '../../../utils/get-handle'; import {LoadingIndicator, Skeleton} from '@tryghost/shade/components'; import {renderTimestamp} from '../../../utils/render-timestamp'; @@ -824,7 +824,7 @@ export const Reader: React.FC = ({ {isLoadingContent ? : actor.name}
- {!isLoadingContent && {getUsername(actor)}} + {!isLoadingContent && {getHandle(actor)}} {isLoadingContent ? : renderTimestamp(object, !object.authored)}
@@ -833,7 +833,7 @@ export const Reader: React.FC = ({ {!object.authored && !isLoadingContent && ( )} diff --git a/apps/activitypub/src/views/profile/components/actor-list.tsx b/apps/activitypub/src/views/profile/components/actor-list.tsx index 26ecf017dc9..851b4a87da6 100644 --- a/apps/activitypub/src/views/profile/components/actor-list.tsx +++ b/apps/activitypub/src/views/profile/components/actor-list.tsx @@ -3,8 +3,8 @@ import ActivityItem from '@src/components/activities/activity-item'; import FollowButton from '@src/components/global/follow-button'; import ProfilePreviewHoverCard from '@components/global/profile-preview-hover-card'; import React, {useEffect, useRef} from 'react'; +import getHandle from '@src/utils/get-handle'; import getName from '@src/utils/get-name'; -import getUsername from '@src/utils/get-username'; import {Actor} from '@src/api/activitypub'; import {Button, LoadingIndicator, NoValueLabel, NoValueLabelIcon} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; @@ -70,7 +70,7 @@ const ActorList: React.FC = ({ ) : (
{actors.map(({actor, isFollowing, blockedByMe, domainBlockedByMe}) => { - const actorHandle = actor.handle || getUsername(actor); + const actorHandle = getHandle(actor); const isCurrentUser = actorHandle === currentUser?.handle; return ( diff --git a/apps/activitypub/test/unit/utils/get-handle.test.tsx b/apps/activitypub/test/unit/utils/get-handle.test.tsx new file mode 100644 index 00000000000..8684037928d --- /dev/null +++ b/apps/activitypub/test/unit/utils/get-handle.test.tsx @@ -0,0 +1,48 @@ +import getHandle from '../../../src/utils/get-handle'; + +describe('getHandle', function () { + it('returns the API-provided handle when present', async function () { + const user = { + handle: '@index@activitypub.example', + preferredUsername: 'index', + id: 'https://www.platformer.news/' + }; + + const result = getHandle(user); + + expect(result).toBe('@index@activitypub.example'); + }); + + it('returns the formatted handle', async function () { + const user = { + preferredUsername: 'index', + id: 'https://www.platformer.news/' + }; + + const result = getHandle(user); + + expect(result).toBe('@index@platformer.news'); + }); + + it('returns a default handle if the user object is missing data', async function () { + const user = { + preferredUsername: '', + id: '' + }; + + const result = getHandle(user); + + expect(result).toBe('@unknown@unknown'); + }); + + it('returns a default handle if url parsing fails', async function () { + const user = { + preferredUsername: 'index', + id: 'not-a-url' + }; + + const result = getHandle(user); + + expect(result).toBe('@unknown@unknown'); + }); +}); diff --git a/apps/activitypub/test/unit/utils/get-username.test.tsx b/apps/activitypub/test/unit/utils/get-username.test.tsx deleted file mode 100644 index f3846c5323d..00000000000 --- a/apps/activitypub/test/unit/utils/get-username.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import getUsername from '../../../src/utils/get-username'; - -describe('getUsername', function () { - it('returns the formatted username', async function () { - const user = { - preferredUsername: 'index', - id: 'https://www.platformer.news/' - }; - - const result = getUsername(user); - - expect(result).toBe('@index@platformer.news'); - }); - - it('returns a default username if the user object is missing data', async function () { - const user = { - preferredUsername: '', - id: '' - }; - - const result = getUsername(user); - - expect(result).toBe('@unknown@unknown'); - }); - - it('returns a default username if url parsing fails', async function () { - const user = { - preferredUsername: 'index', - id: 'not-a-url' - }; - - const result = getUsername(user); - - expect(result).toBe('@unknown@unknown'); - }); -}); diff --git a/apps/activitypub/test/unit/utils/posts.test.ts b/apps/activitypub/test/unit/utils/posts.test.ts index 5eabbf43bde..44fa175d9be 100644 --- a/apps/activitypub/test/unit/utils/posts.test.ts +++ b/apps/activitypub/test/unit/utils/posts.test.ts @@ -74,6 +74,7 @@ describe('mapPostToActivity', function () { expect(actor.id).toBe('https://example.com/users/123'); expect(actor.icon.url).toBe('https://example.com/users/123/avatar.jpg'); expect(actor.name).toBe('Test User'); + expect(actor.handle).toBe('@testuser@example.com'); expect(actor.preferredUsername).toBe('testuser'); // When the post has been reposted, the actor should be the reposter @@ -92,9 +93,25 @@ describe('mapPostToActivity', function () { expect(actor.id).toBe('https://example.com/users/456'); expect(actor.icon.url).toBe('https://example.com/users/456/avatar.jpg'); expect(actor.name).toBe('Test User 2'); + expect(actor.handle).toBe('@testuser2@example.com'); expect(actor.preferredUsername).toBe('testuser2'); }); + test('it preserves the API-provided author handle', function () { + const actor = mapPostToActivity({ + ...post, + author: { + ...post.author, + handle: '@testuser@social.example', + url: 'https://example.com/users/123' + } + }).actor; + + expect(actor.id).toBe('https://example.com/users/123'); + expect(actor.handle).toBe('@testuser@social.example'); + expect(actor.preferredUsername).toBe('testuser'); + }); + test('it sets the correct object type', function () { expect( mapPostToActivity(post).object.type @@ -124,6 +141,7 @@ describe('mapPostToActivity', function () { expect(object.summary).toBe('Test Summary'); expect(object.url).toBe('https://example.com/posts/123'); expect(object.attributedTo.id).toBe('https://example.com/users/123'); + expect(object.attributedTo.handle).toBe('@testuser@example.com'); expect(object.published).toBe('2024-01-01T00:00:00Z'); expect(object.preview.content).toBe('Test Excerpt'); expect(object.id).toBe('123');