diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 79a0d98e7ce6..358f24e9d99e 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -15,6 +15,7 @@ RUN apt-get update && \ # Base DevContainer: for use in a Dev Container where your local code is mounted into the container ### Adding code and installing dependencies gets overridden by your local code/dependencies, so this is done in onCreateCommand FROM base AS base-devcontainer +ARG WORKDIR=/home/ghost # Install Stripe CLI, zsh, playwright RUN curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | tee /usr/share/keyrings/stripe.gpg && \ echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | tee -a /etc/apt/sources.list.d/stripe.list && \ @@ -28,10 +29,11 @@ RUN curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/publ npx -y playwright@1.46.1 install --with-deps ENV NX_DAEMON=true -ENV YARN_CACHE_FOLDER=/workspaces/ghost/.yarncache +ENV YARN_CACHE_FOLDER=$WORKDIR/.yarncache EXPOSE 2368 EXPOSE 4200 +EXPOSE 4201 EXPOSE 4173 EXPOSE 41730 EXPOSE 4175 @@ -47,21 +49,13 @@ EXPOSE 7174 ### This is a full devcontainer with all the code and dependencies installed ### Useful in an environment like Github Codespaces where you don't mount your local code into the container FROM base-devcontainer AS full-devcontainer +ARG WORKDIR=/home/ghost WORKDIR $WORKDIR -COPY ../../ . +COPY . . RUN yarn install --frozen-lockfile --prefer-offline --cache-folder $YARN_CACHE_FOLDER -# Development Stage: alternative entrypoint for development with some caching optimizations FROM base-devcontainer AS development - +ARG WORKDIR=/home/ghost WORKDIR $WORKDIR - -COPY ../../ . - -RUN yarn install --frozen-lockfile --prefer-offline --cache-folder $YARN_CACHE_FOLDER && \ - cp -r .yarncache .yarncachecopy && \ - rm -Rf .yarncachecopy/.tmp && \ - yarn cache clean - -ENTRYPOINT ["./.devcontainer/.docker/development.entrypoint.sh"] +ENTRYPOINT ["/home/ghost/.docker/development.entrypoint.sh"] CMD ["yarn", "dev"] diff --git a/.docker/development.entrypoint.sh b/.docker/development.entrypoint.sh new file mode 100755 index 000000000000..600dc59c98ed --- /dev/null +++ b/.docker/development.entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Mounting local code into the container overwrites the `node_modules` directories +# so we need to install dependencies again +yarn install --frozen-lockfile --prefer-offline + +yarn nx run-many -t build:ts + +# Execute the CMD +exec "$@" \ No newline at end of file diff --git a/.github/scripts/clean.js b/.github/scripts/clean.js index e6909bd9f8cf..2913ca12ed14 100644 --- a/.github/scripts/clean.js +++ b/.github/scripts/clean.js @@ -1,8 +1,6 @@ // NOTE: this file can't use any NPM dependencies because it needs to run even if dependencies aren't installed yet or are corrupted const {execSync} = require('child_process'); -const isDevContainer = process.env.DEVCONTAINER === 'true'; - cleanYarnCache(); resetNxCache(); deleteNodeModules(); @@ -49,13 +47,7 @@ function resetNxCache() { function cleanYarnCache() { console.log('Cleaning yarn cache...'); try { - if (isDevContainer) { - // In devcontainer, these directories are mounted from the host so we can't delete them — `yarn cache clean` will fail - // so we delete the contents of the directories instead - execSync('rm -rf .yarncache/* .yarncachecopy/*'); - } else { - execSync('yarn cache clean'); - } + execSync('rm -rf .yarncache/* .yarncachecopy/*'); } catch (error) { console.error('Failed to clean yarn cache:', error); process.exit(1); diff --git a/.github/scripts/setup-docker.js b/.github/scripts/setup-docker.js new file mode 100644 index 000000000000..efb2e4abbed6 --- /dev/null +++ b/.github/scripts/setup-docker.js @@ -0,0 +1,100 @@ +const path = require('path'); +const fs = require('fs').promises; +const {spawn} = require('child_process'); + +/** + * Run a command and stream output to the console + * + * @param {string} command + * @param {string[]} args + * @param {object} options + */ +async function runAndStream(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + ...options + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(code); + } else { + reject(new Error(`'${command} ${args.join(' ')}' exited with code ${code}`)); + } + }); + + }); +} + +/** + * Removes node dependencies and cleans up local caches + */ +function clean() { + require('./clean'); +} + +/** + * Adjust config.local.json for Docker Compose setup + */ +async function adjustConfig() { + console.log('Adjusting configuration...'); + const coreFolder = path.join(__dirname, '../../ghost/core'); + const currentConfigPath = path.join(coreFolder, 'config.local.json'); + let currentConfig; + try { + currentConfig = require(currentConfigPath); + } catch (err) { + currentConfig = {}; + } + + currentConfig.database = { + client: 'mysql', + docker: true, + connection: { + host: 'mysql', + user: 'root', + password: 'root', + database: 'ghost' + } + }; + + currentConfig.adapters = { + ...currentConfig.adapters, + Redis: { + host: 'redis', + port: 6379 + } + }; + + currentConfig.server = { + ...currentConfig.server, + host: '0.0.0.0', + port: 2368 + }; + + try { + await fs.writeFile(currentConfigPath, JSON.stringify(currentConfig, null, 4)); + } catch (err) { + console.error('Failed to write config.local.json', err); + console.log(`Please add the following to config.local.json:\n`, JSON.stringify(currentConfig, null, 4)); + process.exit(1); + } +} + +async function buildContainer() { + console.log('Building container...'); + await runAndStream('docker-compose', ['build'], {}); +} + +async function runMigrations() { + console.log('Running migrations...'); + await runAndStream('docker-compose', ['run', '--rm', '-w', '/home/ghost/ghost/core', 'ghost', 'yarn', 'knex-migrator', 'init'], {cwd: path.join(__dirname, '../../')}); +} + +(async () => { + clean(); + await adjustConfig(); + await buildContainer(); + await runMigrations(); +})(); diff --git a/LICENSE b/LICENSE index ce0968e726bc..d060f0e682c4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013-2024 Ghost Foundation +Copyright (c) 2013-2025 Ghost Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/README.md b/README.md index 7fa794ca2ce3..8aa47ee5f712 100644 --- a/README.md +++ b/README.md @@ -98,5 +98,5 @@ To stay up to date with all the latest news and product updates, make sure you [ # Copyright & license -Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 81048bd91974..26a555f3578a 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.42", + "version": "0.3.54", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts index 12300e0ee721..79c9b7ccdceb 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -850,7 +850,7 @@ describe('ActivityPubAPI', function () { }, [`https://activitypub.api/.ghost/activitypub/actions/search?query=${encodeURIComponent(handle)}`]: { response: JSONResponse({ - profiles: [ + accounts: [ { handle, name: 'Foo Bar' @@ -869,7 +869,7 @@ describe('ActivityPubAPI', function () { const actual = await api.search(handle); const expected = { - profiles: [ + accounts: [ { handle, name: 'Foo Bar' @@ -880,7 +880,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('It returns an empty array when there are no profiles in the response', async function () { + test('It returns an empty array when there are no accounts in the response', async function () { const handle = '@foo@bar.baz'; const fakeFetch = Fetch({ @@ -905,7 +905,7 @@ describe('ActivityPubAPI', function () { const actual = await api.search(handle); const expected = { - profiles: [] + accounts: [] }; expect(actual).toEqual(expected); diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 74eecb43a93a..d2fbed84daf5 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -12,8 +12,30 @@ export interface Profile { isFollowing: boolean; } +interface Account { + id: string; + name: string; + handle: string; + bio: string; + url: string; + avatarUrl: string; + bannerImageUrl: string | null; + customFields: Record; + postCount: number; + likedCount: number; + followingCount: number; + followerCount: number; + followsMe: boolean; + followedByMe: boolean; +} + +export type AccountSearchResult = Pick< + Account, + 'id' | 'name' | 'handle' | 'avatarUrl' | 'followedByMe' | 'followerCount' +>; + export interface SearchResults { - profiles: Profile[]; + accounts: AccountSearchResult[]; } export interface ActivityThread { @@ -43,6 +65,17 @@ export interface GetPostsForProfileResponse { next: string | null; } +export type AccountFollowsType = 'following' | 'followers'; + +type GetAccountResponse = Account + +export type FollowAccount = Pick; + +export interface GetAccountFollowsResponse { + accounts: FollowAccount[]; + next: string | null; +} + export class ActivityPubAPI { constructor( private readonly apiUrl: URL, @@ -114,20 +147,6 @@ export class ActivityPubAPI { }; } - private async getActivityPubCollectionCount(collectionUrl: URL): Promise { - const json = await this.fetchJSON(collectionUrl); - - if (json === null) { - return 0; - } - - if ('totalItems' in json && typeof json.totalItems === 'number') { - return json.totalItems; - } - - return 0; - } - get inboxApiUrl() { return new URL(`.ghost/activitypub/inbox/${this.handle}`, this.apiUrl); } @@ -162,10 +181,6 @@ export class ActivityPubAPI { return this.getActivityPubCollection(this.followingApiUrl, cursor); } - async getFollowingCount(): Promise { - return this.getActivityPubCollectionCount(this.followingApiUrl); - } - get followersApiUrl() { return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl); } @@ -174,10 +189,6 @@ export class ActivityPubAPI { return this.getActivityPubCollection(this.followersApiUrl, cursor); } - async getFollowersCount(): Promise { - return this.getActivityPubCollectionCount(this.followersApiUrl); - } - async follow(username: string): Promise { const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl); const json = await this.fetchJSON(url, 'POST'); @@ -192,10 +203,6 @@ export class ActivityPubAPI { return this.getActivityPubCollection(this.likedApiUrl, cursor); } - async getLikedCount(): Promise { - return this.getActivityPubCollectionCount(this.likedApiUrl); - } - async like(id: string): Promise { const url = new URL(`.ghost/activitypub/actions/like/${encodeURIComponent(id)}`, this.apiUrl); await this.fetchJSON(url, 'POST'); @@ -291,12 +298,12 @@ export class ActivityPubAPI { const json = await this.fetchJSON(url, 'GET'); - if (json && 'profiles' in json) { + if (json && 'accounts' in json) { return json as SearchResults; } return { - profiles: [] + accounts: [] }; } @@ -404,4 +411,46 @@ export class ActivityPubAPI { const json = await this.fetchJSON(url); return json as ActivityThread; } + + get accountApiUrl() { + return new URL(`.ghost/activitypub/account/${this.handle}`, this.apiUrl); + } + + async getAccount(): Promise { + const json = await this.fetchJSON(this.accountApiUrl); + + return json as GetAccountResponse; + } + + async getAccountFollows(type: AccountFollowsType, next?: string): Promise { + const url = new URL(`.ghost/activitypub/account/${this.handle}/follows/${type}`, this.apiUrl); + if (next) { + url.searchParams.set('next', next); + } + + const json = await this.fetchJSON(url); + + if (json === null) { + return { + accounts: [], + next: null + }; + } + + if (!('accounts' in json)) { + return { + accounts: [], + next: null + }; + } + + const accounts = Array.isArray(json.accounts) ? json.accounts : []; + const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null; + + return { + accounts, + next: nextPage + }; + } } + diff --git a/apps/admin-x-activitypub/src/components/Activities.tsx b/apps/admin-x-activitypub/src/components/Activities.tsx index 24c0d6dfb369..b626bb85fc8b 100644 --- a/apps/admin-x-activitypub/src/components/Activities.tsx +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -148,7 +148,7 @@ const getGroupDescription = (group: GroupedActivity): JSX.Element => { return <>{actorText} liked your post {group.object?.name || ''}; case ACTIVITY_TYPE.CREATE: if (group.object?.inReplyTo && typeof group.object?.inReplyTo !== 'string') { - let content = stripHtml(group.object.inReplyTo.content); + let content = stripHtml(group.object.inReplyTo.content || ''); // If the post has a name, use that instead of the content (short // form posts do not have a name) diff --git a/apps/admin-x-activitypub/src/components/Inbox.tsx b/apps/admin-x-activitypub/src/components/Inbox.tsx index 4fb7fc753725..9f4c3e5b5466 100644 --- a/apps/admin-x-activitypub/src/components/Inbox.tsx +++ b/apps/admin-x-activitypub/src/components/Inbox.tsx @@ -137,9 +137,9 @@ const Inbox: React.FC = ({layout}) => {
-

This is your {layout === 'inbox' ? 'inbox' : 'feed'}

-

You'll find {layout === 'inbox' ? 'long-form content' : 'short posts and updates'} from the accounts you follow here.

-

You might also like

+

This is your {layout === 'inbox' ? 'inbox' : 'feed'}

+

You'll find {layout === 'inbox' ? 'long-form content' : 'short posts and updates'} from the accounts you follow here.

+

You might also like

{isLoadingSuggested ? ( ) : ( @@ -154,7 +154,7 @@ const Inbox: React.FC = ({layout}) => { >
- {getName(actor)} + {getName(actor)} {getUsername(actor)}
@@ -165,7 +165,7 @@ const Inbox: React.FC = ({layout}) => { })} )} -
diff --git a/apps/admin-x-activitypub/src/components/Profile.tsx b/apps/admin-x-activitypub/src/components/Profile.tsx index ea851516e1a2..d9c50901e392 100644 --- a/apps/admin-x-activitypub/src/components/Profile.tsx +++ b/apps/admin-x-activitypub/src/components/Profile.tsx @@ -4,19 +4,15 @@ import NiceModal from '@ebay/nice-modal-react'; import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Heading, List, LoadingIndicator, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system'; -import getName from '../utils/get-name'; -import getUsername from '../utils/get-username'; import { + type AccountFollowsQueryResult, type ActivityPubCollectionQueryResult, - useFollowersCountForUser, - useFollowersForUser, - useFollowingCountForUser, - useFollowingForUser, - useLikedCountForUser, + useAccountFollowsForUser, + useAccountForUser, useLikedForUser, - useOutboxForUser, - useUserDataForUser + useOutboxForUser } from '../hooks/useActivityPubQueries'; +import {FollowAccount} from '../api/activitypub'; import {handleViewContent} from '../utils/content-handlers'; import APAvatar from './global/APAvatar'; @@ -28,13 +24,13 @@ import ViewProfileModal from './modals/ViewProfileModal'; import {type Activity} from '../components/activities/ActivityItem'; interface UseInfiniteScrollTabProps { - useDataHook: (key: string) => ActivityPubCollectionQueryResult; + useDataHook: (key: string) => ActivityPubCollectionQueryResult | AccountFollowsQueryResult; emptyStateLabel: string; emptyStateIcon: string; } /** - * Hook to abstract away the common logic for infinite scroll in tabs + * Hook to abstract away the common logic for infinite scroll in the tabs */ const useInfiniteScrollTab = ({useDataHook, emptyStateLabel, emptyStateIcon}: UseInfiniteScrollTabProps) => { const { @@ -45,7 +41,15 @@ const useInfiniteScrollTab = ({useDataHook, emptyStateLabel, emptyStateI isLoading } = useDataHook('index'); - const items = (data?.pages.flatMap(page => page.data) ?? []); + const items = (data?.pages.flatMap((page) => { + if ('data' in page) { + return page.data; + } else if ('accounts' in page) { + return page.accounts as TData[]; + } + + return []; + }) ?? []); const observerRef = useRef(null); const loadMoreRef = useRef(null); @@ -172,15 +176,13 @@ const LikesTab: React.FC = () => { ); }; -const handleUserClick = (actor: ActorProperties) => { - NiceModal.show(ViewProfileModal, { - profile: getUsername(actor) - }); +const handleAccountClick = (handle: string) => { + NiceModal.show(ViewProfileModal, {handle}); }; const FollowingTab: React.FC = () => { - const {items: following, EmptyState, LoadingState} = useInfiniteScrollTab({ - useDataHook: useFollowingForUser, + const {items: accounts, EmptyState, LoadingState} = useInfiniteScrollTab({ + useDataHook: handle => useAccountFollowsForUser(handle, 'following'), emptyStateLabel: 'You aren\'t following anyone yet.', emptyStateIcon: 'user-add' }); @@ -190,21 +192,27 @@ const FollowingTab: React.FC = () => { { - {following.map((item, index) => ( - + {accounts.map((account, index) => ( + handleUserClick(item)} + key={account.id} + onClick={() => handleAccountClick(account.handle)} > - +
- {getName(item)} -
{getUsername(item)}
+ {account.name} +
{account.handle}
- {index < following.length - 1 && } + {index < accounts.length - 1 && }
))}
@@ -215,8 +223,8 @@ const FollowingTab: React.FC = () => { }; const FollowersTab: React.FC = () => { - const {items: followers, EmptyState, LoadingState} = useInfiniteScrollTab({ - useDataHook: useFollowersForUser, + const {items: accounts, EmptyState, LoadingState} = useInfiniteScrollTab({ + useDataHook: handle => useAccountFollowsForUser(handle, 'followers'), emptyStateLabel: 'Nobody\'s following you yet. Their loss!', emptyStateIcon: 'user-add' }); @@ -226,21 +234,27 @@ const FollowersTab: React.FC = () => { { - {followers.map((item, index) => ( - + {accounts.map((account, index) => ( + handleUserClick(item)} + key={account.id} + onClick={() => handleAccountClick(account.handle)} > - +
- {item.name || getName(item) || 'Unknown'} -
{getUsername(item)}
+ {account.name} +
{account.handle}
- {index < followers.length - 1 && } + {index < accounts.length - 1 && }
))}
@@ -255,12 +269,7 @@ type ProfileTab = 'posts' | 'likes' | 'following' | 'followers'; interface ProfileProps {} const Profile: React.FC = ({}) => { - const {data: followersCount = 0, isLoading: isLoadingFollowersCount} = useFollowersCountForUser('index'); - const {data: followingCount = 0, isLoading: isLoadingFollowingCount} = useFollowingCountForUser('index'); - const {data: likedCount = 0, isLoading: isLoadingLikedCount} = useLikedCountForUser('index'); - const {data: userProfile, isLoading: isLoadingProfile} = useUserDataForUser('index') as {data: ActorProperties | null, isLoading: boolean}; - - const isInitialLoading = isLoadingProfile || isLoadingFollowersCount || isLoadingFollowingCount || isLoadingLikedCount; + const {data: account, isLoading: isLoadingAccount} = useAccountForUser('index'); const [selectedTab, setSelectedTab] = useState('posts'); @@ -282,7 +291,7 @@ const Profile: React.FC = ({}) => { ), - counter: likedCount + counter: account?.likedCount || 0 }, { id: 'following', @@ -292,7 +301,7 @@ const Profile: React.FC = ({}) => { ), - counter: followingCount + counter: account?.followingCount || 0 }, { id: 'followers', @@ -302,11 +311,16 @@ const Profile: React.FC = ({}) => { ), - counter: followersCount + counter: account?.followerCount || 0 } ].filter(Boolean) as Tab[]; - const attachments = (userProfile?.attachment || []); + const customFields = Object.keys(account?.customFields || {}).map((key) => { + return { + name: key, + value: account!.customFields[key] + }; + }) || []; const [isExpanded, setisExpanded] = useState(false); @@ -326,45 +340,51 @@ const Profile: React.FC = ({}) => { return ( <> - {isInitialLoading ? ( + {isLoadingAccount ? (
) : (
- {userProfile?.image && ( + {account?.bannerImageUrl && (
{userProfile?.name}
)} -
+
- {userProfile?.name} + {account?.name} - {userProfile && getUsername(userProfile)} + {account?.handle} - {(userProfile?.summary || attachments.length > 0) && ( + {(account?.bio || customFields.length > 0) && (
p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
- {attachments.map(attachment => ( - - {attachment.name} - + {customFields.map(customField => ( + + {customField.name} + ))} {!isExpanded && isOverflowing && ( diff --git a/apps/admin-x-activitypub/src/components/Search.tsx b/apps/admin-x-activitypub/src/components/Search.tsx index 39451a20ea1f..7062408724dd 100644 --- a/apps/admin-x-activitypub/src/components/Search.tsx +++ b/apps/admin-x-activitypub/src/components/Search.tsx @@ -1,6 +1,6 @@ import React, {useEffect, useRef, useState} from 'react'; -import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import NiceModal from '@ebay/nice-modal-react'; import {Button, Icon, LoadingIndicator, NoValueLabel, TextField} from '@tryghost/admin-x-design-system'; import {useDebounce} from 'use-debounce'; @@ -8,60 +8,65 @@ import APAvatar from './global/APAvatar'; import ActivityItem from './activities/ActivityItem'; import FollowButton from './global/FollowButton'; import MainNavigation from './navigation/MainNavigation'; - -import NiceModal from '@ebay/nice-modal-react'; -import ViewProfileModal from './modals/ViewProfileModal'; - import Separator from './global/Separator'; +import ViewProfileModal from './modals/ViewProfileModal'; +import {type Profile} from '../api/activitypub'; import {useSearchForUser, useSuggestedProfiles} from '../hooks/useActivityPubQueries'; -interface SearchResultItem { - actor: ActorProperties; +interface AccountSearchResult { + id: string; + name: string; handle: string; + avatarUrl: string; followerCount: number; - followingCount: number; - isFollowing: boolean; + followedByMe: boolean; } -interface SearchResultProps { - result: SearchResultItem; - update: (id: string, updated: Partial) => void; +interface AccountSearchResultItemProps { + account: AccountSearchResult; + update: (id: string, updated: Partial) => void; } -const SearchResult: React.FC = ({result, update}) => { +const AccountSearchResultItem: React.FC = ({account, update}) => { const onFollow = () => { - update(result.actor.id!, { - isFollowing: true, - followerCount: result.followerCount + 1 + update(account.id, { + followedByMe: true, + followerCount: account.followerCount + 1 }); }; const onUnfollow = () => { - update(result.actor.id!, { - isFollowing: false, - followerCount: result.followerCount - 1 + update(account.id, { + followedByMe: false, + followerCount: account.followerCount - 1 }); }; return ( { - NiceModal.show(ViewProfileModal, {profile: result, onFollow, onUnfollow}); + NiceModal.show(ViewProfileModal, {handle: account.handle, onFollow, onUnfollow}); }} > - +
- {result.actor.name} {result.handle} + {account.name} {account.handle}
-
{new Intl.NumberFormat().format(result.followerCount)} followers
+
{new Intl.NumberFormat().format(account.followerCount)} followers
= ({result, update}) => { ); }; -const SearchResults: React.FC<{ - results: SearchResultItem[]; - onUpdate: (id: string, updated: Partial) => void; -}> = ({results, onUpdate}) => { +interface SearchResultsProps { + results: AccountSearchResult[]; + onUpdate: (id: string, updated: Partial) => void; +} + +const SearchResults: React.FC = ({results, onUpdate}) => { return ( <> - {results.map(result => ( - ( + ))} @@ -87,11 +94,59 @@ const SearchResults: React.FC<{ ); }; -const SuggestedAccounts: React.FC<{ - profiles: SearchResultItem[]; +interface SuggestedProfileProps { + profile: Profile; + update: (id: string, updated: Partial) => void; +} + +const SuggestedProfile: React.FC = ({profile, update}) => { + const onFollow = () => { + update(profile.actor.id, { + isFollowing: true, + followerCount: profile.followerCount + 1 + }); + }; + + const onUnfollow = () => { + update(profile.actor.id, { + isFollowing: false, + followerCount: profile.followerCount - 1 + }); + }; + + return ( + { + NiceModal.show(ViewProfileModal, {handle: profile.handle, onFollow, onUnfollow}); + }} + > + +
+
+ {profile.actor.name} {profile.handle} +
+
{new Intl.NumberFormat().format(profile.followerCount)} followers
+
+ +
+ ); +}; + +interface SuggestedProfilesProps { + profiles: Profile[]; isLoading: boolean; - onUpdate: (id: string, updated: Partial) => void; -}> = ({profiles, isLoading, onUpdate}) => { + onUpdate: (id: string, updated: Partial) => void; +} + +const SuggestedProfiles: React.FC = ({profiles, isLoading, onUpdate}) => { return ( <> @@ -105,9 +160,8 @@ const SuggestedAccounts: React.FC<{ {profiles.map((profile, index) => { return ( - {index < profiles.length - 1 && } @@ -123,17 +177,17 @@ interface SearchProps {} const Search: React.FC = ({}) => { // Initialise suggested profiles const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfiles('index', 6); - const {data: suggestedData, isLoading: isLoadingSuggested} = suggestedProfilesQuery; - const suggested = suggestedData || []; + const {data: suggestedProfilesData, isLoading: isLoadingSuggestedProfiles} = suggestedProfilesQuery; + const suggestedProfiles = suggestedProfilesData || []; // Initialise search query const queryInputRef = useRef(null); const [query, setQuery] = useState(''); const [debouncedQuery] = useDebounce(query, 300); - const {searchQuery, updateProfileSearchResult: updateResult} = useSearchForUser('index', query !== '' ? debouncedQuery : query); + const {searchQuery, updateAccountSearchResult: updateResult} = useSearchForUser('index', query !== '' ? debouncedQuery : query); const {data, isFetching, isFetched} = searchQuery; - const results = data?.profiles || []; + const results = data?.accounts || []; const showLoading = isFetching && query.length > 0; const showNoResults = !isFetching && isFetched && results.length === 0 && query.length > 0 && debouncedQuery === query; const showSuggested = query === '' || (isFetched && results.length === 0); @@ -156,7 +210,7 @@ const Search: React.FC = ({}) => { className='mb-6 mr-12 flex h-10 w-full items-center rounded-lg border border-transparent bg-grey-100 px-[33px] py-1.5 transition-colors focus:border-green focus:bg-white focus:outline-2 dark:border-transparent dark:bg-grey-925 dark:text-white dark:placeholder:text-grey-800 dark:focus:border-green dark:focus:bg-grey-950 tablet:mr-0' containerClassName='w-100' inputRef={queryInputRef} - placeholder='Enter a username...' + placeholder='Enter a handle or account URL...' title="Search" type='text' value={query} @@ -183,21 +237,21 @@ const Search: React.FC = ({}) => { {showNoResults && ( - No users matching this username + No users matching this handle or account URL )} {!showLoading && !showNoResults && ( )} {showSuggested && ( - )} diff --git a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx index 5b3cd58153c7..67461928c1a9 100644 --- a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -1,7 +1,7 @@ import FeedItem from './FeedItem'; import FeedItemStats from './FeedItemStats'; import NiceModal from '@ebay/nice-modal-react'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import articleBodyStyles from '../articleBodyStyles'; import getUsername from '../../utils/get-username'; import {OptionProps, SingleValueProps, components} from 'react-select'; @@ -16,7 +16,9 @@ import {useThreadForUser} from '../../hooks/useActivityPubQueries'; import APAvatar from '../global/APAvatar'; import APReplyBox from '../global/APReplyBox'; +import TableOfContents, {TOCItem} from './TableOfContents'; import getReadingTime from '../../utils/get-reading-time'; +import {useDebounce} from 'use-debounce'; interface ArticleModalProps { activityId: string; @@ -37,14 +39,28 @@ interface IframeWindow extends Window { resizeIframe?: () => void; } -const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string, fontSize: FontSize, lineHeight: string, fontFamily: SelectOption}> = ({ +const ArticleBody: React.FC<{ + heading: string; + image: string|undefined; + excerpt: string|undefined; + html: string; + fontSize: FontSize; + lineHeight: string; + fontFamily: SelectOption; + onHeadingsExtracted?: (headings: TOCItem[]) => void; + onIframeLoad?: (iframe: HTMLIFrameElement) => void; + onLoadingChange?: (isLoading: boolean) => void; +}> = ({ heading, image, excerpt, html, fontSize, lineHeight, - fontFamily + fontFamily, + onHeadingsExtracted, + onIframeLoad, + onLoadingChange }) => { const site = useBrowseSite(); const siteData = site.data?.site; @@ -112,7 +128,15 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: window.addEventListener('DOMContentLoaded', initializeResize); window.addEventListener('load', resizeIframe); window.addEventListener('resize', resizeIframe); - new MutationObserver(resizeIframe).observe(document.body, { subtree: true, childList: true }); + + if (document.body) { + const observer = new MutationObserver(resizeIframe); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true + }); + } window.addEventListener('message', (event) => { if (event.data.type === 'triggerResize') { @@ -192,12 +216,66 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: if (iframeWindow && typeof iframeWindow.resizeIframe === 'function') { iframeWindow.resizeIframe(); } else { - // Fallback: trigger a resize event const resizeEvent = new Event('resize'); iframeDocument.dispatchEvent(resizeEvent); } }, [fontSize, lineHeight, fontFamily]); + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) { + return; + } + + const handleLoad = () => { + if (!iframe.contentDocument) { + return; + } + + // Get all headings except the article title + const headingElements = Array.from( + iframe.contentDocument.querySelectorAll('h1:not(.gh-article-title), h2, h3, h4, h5, h6') + ); + + if (headingElements.length === 0) { + return; + } + + // Find the highest level (smallest number) heading + const highestLevel = Math.min( + ...headingElements.map(el => parseInt(el.tagName[1])) + ); + + // Map headings and normalize their levels + const headings = headingElements.map((el, idx) => { + const id = `heading-${idx}`; + el.id = id; + + // Calculate normalized level (e.g., if highest is h3, then h3->h1, h4->h2) + const actualLevel = parseInt(el.tagName[1]); + const normalizedLevel = actualLevel - highestLevel + 1; + + return { + id, + text: el.textContent || '', + level: normalizedLevel, + element: el as HTMLElement + }; + }); + + onHeadingsExtracted?.(headings); + onIframeLoad?.(iframe); + }; + + iframe.addEventListener('load', handleLoad); + return () => iframe.removeEventListener('load', handleLoad); + }, [onHeadingsExtracted, onIframeLoad]); + + // Update parent when loading state changes + useEffect(() => { + onLoadingChange?.(isLoading); + }, [isLoading, onLoadingChange]); + return (
@@ -455,31 +533,159 @@ const ArticleModal: React.FC = ({ const currentGridWidth = `${parseInt(currentMaxWidth) - 64}px`; const [readingProgress, setReadingProgress] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + // Add debounced version of setReadingProgress + const [debouncedSetReadingProgress] = useDebounce(setReadingProgress, 100); + + const PROGRESS_INCREMENT = 5; // Progress is shown in 5% increments (0%, 5%, 10%, etc.) useEffect(() => { const container = document.querySelector('.overflow-y-auto'); const article = document.getElementById('object-content'); const handleScroll = () => { + if (isLoading) { + return; + } + if (!container || !article) { return; } const articleRect = article.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); + + const isContentShorterThanViewport = articleRect.height <= containerRect.height; + + if (isContentShorterThanViewport) { + debouncedSetReadingProgress(100); + return; + } + const scrolledPast = Math.max(0, containerRect.top - articleRect.top); const totalHeight = (article as HTMLElement).offsetHeight - (container as HTMLElement).offsetHeight; const rawProgress = Math.min(Math.max((scrolledPast / totalHeight) * 100, 0), 100); - const progress = Math.round(rawProgress / 5) * 5; + const progress = Math.round(rawProgress / PROGRESS_INCREMENT) * PROGRESS_INCREMENT; - setReadingProgress(progress); + debouncedSetReadingProgress(progress); }; + if (isLoading) { + return; + } + + const observer = new MutationObserver(handleScroll); + if (article) { + observer.observe(article, { + childList: true, + subtree: true, + characterData: true + }); + } + container?.addEventListener('scroll', handleScroll); - return () => container?.removeEventListener('scroll', handleScroll); + handleScroll(); + + return () => { + container?.removeEventListener('scroll', handleScroll); + observer.disconnect(); + }; + }, [isLoading, debouncedSetReadingProgress]); + + const [tocItems, setTocItems] = useState([]); + const [activeHeadingId, setActiveHeadingId] = useState(null); + const [iframeElement, setIframeElement] = useState(null); + + const handleHeadingsExtracted = useCallback((headings: TOCItem[]) => { + setTocItems(headings); + }, []); + + const handleIframeLoad = useCallback((iframe: HTMLIFrameElement) => { + setIframeElement(iframe); }, []); + const scrollToHeading = useCallback((id: string) => { + if (!iframeElement?.contentDocument) { + return; + } + + const heading = iframeElement.contentDocument.getElementById(id); + if (heading) { + const container = document.querySelector('.overflow-y-auto'); + if (!container) { + return; + } + + const headingOffset = heading.offsetTop; + + container.scrollTo({ + top: headingOffset - 120, + behavior: 'smooth' + }); + } + }, [iframeElement]); + + useEffect(() => { + if (!iframeElement?.contentDocument || !tocItems.length) { + return; + } + + const setupObserver = () => { + const container = document.querySelector('.overflow-y-auto'); + if (!container) { + return; + } + + const handleScroll = () => { + const doc = iframeElement.contentDocument; + if (!doc || !doc.documentElement) { + return; + } + + const headings = tocItems + .map(item => doc.getElementById(item.id)) + .filter((el): el is HTMLElement => el !== null) + .map(el => ({ + element: el, + id: el.id, + position: el.getBoundingClientRect().top - container.getBoundingClientRect().top + })); + + if (!headings.length) { + return; + } + + // Find the last visible heading + const viewportCenter = container.clientHeight / 2; + const buffer = 100; + + // Find the last heading that's above the viewport center + const lastVisibleHeading = headings.reduce((last, current) => { + if (current.position < (viewportCenter + buffer)) { + return current; + } + return last; + }, headings[0]); + + if (lastVisibleHeading && lastVisibleHeading.element.id !== activeHeadingId) { + setActiveHeadingId(lastVisibleHeading.element.id); + } + }; + + container.addEventListener('scroll', handleScroll); + handleScroll(); + + return () => { + container.removeEventListener('scroll', handleScroll); + }; + }; + + const timeoutId = setTimeout(setupObserver, 100); + return () => clearTimeout(timeoutId); + }, [iframeElement, tocItems, activeHeadingId]); + return ( = ({
- {actor.name} + {actor.name}
{getUsername(actor)} @@ -617,96 +823,26 @@ const ArticleModal: React.FC = ({
-
-
- {activityThreadParents.map((item) => { - return ( - <> - { - navigateForward(item.id, item.object, item.actor, false); - }} - onCommentClick={() => { - navigateForward(item.id, item.object, item.actor, true); - }} - /> - - ); - })} - - {object.type === 'Note' && ( - 0)) ? true : false} - type='Note' - onCommentClick={() => { - repliesRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - }} - /> - )} - {object.type === 'Article' && ( -
- + {modalSize === MODAL_SIZE_LG && object.type === 'Article' && tocItems.length > 0 && ( +
+
+ -
- { - repliesRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - }} - onLikeClick={onLikeClick} - /> -
- )} - -
-
- - - {isLoadingThread && } - -
- {activityThreadChildren.map((item, index) => { - const showDivider = index !== activityThreadChildren.length - 1; - + )} +
+
+ {activityThreadParents.map((item) => { return ( <> = ({ navigateForward(item.id, item.object, item.actor, true); }} /> - {showDivider && } ); })} + + {object.type === 'Note' && ( + 0))} + type='Note' + onCommentClick={() => { + repliesRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }} + /> + )} + {object.type === 'Article' && ( +
+ +
+ { + repliesRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }} + onLikeClick={onLikeClick} + /> +
+
+ )} + +
+ +
+ + + {isLoadingThread && } + +
+ {activityThreadChildren.map((item, index) => { + const showDivider = index !== activityThreadChildren.length - 1; + + return ( + + { + navigateForward(item.id, item.object, item.actor, false); + }} + onCommentClick={() => { + navigateForward(item.id, item.object, item.actor, true); + }} + /> + {showDivider && } + + ); + })} +
{modalSize === MODAL_SIZE_LG && object.type === 'Article' && ( -
+
- {getReadingTime(object.content)} + {getReadingTime(object.content ?? '')}
{readingProgress}% diff --git a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx index a8f8194216ec..410d8960f670 100644 --- a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx +++ b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Heading, Icon, Menu, MenuItem, showToast} from '@tryghost/admin-x-design-system'; @@ -7,7 +7,6 @@ import APAvatar from '../global/APAvatar'; import FeedItemStats from './FeedItemStats'; import clsx from 'clsx'; 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'; @@ -62,7 +61,7 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) { return (
{attachment.map((item, index) => ( - {`attachment-${index}`} + {item.name ))}
); @@ -72,21 +71,20 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) { case 'image/jpeg': case 'image/png': case 'image/gif': - return attachment; + return {attachment.name; case 'video/mp4': case 'video/webm': return
; - case 'audio/mpeg': case 'audio/ogg': return
; default: - if (object.image) { - return attachment; + if (object.image || attachment.type === 'Image') { + return {attachment.name; } return null; } @@ -162,10 +160,18 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment const timestamp = new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'}); - const date = new Date(object?.published ?? new Date()); - const [, setIsCopied] = useState(false); + const contentRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + const element = contentRef.current; + if (element) { + setIsTruncated(element.scrollHeight > element.clientHeight); + } + }, [object.content]); + const onLikeClick = () => { // Do API req or smth // Don't need to know about setting timeouts or anything like that @@ -262,18 +268,25 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment
- {(object.type === 'Article') && renderFeedAttachment(object, layout)} - {object.name && {object.name}} - {(object.preview && object.type === 'Article') ?
{object.preview.content}
:
} - {(object.type === 'Note') && renderFeedAttachment(object, layout)} - {(object.type === 'Article') && + )} + {renderFeedAttachment(object, layout)} +
+ }
= ({actor, object, layout, type, comment
- {author.name} + {author.name}
{renderTimestamp(object)}
@@ -318,7 +331,7 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment
{object.name && {object.name}} -
+
{renderFeedAttachment(object, layout)}
= ({actor, object, layout, type, comment
- {author.name} + {author.name}
{renderTimestamp(object)}
@@ -372,7 +385,7 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment
{(object.type === 'Article') && renderFeedAttachment(object, layout)} {object.name && {object.name}} - {(object.preview && object.type === 'Article') ?
{object.preview.content}
:
} + {(object.preview && object.type === 'Article') ?
{object.preview.content}
:
} {(object.type === 'Note') && renderFeedAttachment(object, layout)} {(object.type === 'Article') &&
{object.name ? object.name : ( )} -
+
{object.content && `${getReadingTime(object.content)}`}
diff --git a/apps/admin-x-activitypub/src/components/feed/TableOfContents.tsx b/apps/admin-x-activitypub/src/components/feed/TableOfContents.tsx new file mode 100644 index 000000000000..6814723b396e --- /dev/null +++ b/apps/admin-x-activitypub/src/components/feed/TableOfContents.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import {Popover} from '@tryghost/admin-x-design-system'; + +export interface TOCItem { + id: string; + text: string; + level: number; + element?: HTMLElement; +} + +interface TableOfContentsProps { + items: TOCItem[]; + onItemClick: (id: string) => void; +} + +const LINE_WIDTHS = { + 1: 'w-3', + 2: 'w-2', + 3: 'w-1' +} as const; + +const HEADING_PADDINGS = { + 1: 'pl-2', + 2: 'pl-6', + 3: 'pl-10' +} as const; + +const TableOfContents: React.FC = ({items, onItemClick}) => { + if (items.length === 0) { + return null; + } + + const getNormalizedLevel = (level: number) => { + return Math.min(level, 3); + }; + + const getLineWidth = (level: number) => { + return LINE_WIDTHS[getNormalizedLevel(level) as keyof typeof LINE_WIDTHS]; + }; + + const getHeadingPadding = (level: number) => { + return HEADING_PADDINGS[getNormalizedLevel(level) as keyof typeof HEADING_PADDINGS]; + }; + + return ( +
+ + {items.map(item => ( +
+ ))} +
+ } + > +
+ +
+
+
+ ); +}; + +export default TableOfContents; diff --git a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx index 9de312ddaa5b..d9ed572e3115 100644 --- a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx +++ b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx @@ -9,14 +9,20 @@ import {Icon} from '@tryghost/admin-x-design-system'; type AvatarSize = '2xs' | 'xs' | 'sm' | 'lg' | 'notification'; interface APAvatarProps { - author: ActorProperties | undefined; + author: { + icon: { + url: string; + }; + name: string; + handle?: string; + } | undefined; size?: AvatarSize; } const APAvatar: React.FC = ({author, size}) => { let iconSize = 18; - 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'; + let containerClass = `shrink-0 items-center justify-center overflow-hidden relative z-10 flex ${size === 'lg' ? '' : 'hover:opacity-80 cursor-pointer'}`; + let imageClass = 'z-10 object-cover'; const [iconUrl, setIconUrl] = useState(author?.icon?.url); useEffect(() => { @@ -30,28 +36,30 @@ const APAvatar: React.FC = ({author, size}) => { switch (size) { case '2xs': iconSize = 10; - containerClass = clsx('h-4 w-4 rounded-md ', containerClass); - imageClass = 'z-10 rounded-md w-4 h-4 object-cover'; + containerClass = clsx('h-4 w-4 rounded-md', containerClass); + imageClass = clsx('h-4 w-4', imageClass); break; case 'xs': iconSize = 12; - containerClass = clsx('h-6 w-6 rounded-md ', containerClass); - imageClass = 'z-10 rounded-md w-6 h-6 object-cover'; + containerClass = clsx('h-6 w-6 rounded-lg', containerClass); + imageClass = clsx('h-6 w-6', imageClass); break; case 'notification': iconSize = 12; - containerClass = clsx('h-9 w-9 rounded-md', containerClass); - imageClass = 'z-10 rounded-xl w-9 h-9 object-cover'; + containerClass = clsx('h-9 w-9 rounded-lg', containerClass); + imageClass = clsx('h-9 w-9', imageClass); break; case 'sm': - containerClass = clsx('h-10 w-10 rounded-md', containerClass); + containerClass = clsx('h-10 w-10 rounded-xl', containerClass); + imageClass = clsx('h-10 w-10', imageClass); break; case 'lg': containerClass = clsx('h-22 w-22 rounded-xl', containerClass); - imageClass = 'z-10 rounded-xl w-22 h-22 object-cover'; + imageClass = clsx('h-22 w-22', imageClass); break; default: - containerClass = clsx('h-10 w-10 rounded-md', containerClass); + containerClass = clsx('h-10 w-10 rounded-lg', containerClass); + imageClass = clsx('h-10 w-10', imageClass); break; } @@ -59,21 +67,21 @@ const APAvatar: React.FC = ({author, size}) => { containerClass = clsx(containerClass, 'bg-grey-100'); } + const handle = author?.handle || getUsername(author as ActorProperties); + const onClick = (e: React.MouseEvent) => { e.stopPropagation(); - NiceModal.show(ViewProfileModal, { - profile: getUsername(author as ActorProperties) - }); + NiceModal.show(ViewProfileModal, {handle}); }; - const title = `${author?.name} ${getUsername(author as ActorProperties)}`; + const title = `${author?.name} ${handle}`; if (iconUrl) { return (
{}; @@ -174,7 +174,8 @@ const PostsTab: React.FC<{handle: string}> = ({handle}) => { layout='feed' object={post.object} type={post.type} - onCommentClick={() => {}} + onClick={() => handleViewContent(post, false)} + onCommentClick={() => handleViewContent(post, true)} /> {index < posts.length - 1 && }
@@ -217,13 +218,7 @@ const FollowersTab: React.FC<{handle: string}> = ({handle}) => { }; interface ViewProfileModalProps { - profile: { - actor: ActorProperties; - handle: string; - followerCount: number; - followingCount: number; - isFollowing: boolean; - } | string; + handle: string; onFollow?: () => void; onUnfollow?: () => void; } @@ -231,20 +226,14 @@ interface ViewProfileModalProps { type ProfileTab = 'posts' | 'following' | 'followers'; const ViewProfileModal: React.FC = ({ - profile: initialProfile, + handle, onFollow = noop, onUnfollow = noop }) => { const modal = useModal(); const [selectedTab, setSelectedTab] = useState('posts'); - const willLoadProfile = typeof initialProfile === 'string'; - let {data: profile, isInitialLoading: isLoading} = useProfileForUser('index', initialProfile as string, willLoadProfile); - - if (!willLoadProfile) { - profile = initialProfile; - isLoading = false; - } + const {data: profile, isLoading} = useProfileForUser('index', handle); const attachments = (profile?.actor.attachment || []); @@ -287,7 +276,7 @@ const ViewProfileModal: React.FC = ({ if (contentRef.current) { setIsOverflowing(contentRef.current.scrollHeight > 160); // Compare content height to max height } - }, [isExpanded]); + }, [isExpanded, profile]); return ( = ({
)} {isOverflowing &&