diff --git a/apps/activitypub/package.json b/apps/activitypub/package.json index 99e22284ed2..35403c4dcd2 100644 --- a/apps/activitypub/package.json +++ b/apps/activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/activitypub", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "repository": { "type": "git", diff --git a/apps/activitypub/src/api/activitypub.test.ts b/apps/activitypub/src/api/activitypub.test.ts index 4f1700cfcae..2e141c81338 100644 --- a/apps/activitypub/src/api/activitypub.test.ts +++ b/apps/activitypub/src/api/activitypub.test.ts @@ -1607,10 +1607,11 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/actions/bluesky/enable`]: { - response: JSONResponse({ - handle: '@foo@bar.baz' - }) + [`https://activitypub.api/.ghost/activitypub/v2/actions/bluesky/enable`]: { + async assert(_resource, init) { + expect(init?.method).toEqual('POST'); + }, + response: JSONResponse(null) } }); @@ -1621,12 +1622,12 @@ describe('ActivityPubAPI', function () { fakeFetch ); - const result = await api.enableBluesky(); - - expect(result).toBe('@foo@bar.baz'); + await api.enableBluesky(); }); + }); - test('It returns an empty string if the response is null', async function () { + describe('disableBluesky', function () { + test('It disables bluesky', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -1635,7 +1636,10 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/actions/bluesky/enable`]: { + [`https://activitypub.api/.ghost/activitypub/v2/actions/bluesky/disable`]: { + async assert(_resource, init) { + expect(init?.method).toEqual('POST'); + }, response: JSONResponse(null) } }); @@ -1647,12 +1651,12 @@ describe('ActivityPubAPI', function () { fakeFetch ); - const result = await api.enableBluesky(); - - expect(result).toBe(''); + await api.disableBluesky(); }); + }); - test('It returns an empty string if the response does not contain a handle property', async function () { + describe('confirmBlueskyHandle', function () { + test('It confirms the bluesky handle', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -1661,9 +1665,9 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/actions/bluesky/enable`]: { + [`https://activitypub.api/.ghost/activitypub/v2/actions/bluesky/confirm-handle`]: { response: JSONResponse({ - foo: 'bar' + handle: 'foo@bar.baz' }) } }); @@ -1675,12 +1679,38 @@ describe('ActivityPubAPI', function () { fakeFetch ); - const result = await api.enableBluesky(); + const result = await api.confirmBlueskyHandle(); + + expect(result).toBe('foo@bar.baz'); + }); + + test('It returns an empty string if the response is null', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v2/actions/bluesky/confirm-handle`]: { + response: JSONResponse(null) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.confirmBlueskyHandle(); expect(result).toBe(''); }); - test('It returns an empty string if the response contains an invalid handle property', async function () { + test('It returns an empty string if the response does not contain a handle property', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -1689,9 +1719,9 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/actions/bluesky/enable`]: { + [`https://activitypub.api/.ghost/activitypub/v2/actions/bluesky/confirm-handle`]: { response: JSONResponse({ - handle: ['@foo@bar.baz'] + foo: 'bar' }) } }); @@ -1703,14 +1733,12 @@ describe('ActivityPubAPI', function () { fakeFetch ); - const result = await api.enableBluesky(); + const result = await api.confirmBlueskyHandle(); expect(result).toBe(''); }); - }); - describe('disableBluesky', function () { - test('It disables bluesky', async function () { + test('It returns an empty string if the response contains an invalid handle property', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -1719,11 +1747,10 @@ describe('ActivityPubAPI', function () { }] }) }, - [`https://activitypub.api/.ghost/activitypub/v1/actions/bluesky/disable`]: { - async assert(_resource, init) { - expect(init?.method).toEqual('POST'); - }, - response: JSONResponse(null) + [`https://activitypub.api/.ghost/activitypub/v2/actions/bluesky/confirm-handle`]: { + response: JSONResponse({ + handle: ['foo@bar.baz'] + }) } }); @@ -1734,7 +1761,9 @@ describe('ActivityPubAPI', function () { fakeFetch ); - await api.disableBluesky(); + const result = await api.confirmBlueskyHandle(); + + expect(result).toBe(''); }); }); }); diff --git a/apps/activitypub/src/api/activitypub.ts b/apps/activitypub/src/api/activitypub.ts index cda7ed14110..55a50601f38 100644 --- a/apps/activitypub/src/api/activitypub.ts +++ b/apps/activitypub/src/api/activitypub.ts @@ -26,7 +26,8 @@ export interface Account { domainBlockedByMe: boolean; attachment: { name: string; value: string }[]; blueskyEnabled?: boolean; - blueskyHandle?: string; + blueskyHandleConfirmed?: boolean; + blueskyHandle?: string | null; } export type AccountSearchResult = Pick< @@ -707,8 +708,20 @@ export class ActivityPubAPI { return json.fileUrl; } - async enableBluesky(): Promise { - const url = new URL('.ghost/activitypub/v1/actions/bluesky/enable', this.apiUrl); + async enableBluesky() { + const url = new URL('.ghost/activitypub/v2/actions/bluesky/enable', this.apiUrl); + + await this.fetchJSON(url, 'POST'); + } + + async disableBluesky() { + const url = new URL('.ghost/activitypub/v2/actions/bluesky/disable', this.apiUrl); + + await this.fetchJSON(url, 'POST'); + } + + async confirmBlueskyHandle(): Promise { + const url = new URL('.ghost/activitypub/v2/actions/bluesky/confirm-handle', this.apiUrl); const json = await this.fetchJSON(url, 'POST'); @@ -718,10 +731,4 @@ export class ActivityPubAPI { return String(json.handle); } - - async disableBluesky() { - const url = new URL('.ghost/activitypub/v1/actions/bluesky/disable', this.apiUrl); - - await this.fetchJSON(url, 'POST'); - } } diff --git a/apps/activitypub/src/hooks/use-activity-pub-queries.ts b/apps/activitypub/src/hooks/use-activity-pub-queries.ts index 795316a686a..88bd2eaa220 100644 --- a/apps/activitypub/src/hooks/use-activity-pub-queries.ts +++ b/apps/activitypub/src/hooks/use-activity-pub-queries.ts @@ -2722,18 +2722,23 @@ export function useSuggestedProfilesForUser(handle: string, limit = 3) { return {suggestedProfilesQuery, updateSuggestedProfile}; } -function updateAccountBlueskyCache(queryClient: QueryClient, blueskyHandle: string | null) { +type BlueskyDetails = { + blueskyEnabled: boolean; + blueskyHandleConfirmed: boolean; + blueskyHandle: string | null; +} + +function updateAccountBlueskyCache(queryClient: QueryClient, blueskyDetails: BlueskyDetails) { const profileQueryKey = QUERY_KEYS.account('index'); - queryClient.setQueryData(profileQueryKey, (currentProfile?: {blueskyEnabled: boolean, blueskyHandle: string | null}) => { + queryClient.setQueryData(profileQueryKey, (currentProfile?: BlueskyDetails) => { if (!currentProfile) { return currentProfile; } return { ...currentProfile, - blueskyEnabled: blueskyHandle !== null, - blueskyHandle + ...blueskyDetails }; }); } @@ -2748,8 +2753,12 @@ export function useEnableBlueskyMutationForUser(handle: string) { return api.enableBluesky(); }, - onSuccess(blueskyHandle: string) { - updateAccountBlueskyCache(queryClient, blueskyHandle); + onSuccess() { + updateAccountBlueskyCache(queryClient, { + blueskyEnabled: true, + blueskyHandleConfirmed: false, + blueskyHandle: null + }); // Invalidate the following query as enabling bluesky will cause // the account to follow the brid.gy account (and we want this to @@ -2777,7 +2786,11 @@ export function useDisableBlueskyMutationForUser(handle: string) { return api.disableBluesky(); }, onSuccess() { - updateAccountBlueskyCache(queryClient, null); + updateAccountBlueskyCache(queryClient, { + blueskyEnabled: false, + blueskyHandleConfirmed: false, + blueskyHandle: null + }); // Invalidate the following query as disabling bluesky will cause // the account to unfollow the brid.gy account (and we want this to @@ -2793,3 +2806,34 @@ export function useDisableBlueskyMutationForUser(handle: string) { } }); } + +export function useConfirmBlueskyHandleMutationForUser(handle: string) { + const queryClient = useQueryClient(); + + return useMutation({ + async mutationFn() { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + + return api.confirmBlueskyHandle(); + }, + onSuccess(blueskyHandle: string) { + // If the bluesky handle is empty then the handle was not confirmed + // so we don't need to update the cache + if (blueskyHandle === '') { + return; + } + + updateAccountBlueskyCache(queryClient, { + blueskyEnabled: true, + blueskyHandleConfirmed: true, + blueskyHandle: blueskyHandle + }); + }, + onError(error: {message: string, statusCode: number}) { + if (error.statusCode === 429) { + renderRateLimitError(); + } + } + }); +} diff --git a/apps/activitypub/src/views/Preferences/components/BlueskySharing.tsx b/apps/activitypub/src/views/Preferences/components/BlueskySharing.tsx index 43a0af2ed0f..d0ecbd58455 100644 --- a/apps/activitypub/src/views/Preferences/components/BlueskySharing.tsx +++ b/apps/activitypub/src/views/Preferences/components/BlueskySharing.tsx @@ -1,7 +1,7 @@ import APAvatar from '@src/components/global/APAvatar'; import EditProfile from '@src/views/Preferences/components/EditProfile'; import Layout from '@src/components/layout'; -import React, {useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {AlertDialog, AlertDialogAction, AlertDialogCancel, @@ -22,17 +22,22 @@ import {AlertDialog, LucideIcon, buttonVariants} from '@tryghost/shade'; import {toast} from 'sonner'; -import {useAccountForUser, useDisableBlueskyMutationForUser, useEnableBlueskyMutationForUser} from '@hooks/use-activity-pub-queries'; +import {useAccountForUser, useConfirmBlueskyHandleMutationForUser, useDisableBlueskyMutationForUser, useEnableBlueskyMutationForUser} from '@hooks/use-activity-pub-queries'; + +const CONFIRMATION_INTERVAL = 5000; +const MAX_CONFIRMATION_RETRIES = 12; const BlueskySharing: React.FC = () => { const {data: account, isLoading: isLoadingAccount} = useAccountForUser('index', 'me'); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(() => account?.blueskyEnabled && !account?.blueskyHandleConfirmed); const [copied, setCopied] = useState(false); const [isEditingProfile, setIsEditingProfile] = useState(false); const [showConfirm, setShowConfirm] = useState(false); + const [handleConfirmed, setHandleConfirmed] = useState(false); + const retryCountRef = useRef(0); const enableBlueskyMutation = useEnableBlueskyMutationForUser('index'); const disableBlueskyMutation = useDisableBlueskyMutationForUser('index'); - const enabled = account?.blueskyEnabled ?? false; + const confirmBlueskyHandleMutation = useConfirmBlueskyHandleMutationForUser('index'); const handleCopy = async () => { setCopied(true); @@ -47,9 +52,9 @@ const BlueskySharing: React.FC = () => { setLoading(true); try { await enableBlueskyMutation.mutateAsync(); - toast.success('Bluesky sharing enabled'); - } finally { + } catch (error) { setLoading(false); + toast.error('Something went wrong, please try again.'); } } }; @@ -65,6 +70,62 @@ const BlueskySharing: React.FC = () => { } }; + const confirmHandle = useCallback(() => { + confirmBlueskyHandleMutation.mutateAsync().then((handle) => { + if (handle) { + setHandleConfirmed(true); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty deps - mutations are stable in practice + + useEffect(() => { + if (!account?.blueskyEnabled) { + setHandleConfirmed(false); + setLoading(false); + retryCountRef.current = 0; + + return; + } + + if (account?.blueskyHandleConfirmed) { + setHandleConfirmed(true); + setLoading(false); + + // Only show toast on first confirmation + if (retryCountRef.current > 0) { + toast.success('Bluesky sharing enabled'); + } + retryCountRef.current = 0; + + return; + } + + setHandleConfirmed(false); + setLoading(true); + retryCountRef.current = 0; + + const confirmHandleInterval = setInterval(async () => { + retryCountRef.current += 1; + + if (retryCountRef.current > MAX_CONFIRMATION_RETRIES) { + clearInterval(confirmHandleInterval); + + toast.error('Something went wrong, please try again.'); + + await disableBlueskyMutation.mutateAsync(); + setLoading(false); + + return; + } + + confirmHandle(); + }, CONFIRMATION_INTERVAL); + + return () => clearInterval(confirmHandleInterval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [account?.blueskyEnabled, account?.blueskyHandleConfirmed, confirmHandle]); // disableBlueskyMutation is stable + if (isLoadingAccount) { return ( @@ -80,18 +141,20 @@ const BlueskySharing: React.FC = () => { ); } + const showAsEnabled = account?.blueskyEnabled && account?.blueskyHandleConfirmed; + return (

Bluesky sharing

- {enabled && }
- {!enabled ? + {!showAsEnabled ?

{!account?.avatarUrl ? 'Add a profile image to connect to Bluesky. Profile pictures help prevent spam.' : @@ -111,54 +174,64 @@ const BlueskySharing: React.FC = () => { ) : ( - + <> + + {loading && ( +

You can leave this page and come back to check the status.

+ )} + )}
: <>

Your social web profile is now connected to Bluesky, via Bridgy Fed. Posts are automatically synced after a short delay to complete activation.

-
-
- +
+ -
- + size='md' + /> +
+ +
-
-
-

{account?.name || ''}

-
- {account?.blueskyHandle} - +
+

{account?.name || ''}

+
+ {account?.blueskyHandle} + +
+
- -
+ )} }