Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/activitypub/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryghost/activitypub",
"version": "2.0.5",
"version": "3.0.0",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
32 changes: 32 additions & 0 deletions apps/activitypub/src/api/activitypub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ export type ExploreAccount = Pick<
'id' | 'name' | 'handle' | 'avatarUrl' | 'bio' | 'url' | 'followedByMe'
>;

export interface TopicData {
slug: string;
name: string;
}

export interface GetTopicsResponse {
topics: TopicData[];
}

export interface GetRecommendationsResponse {
accounts: ExploreAccount[];
}

export interface SearchResults {
accounts: AccountSearchResult[];
}
Expand Down Expand Up @@ -477,6 +490,25 @@ export class ActivityPubAPI {
return this.getPaginatedExploreAccounts(endpoint, next);
}

async getTopics(): Promise<GetTopicsResponse> {
const url = new URL('.ghost/activitypub/v1/topics', this.apiUrl);
const json = await this.fetchJSON(url);
return {
topics: (json && 'topics' in json && Array.isArray(json.topics)) ? json.topics : []
};
}

async getRecommendations(limit?: number): Promise<GetRecommendationsResponse> {
const url = new URL('.ghost/activitypub/v1/recommendations', this.apiUrl);
if (limit) {
url.searchParams.set('limit', limit.toString());
}
const json = await this.fetchJSON(url);
return {
accounts: (json && 'accounts' in json && Array.isArray(json.accounts)) ? json.accounts : []
};
}

async getPostsByAccount(handle: string, next?: string): Promise<PaginatedPostsResponse> {
return this.getPaginatedPosts(`.ghost/activitypub/v1/posts/${handle}`, next);
}
Expand Down
54 changes: 17 additions & 37 deletions apps/activitypub/src/components/TopicFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,8 @@
import React, {useEffect, useRef, useState} from 'react';
import {Button} from '@tryghost/shade';
import {useTopicsForUser} from '@src/hooks/use-activity-pub-queries';

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

const TOPICS: {value: Topic; label: string}[] = [
{value: 'following', label: 'Following'},
{value: 'top', label: 'Top'},
{value: 'tech', label: 'Technology'},
{value: 'business', label: 'Business'},
{value: 'news', label: 'News'},
{value: 'culture', label: 'Culture'},
{value: 'art', label: 'Art'},
{value: 'travel', label: 'Travel'},
{value: 'education', label: 'Education'},
{value: 'finance', label: 'Finance'},
{value: 'entertainment', label: 'Entertainment'},
{value: 'productivity', label: 'Productivity'},
{value: 'literature', label: 'Literature'},
{value: 'personal', label: 'Personal'},
{value: 'programming', label: 'Programming'},
{value: 'design', label: 'Design'},
{value: 'sport', label: 'Sport & fitness'},
{value: 'faith-spirituality', label: 'Faith & spirituality'},
{value: 'science', label: 'Science'},
{value: 'crypto', label: 'Crypto'},
{value: 'food-drink', label: 'Food & drink'},
{value: 'music', label: 'Music'},
{value: 'nature-outdoors', label: 'Nature & outdoors'},
{value: 'climate', label: 'Climate'},
{value: 'history', label: 'History'},
{value: 'gear-gadgets', label: 'Gear & gadgets'}
];
export type Topic = string;

interface TopicFilterProps {
currentTopic: Topic;
Expand All @@ -39,7 +11,15 @@ interface TopicFilterProps {
}

const TopicFilter: React.FC<TopicFilterProps> = ({currentTopic, onTopicChange, excludeTopics = []}) => {
const filteredTopics = TOPICS.filter(({value}) => !excludeTopics.includes(value));
const {topicsQuery} = useTopicsForUser();
const {data: topicsData} = topicsQuery;

// Always include "Following" topic at the beginning, then merge with API topics
const followingTopic = {slug: 'following', name: 'Following'};
const apiTopics = topicsData?.topics || [];
const allTopics = [followingTopic, ...apiTopics];

const filteredTopics = allTopics.filter(({slug}) => !excludeTopics.includes(slug));
const selectedButtonRef = useRef<HTMLButtonElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showGradient, setShowGradient] = useState(true);
Expand All @@ -66,15 +46,15 @@ const TopicFilter: React.FC<TopicFilterProps> = ({currentTopic, onTopicChange, e
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"
onScroll={handleScroll}
>
{filteredTopics.map(({value, label}) => (
{filteredTopics.map(({slug, name}) => (
<Button
key={value}
ref={currentTopic === value ? selectedButtonRef : null}
key={slug}
ref={currentTopic === slug ? selectedButtonRef : null}
className="h-8 snap-start rounded-full px-3.5 text-sm"
variant={currentTopic === value ? 'default' : 'secondary'}
onClick={() => onTopicChange(value)}
variant={currentTopic === slug ? 'default' : 'secondary'}
onClick={() => onTopicChange(slug)}
>
{label}
{name}
</Button>
))}
</div>
Expand Down
10 changes: 6 additions & 4 deletions apps/activitypub/src/components/global/SuggestedProfiles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ export const SuggestedProfile: React.FC<SuggestedProfileProps & {
}> = ({profile, update, isLoading, onOpenChange}) => {
const onFollow = () => {
update(profile.id, {
followedByMe: true,
followerCount: profile.followerCount + 1
followedByMe: true
});
};

const onUnfollow = () => {
update(profile.id, {
followedByMe: false,
followerCount: profile.followerCount - 1
followedByMe: false
});
};

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

if (!isLoadingSuggestedProfiles && (!suggestedProfilesData || suggestedProfilesData.length === 0)) {
return null;
}

return (
<div className='mb-[-15px] flex flex-col gap-3 pt-2'>
<div className='flex flex-col'>
Expand Down
6 changes: 5 additions & 1 deletion apps/activitypub/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {useAppBasePath} from '@src/hooks/use-app-base-path';
import {useCurrentPage} from '@src/hooks/use-current-page';
import {useCurrentUser} from '@tryghost/admin-x-framework/api/currentUser';
import {useKeyboardShortcuts} from '@hooks/use-keyboard-shortcuts';
import {useTopicsForUser} from '@src/hooks/use-activity-pub-queries';

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

const {isNewNoteModalOpen, setIsNewNoteModalOpen} = useKeyboardShortcuts();

Expand Down Expand Up @@ -44,7 +48,7 @@ const Layout: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({children, ...pr
<div className='block grid-cols-[auto_320px] items-start lg:grid'>
<div className='z-0 min-w-0'>
<Header
showBorder={!(currentPage === 'reader' || (currentPage === 'explore'))}
showBorder={!(currentPage === 'reader' && hasTopics) && !(currentPage === 'explore')}
onToggleMobileSidebar={toggleMobileSidebar}
/>
<div className='px-[min(4vw,32px)]'>
Expand Down
7 changes: 5 additions & 2 deletions apps/activitypub/src/components/layout/Onboarding/Step3.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import readerCover from '@assets/images/onboarding/cover-reader.png';
import tangleAvatar from '@assets/images/onboarding/avatar-tangle.png';
import tangleCover from '@assets/images/onboarding/cover-tangle.png';
import {Avatar, AvatarFallback, AvatarImage, Button, H1, LucideIcon, Separator} from '@tryghost/shade';
import {useAccountForUser} from '@src/hooks/use-activity-pub-queries';
import {useAccountForUser, useTopicsForUser} from '@src/hooks/use-activity-pub-queries';
import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path';
import {useOnboardingStatus} from './Onboarding';

Expand Down Expand Up @@ -302,6 +302,9 @@ const Step3: React.FC = () => {
const [isHovering, setIsHovering] = useState(false);
const {setOnboarded} = useOnboardingStatus();
const navigate = useNavigateWithBasePath();
const {topicsQuery} = useTopicsForUser();
const {data: topicsData} = topicsQuery;
const hasTopics = topicsData && topicsData.topics.length > 0;
Comment on lines +305 to +307
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent loading state handling compared to Sidebar.

The hasTopics computation doesn't check isLoading, unlike Sidebar.tsx (line 31) which uses !isLoading && topicsData && topicsData.topics.length > 0. If the user clicks "Next" before topics finish loading, hasTopics will be false and they'll navigate to / instead of /explore.

 const {topicsQuery} = useTopicsForUser();
-const {data: topicsData} = topicsQuery;
-const hasTopics = topicsData && topicsData.topics.length > 0;
+const {data: topicsData, isLoading} = topicsQuery;
+const hasTopics = !isLoading && topicsData && topicsData.topics.length > 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const {topicsQuery} = useTopicsForUser();
const {data: topicsData} = topicsQuery;
const hasTopics = topicsData && topicsData.topics.length > 0;
const {topicsQuery} = useTopicsForUser();
const {data: topicsData, isLoading} = topicsQuery;
const hasTopics = !isLoading && topicsData && topicsData.topics.length > 0;
🤖 Prompt for AI Agents
In apps/activitypub/src/components/layout/Onboarding/Step3.tsx around lines 305
to 307, hasTopics is computed without checking the topicsQuery loading state
which can treat pending data as "no topics" and misroute users; update the logic
to destructure isLoading (or the equivalent loading flag) from topicsQuery and
compute hasTopics as !isLoading && topicsData && topicsData.topics.length > 0 so
it matches Sidebar.tsx behavior and prevents navigating to the wrong route if
the user clicks Next before topics finish loading.


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

const handleComplete = async () => {
await setOnboarded(true);
navigate('/explore');
navigate(hasTopics ? '/explore' : '/');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Race condition in onboarding navigation when topics query is loading

The handleComplete function navigates based on hasTopics, but unlike Sidebar.tsx which checks isLoading state, Step3.tsx derives hasTopics directly from topicsData without considering the query's loading state. When topicsQuery is still loading, topicsData is undefined, causing hasTopics to be false regardless of whether topics actually exist. This means users completing onboarding before the query finishes will be incorrectly routed to / instead of /explore, even when topics are available.

Fix in Cursor Fix in Web

};

return (
Expand Down
27 changes: 22 additions & 5 deletions apps/activitypub/src/components/layout/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {useAppBasePath} from '@src/hooks/use-app-base-path';
import {useCurrentUser} from '@tryghost/admin-x-framework/api/currentUser';
import {useFeatureFlags} from '@src/lib/feature-flags';
import {useLocation} from '@tryghost/admin-x-framework';
import {useNotificationsCountForUser, useResetNotificationsCountForUser} from '@src/hooks/use-activity-pub-queries';
import {useNotificationsCountForUser, useResetNotificationsCountForUser, useTopicsForUser} from '@src/hooks/use-activity-pub-queries';

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

// Reset count when on notifications page
React.useEffect(() => {
Expand Down Expand Up @@ -73,10 +76,24 @@ const Sidebar: React.FC<SidebarProps> = ({isMobileSidebarOpen}) => {
<LucideIcon.Bell size={18} strokeWidth={1.5} />
Notifications
</SidebarMenuLink>
<SidebarMenuLink to='/explore'>
<LucideIcon.Globe size={18} strokeWidth={1.5} />
Explore
</SidebarMenuLink>
{hasTopics ? (
<SidebarMenuLink to='/explore'>
<LucideIcon.Globe size={18} strokeWidth={1.5} />
Explore
</SidebarMenuLink>
) : (
<Button
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'
variant='ghost'
asChild
>
<a href="https://explore.ghost.org/social-web" rel="noopener noreferrer" target="_blank">
<LucideIcon.Globe size={18} strokeWidth={1.5} />
Explore
<LucideIcon.ExternalLink className='ml-auto' size={14} strokeWidth={1.5} />
</a>
</Button>
)}
<SidebarMenuLink to='/profile'>
<LucideIcon.User size={18} strokeWidth={1.5} />
Profile
Expand Down
7 changes: 5 additions & 2 deletions apps/activitypub/src/components/modals/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import React, {useEffect, useRef, useState} from 'react';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, H4, Input, LoadingIndicator, LucideIcon, NoValueLabel, NoValueLabelIcon} from '@tryghost/shade';
import {SuggestedProfiles} from '../global/SuggestedProfiles';
import {useAccountForUser, useSearchForUser} from '@hooks/use-activity-pub-queries';
import {useAccountForUser, useSearchForUser, useSuggestedProfilesForUser} from '@hooks/use-activity-pub-queries';
import {useDebounce} from 'use-debounce';
import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path';

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

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

Expand Down Expand Up @@ -184,7 +187,7 @@ const Search: React.FC<SearchProps> = ({onOpenChange, query, setQuery}) => {
onUpdate={updateResult}
/>
)}
{showSuggested && (
{showSuggested && hasSuggestedProfiles && (
<>
<H4>More people to follow</H4>
<SuggestedProfiles
Expand Down
Loading
Loading