Skip to content

Commit 2c9fc66

Browse files
Updated ActivityPub to use dynamic topics and recommendations from API (#25646)
ref https://linear.app/ghost/issue/BER-3098/hide-discovery-feeds-tabs-for-self-hosters ref https://linear.app/ghost/issue/BER-3041/point-activitypub-explore-to-ghost-explore-axis-for-self-hosters ref https://linear.app/ghost/issue/BER-3025/update-sidebar-in-feed-recommendations-to-use-new-explore-data - Added API methods for fetching topics and recommendations from new endpoints - Updated topic filter to use `/topics` endpoint instead of hardcoded list - Conditionally hide topics and recommendations UI when endpoints return empty - Link Explore to external discovery page when no topics available - Updated suggested profiles to use `/recommendations` endpoint with server-side randomization <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Use new API endpoints for topics and recommendations, update hooks and UI to load dynamically, and conditionally show/hide Explore/topic UI (with external link fallback). > > - **API**: > - Add `getTopics()` and `getRecommendations(limit?)` to `ActivityPubAPI` with types (`TopicData`, `GetTopicsResponse`, `GetRecommendationsResponse`). > - **Data hooks**: > - Add `QUERY_KEYS.topics` and `useTopicsForUser()`. > - Update `useSuggestedProfilesForUser()` to fetch from `getRecommendations()` and cache accounts. > - Remove legacy JSON-based explore/suggestions logic. > - **UI/UX**: > - Topic chips now dynamic: `TopicFilter` reads from `/topics` (type `Topic = string`; uses `slug`/`name`). > - Conditionally render topic filter and suggested profiles only when data exists. > - Sidebar "Explore": internal link when topics exist; otherwise external link to `https://explore.ghost.org/social-web`. > - Inbox empty state CTA mirrors the above internal/external behavior. > - Layout/Header border logic and Onboarding Step3 navigation depend on presence of topics. > - **Version**: > - Bump `@tryghost/activitypub` to `3.0.0`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 086cb83. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 2ace1fa commit 2c9fc66

File tree

11 files changed

+153
-311
lines changed

11 files changed

+153
-311
lines changed

apps/activitypub/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tryghost/activitypub",
3-
"version": "2.0.5",
3+
"version": "3.0.0",
44
"license": "MIT",
55
"repository": {
66
"type": "git",

apps/activitypub/src/api/activitypub.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ export type ExploreAccount = Pick<
4040
'id' | 'name' | 'handle' | 'avatarUrl' | 'bio' | 'url' | 'followedByMe'
4141
>;
4242

43+
export interface TopicData {
44+
slug: string;
45+
name: string;
46+
}
47+
48+
export interface GetTopicsResponse {
49+
topics: TopicData[];
50+
}
51+
52+
export interface GetRecommendationsResponse {
53+
accounts: ExploreAccount[];
54+
}
55+
4356
export interface SearchResults {
4457
accounts: AccountSearchResult[];
4558
}
@@ -477,6 +490,25 @@ export class ActivityPubAPI {
477490
return this.getPaginatedExploreAccounts(endpoint, next);
478491
}
479492

493+
async getTopics(): Promise<GetTopicsResponse> {
494+
const url = new URL('.ghost/activitypub/v1/topics', this.apiUrl);
495+
const json = await this.fetchJSON(url);
496+
return {
497+
topics: (json && 'topics' in json && Array.isArray(json.topics)) ? json.topics : []
498+
};
499+
}
500+
501+
async getRecommendations(limit?: number): Promise<GetRecommendationsResponse> {
502+
const url = new URL('.ghost/activitypub/v1/recommendations', this.apiUrl);
503+
if (limit) {
504+
url.searchParams.set('limit', limit.toString());
505+
}
506+
const json = await this.fetchJSON(url);
507+
return {
508+
accounts: (json && 'accounts' in json && Array.isArray(json.accounts)) ? json.accounts : []
509+
};
510+
}
511+
480512
async getPostsByAccount(handle: string, next?: string): Promise<PaginatedPostsResponse> {
481513
return this.getPaginatedPosts(`.ghost/activitypub/v1/posts/${handle}`, next);
482514
}

apps/activitypub/src/components/TopicFilter.tsx

Lines changed: 17 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,8 @@
11
import React, {useEffect, useRef, useState} from 'react';
22
import {Button} from '@tryghost/shade';
3+
import {useTopicsForUser} from '@src/hooks/use-activity-pub-queries';
34

4-
export type Topic = 'following' | 'top' | 'tech' | 'business' | 'news' | 'culture' | 'art' | 'travel' | 'education' | 'finance' | 'entertainment' | 'productivity' | 'literature' | 'personal' | 'programming' | 'design' | 'sport' | 'faith-spirituality' | 'science' | 'crypto' | 'food-drink' | 'music' | 'nature-outdoors' | 'climate' | 'history' | 'gear-gadgets';
5-
6-
const TOPICS: {value: Topic; label: string}[] = [
7-
{value: 'following', label: 'Following'},
8-
{value: 'top', label: 'Top'},
9-
{value: 'tech', label: 'Technology'},
10-
{value: 'business', label: 'Business'},
11-
{value: 'news', label: 'News'},
12-
{value: 'culture', label: 'Culture'},
13-
{value: 'art', label: 'Art'},
14-
{value: 'travel', label: 'Travel'},
15-
{value: 'education', label: 'Education'},
16-
{value: 'finance', label: 'Finance'},
17-
{value: 'entertainment', label: 'Entertainment'},
18-
{value: 'productivity', label: 'Productivity'},
19-
{value: 'literature', label: 'Literature'},
20-
{value: 'personal', label: 'Personal'},
21-
{value: 'programming', label: 'Programming'},
22-
{value: 'design', label: 'Design'},
23-
{value: 'sport', label: 'Sport & fitness'},
24-
{value: 'faith-spirituality', label: 'Faith & spirituality'},
25-
{value: 'science', label: 'Science'},
26-
{value: 'crypto', label: 'Crypto'},
27-
{value: 'food-drink', label: 'Food & drink'},
28-
{value: 'music', label: 'Music'},
29-
{value: 'nature-outdoors', label: 'Nature & outdoors'},
30-
{value: 'climate', label: 'Climate'},
31-
{value: 'history', label: 'History'},
32-
{value: 'gear-gadgets', label: 'Gear & gadgets'}
33-
];
5+
export type Topic = string;
346

357
interface TopicFilterProps {
368
currentTopic: Topic;
@@ -39,7 +11,15 @@ interface TopicFilterProps {
3911
}
4012

4113
const TopicFilter: React.FC<TopicFilterProps> = ({currentTopic, onTopicChange, excludeTopics = []}) => {
42-
const filteredTopics = TOPICS.filter(({value}) => !excludeTopics.includes(value));
14+
const {topicsQuery} = useTopicsForUser();
15+
const {data: topicsData} = topicsQuery;
16+
17+
// Always include "Following" topic at the beginning, then merge with API topics
18+
const followingTopic = {slug: 'following', name: 'Following'};
19+
const apiTopics = topicsData?.topics || [];
20+
const allTopics = [followingTopic, ...apiTopics];
21+
22+
const filteredTopics = allTopics.filter(({slug}) => !excludeTopics.includes(slug));
4323
const selectedButtonRef = useRef<HTMLButtonElement>(null);
4424
const scrollContainerRef = useRef<HTMLDivElement>(null);
4525
const [showGradient, setShowGradient] = useState(true);
@@ -66,15 +46,15 @@ const TopicFilter: React.FC<TopicFilterProps> = ({currentTopic, onTopicChange, e
6646
className="flex w-full min-w-0 max-w-full snap-x snap-mandatory gap-2 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
6747
onScroll={handleScroll}
6848
>
69-
{filteredTopics.map(({value, label}) => (
49+
{filteredTopics.map(({slug, name}) => (
7050
<Button
71-
key={value}
72-
ref={currentTopic === value ? selectedButtonRef : null}
51+
key={slug}
52+
ref={currentTopic === slug ? selectedButtonRef : null}
7353
className="h-8 snap-start rounded-full px-3.5 text-sm"
74-
variant={currentTopic === value ? 'default' : 'secondary'}
75-
onClick={() => onTopicChange(value)}
54+
variant={currentTopic === slug ? 'default' : 'secondary'}
55+
onClick={() => onTopicChange(slug)}
7656
>
77-
{label}
57+
{name}
7858
</Button>
7959
))}
8060
</div>

apps/activitypub/src/components/global/SuggestedProfiles.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,13 @@ export const SuggestedProfile: React.FC<SuggestedProfileProps & {
1919
}> = ({profile, update, isLoading, onOpenChange}) => {
2020
const onFollow = () => {
2121
update(profile.id, {
22-
followedByMe: true,
23-
followerCount: profile.followerCount + 1
22+
followedByMe: true
2423
});
2524
};
2625

2726
const onUnfollow = () => {
2827
update(profile.id, {
29-
followedByMe: false,
30-
followerCount: profile.followerCount - 1
28+
followedByMe: false
3129
});
3230
};
3331

@@ -81,6 +79,10 @@ export const SuggestedProfiles: React.FC<{
8179
const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfilesForUser('index', 5);
8280
const {data: suggestedProfilesData = [], isLoading: isLoadingSuggestedProfiles} = suggestedProfilesQuery;
8381

82+
if (!isLoadingSuggestedProfiles && (!suggestedProfilesData || suggestedProfilesData.length === 0)) {
83+
return null;
84+
}
85+
8486
return (
8587
<div className='mb-[-15px] flex flex-col gap-3 pt-2'>
8688
<div className='flex flex-col'>

apps/activitypub/src/components/layout/Layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {useAppBasePath} from '@src/hooks/use-app-base-path';
88
import {useCurrentPage} from '@src/hooks/use-current-page';
99
import {useCurrentUser} from '@tryghost/admin-x-framework/api/currentUser';
1010
import {useKeyboardShortcuts} from '@hooks/use-keyboard-shortcuts';
11+
import {useTopicsForUser} from '@src/hooks/use-activity-pub-queries';
1112

1213
const Layout: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({children, ...props}) => {
1314
const {isOnboarded} = useOnboardingStatus();
@@ -16,6 +17,9 @@ const Layout: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({children, ...pr
1617
const containerRef = useRef<HTMLDivElement>(null);
1718
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
1819
const currentPage = useCurrentPage();
20+
const {topicsQuery} = useTopicsForUser();
21+
const {data: topicsData} = topicsQuery;
22+
const hasTopics = topicsData && topicsData.topics.length > 0;
1923

2024
const {isNewNoteModalOpen, setIsNewNoteModalOpen} = useKeyboardShortcuts();
2125

@@ -44,7 +48,7 @@ const Layout: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({children, ...pr
4448
<div className='block grid-cols-[auto_320px] items-start lg:grid'>
4549
<div className='z-0 min-w-0'>
4650
<Header
47-
showBorder={!(currentPage === 'reader' || (currentPage === 'explore'))}
51+
showBorder={!(currentPage === 'reader' && hasTopics) && !(currentPage === 'explore')}
4852
onToggleMobileSidebar={toggleMobileSidebar}
4953
/>
5054
<div className='px-[min(4vw,32px)]'>

apps/activitypub/src/components/layout/Onboarding/Step3.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import readerCover from '@assets/images/onboarding/cover-reader.png';
1616
import tangleAvatar from '@assets/images/onboarding/avatar-tangle.png';
1717
import tangleCover from '@assets/images/onboarding/cover-tangle.png';
1818
import {Avatar, AvatarFallback, AvatarImage, Button, H1, LucideIcon, Separator} from '@tryghost/shade';
19-
import {useAccountForUser} from '@src/hooks/use-activity-pub-queries';
19+
import {useAccountForUser, useTopicsForUser} from '@src/hooks/use-activity-pub-queries';
2020
import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path';
2121
import {useOnboardingStatus} from './Onboarding';
2222

@@ -302,6 +302,9 @@ const Step3: React.FC = () => {
302302
const [isHovering, setIsHovering] = useState(false);
303303
const {setOnboarded} = useOnboardingStatus();
304304
const navigate = useNavigateWithBasePath();
305+
const {topicsQuery} = useTopicsForUser();
306+
const {data: topicsData} = topicsQuery;
307+
const hasTopics = topicsData && topicsData.topics.length > 0;
305308

306309
useEffect(() => {
307310
if (isHovering) {
@@ -317,7 +320,7 @@ const Step3: React.FC = () => {
317320

318321
const handleComplete = async () => {
319322
await setOnboarded(true);
320-
navigate('/explore');
323+
navigate(hasTopics ? '/explore' : '/');
321324
};
322325

323326
return (

apps/activitypub/src/components/layout/Sidebar/Sidebar.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {useAppBasePath} from '@src/hooks/use-app-base-path';
1010
import {useCurrentUser} from '@tryghost/admin-x-framework/api/currentUser';
1111
import {useFeatureFlags} from '@src/lib/feature-flags';
1212
import {useLocation} from '@tryghost/admin-x-framework';
13-
import {useNotificationsCountForUser, useResetNotificationsCountForUser} from '@src/hooks/use-activity-pub-queries';
13+
import {useNotificationsCountForUser, useResetNotificationsCountForUser, useTopicsForUser} from '@src/hooks/use-activity-pub-queries';
1414

1515
interface SidebarProps {
1616
isMobileSidebarOpen: boolean;
@@ -26,6 +26,9 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
2626
const basePath = useAppBasePath();
2727
const {data: notificationsCount} = useNotificationsCountForUser(currentUser?.slug || '');
2828
const resetNotificationsCount = useResetNotificationsCountForUser(currentUser?.slug || '');
29+
const {topicsQuery} = useTopicsForUser();
30+
const {data: topicsData, isLoading} = topicsQuery;
31+
const hasTopics = !isLoading && topicsData && topicsData.topics.length > 0;
2932

3033
// Reset count when on notifications page
3134
React.useEffect(() => {
@@ -73,10 +76,24 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
7376
<LucideIcon.Bell size={18} strokeWidth={1.5} />
7477
Notifications
7578
</SidebarMenuLink>
76-
<SidebarMenuLink to='/explore'>
77-
<LucideIcon.Globe size={18} strokeWidth={1.5} />
78-
Explore
79-
</SidebarMenuLink>
79+
{hasTopics ? (
80+
<SidebarMenuLink to='/explore'>
81+
<LucideIcon.Globe size={18} strokeWidth={1.5} />
82+
Explore
83+
</SidebarMenuLink>
84+
) : (
85+
<Button
86+
className='inline-flex w-full items-center gap-2 rounded-sm px-3 py-2.5 text-left text-md font-medium text-gray-800 transition-colors hover:bg-gray-100 dark:text-gray-500 dark:hover:bg-gray-925/70'
87+
variant='ghost'
88+
asChild
89+
>
90+
<a href="https://explore.ghost.org/social-web" rel="noopener noreferrer" target="_blank">
91+
<LucideIcon.Globe size={18} strokeWidth={1.5} />
92+
Explore
93+
<LucideIcon.ExternalLink className='ml-auto' size={14} strokeWidth={1.5} />
94+
</a>
95+
</Button>
96+
)}
8097
<SidebarMenuLink to='/profile'>
8198
<LucideIcon.User size={18} strokeWidth={1.5} />
8299
Profile

apps/activitypub/src/components/modals/Search.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import React, {useEffect, useRef, useState} from 'react';
66
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
77
import {Button, H4, Input, LoadingIndicator, LucideIcon, NoValueLabel, NoValueLabelIcon} from '@tryghost/shade';
88
import {SuggestedProfiles} from '../global/SuggestedProfiles';
9-
import {useAccountForUser, useSearchForUser} from '@hooks/use-activity-pub-queries';
9+
import {useAccountForUser, useSearchForUser, useSuggestedProfilesForUser} from '@hooks/use-activity-pub-queries';
1010
import {useDebounce} from 'use-debounce';
1111
import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path';
1212

@@ -127,6 +127,9 @@ const Search: React.FC<SearchProps> = ({onOpenChange, query, setQuery}) => {
127127
const shouldSearch = query.length >= 2;
128128
const {searchQuery, updateAccountSearchResult: updateResult} = useSearchForUser('index', shouldSearch ? debouncedQuery : '');
129129
const {data, isFetching, isFetched} = searchQuery;
130+
const {suggestedProfilesQuery} = useSuggestedProfilesForUser('index', 5);
131+
const {data: suggestedProfilesData, isLoading: isLoadingSuggestedProfiles} = suggestedProfilesQuery;
132+
const hasSuggestedProfiles = isLoadingSuggestedProfiles || (suggestedProfilesData && suggestedProfilesData.length > 0);
130133

131134
const [displayResults, setDisplayResults] = useState<AccountSearchResult[]>([]);
132135

@@ -184,7 +187,7 @@ const Search: React.FC<SearchProps> = ({onOpenChange, query, setQuery}) => {
184187
onUpdate={updateResult}
185188
/>
186189
)}
187-
{showSuggested && (
190+
{showSuggested && hasSuggestedProfiles && (
188191
<>
189192
<H4>More people to follow</H4>
190193
<SuggestedProfiles

0 commit comments

Comments
 (0)