mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
Updated search to be dynamic in admin-x-activitypub app (#21099)
refs [AP-352](https://linear.app/tryghost/issue/AP-352/search-for-mastodon-usernames-in-ghost-admin) Updated the search functionality in the admin-x-activitypub app to be dynamic and utilise the search API endpoint provided by the activitypub service --------- Co-authored-by: Djordje Vlaisavljevic <dzvlais@gmail.com>
This commit is contained in:
parent
e2bf950b63
commit
8fa9fb9c25
14 changed files with 576 additions and 87 deletions
|
@ -31,15 +31,10 @@
|
|||
"devDependencies": {
|
||||
"@playwright/test": "1.46.1",
|
||||
"@testing-library/react": "14.3.1",
|
||||
"@tryghost/admin-x-design-system": "0.0.0",
|
||||
"@tryghost/admin-x-framework": "0.0.0",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@radix-ui/react-form": "0.0.3",
|
||||
"jest": "29.7.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"ts-jest": "29.1.5"
|
||||
},
|
||||
"nx": {
|
||||
|
@ -67,5 +62,13 @@
|
|||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-form": "0.0.3",
|
||||
"use-debounce": "10.0.3",
|
||||
"@tryghost/admin-x-design-system": "0.0.0",
|
||||
"@tryghost/admin-x-framework": "0.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -580,4 +580,47 @@ describe('ActivityPubAPI', function () {
|
|||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', function () {
|
||||
test('It returns the results of the search', async function () {
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
'https://activitypub.api/.ghost/activitypub/actions/search?query=%40foo%40bar.baz': {
|
||||
response: JSONResponse({
|
||||
profiles: [
|
||||
{
|
||||
handle: '@foo@bar.baz',
|
||||
name: 'Foo Bar'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.search('@foo@bar.baz');
|
||||
const expected = {
|
||||
profiles: [
|
||||
{
|
||||
handle: '@foo@bar.baz',
|
||||
name: 'Foo Bar'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,6 +3,18 @@ export type Actor = any;
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type Activity = any;
|
||||
|
||||
export interface ProfileSearchResult {
|
||||
actor: Actor;
|
||||
handle: string;
|
||||
followerCount: number;
|
||||
isFollowing: boolean;
|
||||
posts: Activity[];
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
profiles: ProfileSearchResult[];
|
||||
}
|
||||
|
||||
export class ActivityPubAPI {
|
||||
constructor(
|
||||
private readonly apiUrl: URL,
|
||||
|
@ -280,4 +292,24 @@ export class ActivityPubAPI {
|
|||
const json = await this.fetchJSON(this.userApiUrl);
|
||||
return json;
|
||||
}
|
||||
|
||||
get searchApiUrl() {
|
||||
return new URL('.ghost/activitypub/actions/search', this.apiUrl);
|
||||
}
|
||||
|
||||
async search(query: string): Promise<SearchResults> {
|
||||
const url = this.searchApiUrl;
|
||||
|
||||
url.searchParams.set('query', query);
|
||||
|
||||
const json = await this.fetchJSON(url, 'GET');
|
||||
|
||||
if (json && 'profiles' in json) {
|
||||
return json as SearchResults;
|
||||
}
|
||||
|
||||
return {
|
||||
profiles: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import {Button, LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import {LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import APAvatar, {AvatarBadge} from './global/APAvatar';
|
||||
import ActivityItem, {type Activity} from './activities/ActivityItem';
|
||||
import ArticleModal from './feed/ArticleModal';
|
||||
import FollowButton from './global/FollowButton';
|
||||
import MainNavigation from './navigation/MainNavigation';
|
||||
|
||||
import getUsername from '../utils/get-username';
|
||||
|
@ -171,13 +173,12 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
|
|||
<div className=''>{getActivityDescription(activity)}</div>
|
||||
{getExtendedDescription(activity)}
|
||||
</div>
|
||||
{isFollower(activity.actor.id) === false && (
|
||||
<Button className='ml-auto' label='Follow' link onClick={(e) => {
|
||||
e?.preventDefault();
|
||||
|
||||
alert('Implement me!');
|
||||
}} />
|
||||
)}
|
||||
<FollowButton
|
||||
className='ml-auto'
|
||||
following={isFollower(activity.actor.id)}
|
||||
handle={getUsername(activity.actor)}
|
||||
type='link'
|
||||
/>
|
||||
</ActivityItem>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,52 +1,234 @@
|
|||
import React, {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {Activity, ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, Icon, LoadingIndicator, NoValueLabel, TextField} from '@tryghost/admin-x-design-system';
|
||||
import {useDebounce} from 'use-debounce';
|
||||
|
||||
import APAvatar from './global/APAvatar';
|
||||
import ActivityItem from './activities/ActivityItem';
|
||||
import FollowButton from './global/FollowButton';
|
||||
import MainNavigation from './navigation/MainNavigation';
|
||||
import React from 'react';
|
||||
import {Button, Icon} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import ProfileSearchResultModal from './search/ProfileSearchResultModal';
|
||||
|
||||
import {useSearchForUser} from '../hooks/useActivityPubQueries';
|
||||
|
||||
interface SearchResultItem {
|
||||
actor: ActorProperties;
|
||||
handle: string;
|
||||
followerCount: number;
|
||||
isFollowing: boolean;
|
||||
posts: Activity[];
|
||||
}
|
||||
|
||||
interface SearchResultProps {
|
||||
result: SearchResultItem;
|
||||
update: (id: string, updated: Partial<SearchResultItem>) => void;
|
||||
}
|
||||
|
||||
interface SearchProps {}
|
||||
|
||||
const SearchResult: React.FC<SearchResultProps> = ({result, update}) => {
|
||||
const onFollow = () => {
|
||||
update(result.actor.id!, {
|
||||
isFollowing: true,
|
||||
followerCount: result.followerCount + 1
|
||||
});
|
||||
};
|
||||
|
||||
const onUnfollow = () => {
|
||||
update(result.actor.id!, {
|
||||
isFollowing: false,
|
||||
followerCount: result.followerCount - 1
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ActivityItem
|
||||
key={result.actor.id}
|
||||
onClick={() => {
|
||||
NiceModal.show(ProfileSearchResultModal, {profile: result, onFollow, onUnfollow});
|
||||
}}
|
||||
>
|
||||
<APAvatar author={result.actor}/>
|
||||
<div>
|
||||
<div className='text-grey-600'>
|
||||
<span className='font-bold text-black'>{result.actor.name} </span>{result.handle}
|
||||
</div>
|
||||
<div className='text-sm'>{new Intl.NumberFormat().format(result.followerCount)} followers</div>
|
||||
</div>
|
||||
<FollowButton
|
||||
className='ml-auto'
|
||||
following={result.isFollowing}
|
||||
handle={result.handle}
|
||||
type='link'
|
||||
onFollow={onFollow}
|
||||
onUnfollow={onUnfollow}
|
||||
/>
|
||||
</ActivityItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Search: React.FC<SearchProps> = ({}) => {
|
||||
// Initialise suggested profiles
|
||||
const [suggested, setSuggested] = useState<SearchResultItem[]>([
|
||||
{
|
||||
actor: {
|
||||
id: 'https://mastodon.social/@quillmatiq',
|
||||
name: 'Anuj Ahooja',
|
||||
preferredUsername: '@quillmatiq@mastodon.social',
|
||||
image: {
|
||||
url: 'https://anujahooja.com/assets/images/image12.jpg?v=601ebe30'
|
||||
},
|
||||
icon: {
|
||||
url: 'https://anujahooja.com/assets/images/image12.jpg?v=601ebe30'
|
||||
}
|
||||
|
||||
} as ActorProperties,
|
||||
handle: '@quillmatiq@mastodon.social',
|
||||
followerCount: 436,
|
||||
isFollowing: false,
|
||||
posts: []
|
||||
},
|
||||
{
|
||||
actor: {
|
||||
id: 'https://flipboard.social/@miaq',
|
||||
name: 'Mia Quagliarello',
|
||||
preferredUsername: '@miaq@flipboard.social',
|
||||
image: {
|
||||
url: 'https://m-cdn.flipboard.social/accounts/avatars/109/824/428/955/351/328/original/383f288b81ab280c.png'
|
||||
},
|
||||
icon: {
|
||||
url: 'https://m-cdn.flipboard.social/accounts/avatars/109/824/428/955/351/328/original/383f288b81ab280c.png'
|
||||
}
|
||||
} as ActorProperties,
|
||||
handle: '@miaq@flipboard.social',
|
||||
followerCount: 533,
|
||||
isFollowing: false,
|
||||
posts: []
|
||||
},
|
||||
{
|
||||
actor: {
|
||||
id: 'https://techpolicy.social/@mallory',
|
||||
name: 'Mallory',
|
||||
preferredUsername: '@mallory@techpolicy.social',
|
||||
image: {
|
||||
url: 'https://techpolicy.social/system/accounts/avatars/109/378/338/180/403/396/original/20b043b0265cac73.jpeg'
|
||||
},
|
||||
icon: {
|
||||
url: 'https://techpolicy.social/system/accounts/avatars/109/378/338/180/403/396/original/20b043b0265cac73.jpeg'
|
||||
}
|
||||
} as ActorProperties,
|
||||
handle: '@mallory@techpolicy.social',
|
||||
followerCount: 1100,
|
||||
isFollowing: false,
|
||||
posts: []
|
||||
}
|
||||
]);
|
||||
|
||||
const updateSuggested = (id: string, updated: Partial<SearchResultItem>) => {
|
||||
const index = suggested.findIndex(result => result.actor.id === id);
|
||||
|
||||
setSuggested((current) => {
|
||||
const newSuggested = [...current];
|
||||
newSuggested[index] = {...newSuggested[index], ...updated};
|
||||
return newSuggested;
|
||||
});
|
||||
};
|
||||
|
||||
// Initialise search query
|
||||
const queryInputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery] = useDebounce(query, 300);
|
||||
const [isQuerying, setIsQuerying] = useState(false);
|
||||
const {searchQuery, updateProfileSearchResult: updateResult} = useSearchForUser('index', query !== '' ? debouncedQuery : query);
|
||||
const {data, isFetching, isFetched} = searchQuery;
|
||||
|
||||
const results = data?.profiles || [];
|
||||
const showLoading = (isFetching || isQuerying) && !isFetched;
|
||||
const showNoResults = isFetched && results.length === 0;
|
||||
const showSuggested = query === '' || (isFetched && results.length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (query !== '') {
|
||||
setIsQuerying(true);
|
||||
} else {
|
||||
setIsQuerying(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
// Focus the query input on initial render
|
||||
useEffect(() => {
|
||||
if (queryInputRef.current) {
|
||||
queryInputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainNavigation title='Search' />
|
||||
<div className='z-0 flex w-full flex-col items-center pt-8'>
|
||||
<div className='mb-6 flex w-full max-w-[560px] items-center gap-2 rounded-full bg-grey-100 px-3 py-2 text-grey-500'><Icon name='magnifying-glass' size={18} />Search the Fediverse</div>
|
||||
<ActivityItem>
|
||||
<APAvatar/>
|
||||
<div>
|
||||
<div className='text-grey-600'><span className='font-bold text-black'>Lydia Mango</span> @username@domain.com</div>
|
||||
<div className='text-sm'>1,535 followers</div>
|
||||
</div>
|
||||
<Button className='ml-auto' label='Follow' link />
|
||||
</ActivityItem>
|
||||
<ActivityItem>
|
||||
<APAvatar/>
|
||||
<div>
|
||||
<div className='text-grey-600'><span className='font-bold text-black'>Tiana Passaquindici Arcand</span> @username@domain.com</div>
|
||||
<div className='text-sm'>4,545 followers</div>
|
||||
</div>
|
||||
<Button className='ml-auto' label='Follow' link />
|
||||
</ActivityItem>
|
||||
<ActivityItem>
|
||||
<APAvatar/>
|
||||
<div>
|
||||
<div className='text-grey-600'><span className='font-bold text-black'>Gretchen Press</span> @username@domain.com</div>
|
||||
<div className='text-sm'>1,156 followers</div>
|
||||
</div>
|
||||
<Button className='ml-auto' label='Follow' link />
|
||||
</ActivityItem>
|
||||
<ActivityItem>
|
||||
<APAvatar/>
|
||||
<div>
|
||||
<div className='text-grey-600'><span className='font-bold text-black'>Leo Lubin</span> @username@domain.com</div>
|
||||
<div className='text-sm'>1,584 followers</div>
|
||||
</div>
|
||||
<Button className='ml-auto' label='Follow' link />
|
||||
</ActivityItem>
|
||||
<div className='relative flex w-full max-w-[560px] items-center '>
|
||||
<Icon className='absolute left-3 top-3 z-10' colorClass='text-grey-500' name='magnifying-glass' size='sm' />
|
||||
<TextField
|
||||
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...'
|
||||
title="Search"
|
||||
type='text'
|
||||
value={query}
|
||||
clearBg
|
||||
hideTitle
|
||||
unstyled
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
className='absolute top-3 p-1 sm:right-14 tablet:right-3'
|
||||
icon='close'
|
||||
iconColorClass='text-grey-700 !w-[10px] !h-[10px]'
|
||||
size='sm'
|
||||
unstyled
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
|
||||
queryInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showLoading && (
|
||||
<LoadingIndicator size='lg'/>
|
||||
)}
|
||||
{showNoResults && (
|
||||
<NoValueLabel icon='user'>
|
||||
No users matching this username
|
||||
</NoValueLabel>
|
||||
)}
|
||||
{results.map(result => (
|
||||
<SearchResult
|
||||
key={(result as SearchResultItem).actor.id}
|
||||
result={result as SearchResultItem}
|
||||
update={updateResult}
|
||||
/>
|
||||
))}
|
||||
{showSuggested && (
|
||||
<>
|
||||
<span className='mb-1 flex w-full max-w-[560px] font-semibold'>Suggested accounts</span>
|
||||
{suggested.map(profile => (
|
||||
<SearchResult
|
||||
key={profile.actor.id}
|
||||
result={profile}
|
||||
update={updateSuggested}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
export default Search;
|
||||
|
|
|
@ -21,7 +21,7 @@ const ActivityItem: React.FC<ActivityItemProps> = ({children, url = null, onClic
|
|||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
const Item = (
|
||||
<div className='flex w-full max-w-[560px] flex-col hover:bg-grey-75' onClick={() => {
|
||||
<div className='flex w-full max-w-[560px] cursor-pointer flex-col hover:bg-grey-75' onClick={() => {
|
||||
if (!url && onClick) {
|
||||
onClick();
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
|
|||
let iconSize = 18;
|
||||
let containerClass = '';
|
||||
let imageClass = 'z-10 rounded w-10 h-10 object-cover';
|
||||
const badgeClass = `w-6 h-6 rounded-full absolute -bottom-2 -right-2 border-2 border-white content-box flex items-center justify-center `;
|
||||
const badgeClass = `w-6 h-6 z-20 rounded-full absolute -bottom-2 -right-2 border-2 border-white content-box flex items-center justify-center `;
|
||||
let badgeColor = '';
|
||||
const [iconUrl, setIconUrl] = useState(author?.icon?.url);
|
||||
|
||||
|
@ -30,31 +30,32 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
|
|||
badgeColor = ' bg-purple-500';
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
iconSize = 12;
|
||||
containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[3px] w-6 h-6';
|
||||
containerClass = 'z-10 relative rounded bg-grey-100 shrink-0 flex items-center justify-center w-6 h-6';
|
||||
imageClass = 'z-10 rounded w-6 h-6 object-cover';
|
||||
break;
|
||||
case 'sm':
|
||||
containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-10 h-10';
|
||||
containerClass = 'z-10 relative rounded bg-grey-100 shrink-0 flex items-center justify-center w-10 h-10';
|
||||
break;
|
||||
case 'lg':
|
||||
containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-22 h-22';
|
||||
containerClass = 'z-10 relative rounded-xl bg-grey-100 shrink-0 flex items-center justify-center w-22 h-22';
|
||||
imageClass = 'z-10 rounded-xl w-22 h-22 object-cover';
|
||||
break;
|
||||
default:
|
||||
containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-10 h-10';
|
||||
containerClass = 'z-10 relative rounded bg-grey-100 shrink-0 flex items-center justify-center w-10 h-10';
|
||||
break;
|
||||
}
|
||||
|
||||
if (iconUrl) {
|
||||
return (
|
||||
<a className='relative z-10 h-10 w-10 shrink-0 pt-[3px] transition-opacity hover:opacity-80' href={author?.url} rel='noopener noreferrer' target='_blank'>
|
||||
<a className={containerClass} href={author?.url} rel='noopener noreferrer' target='_blank'>
|
||||
<img
|
||||
className={imageClass}
|
||||
src={iconUrl}
|
||||
onError={() => setIconUrl(null)}
|
||||
onError={() => setIconUrl(undefined)}
|
||||
/>
|
||||
{badge && (
|
||||
<div className={`${badgeClass} ${badgeColor}`}>
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
import {useEffect, useState} from 'react';
|
||||
|
||||
import {Button} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import {useFollow} from '../../hooks/useActivityPubQueries';
|
||||
|
||||
interface FollowButtonProps {
|
||||
className?: string;
|
||||
following: boolean;
|
||||
handle: string;
|
||||
type?: 'button' | 'link';
|
||||
onFollow?: () => void;
|
||||
onUnfollow?: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const FollowButton: React.FC<FollowButtonProps> = ({
|
||||
className,
|
||||
following,
|
||||
handle,
|
||||
type = 'button',
|
||||
onFollow = noop,
|
||||
onUnfollow = noop
|
||||
}) => {
|
||||
const [isFollowing, setIsFollowing] = useState(following);
|
||||
|
||||
const mutation = useFollow('index',
|
||||
noop,
|
||||
() => {
|
||||
setIsFollowing(false);
|
||||
onUnfollow();
|
||||
}
|
||||
);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (isFollowing) {
|
||||
setIsFollowing(false);
|
||||
onUnfollow();
|
||||
|
||||
// @TODO: Implement unfollow mutation
|
||||
} else {
|
||||
setIsFollowing(true);
|
||||
onFollow();
|
||||
|
||||
mutation.mutate(handle);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsFollowing(following);
|
||||
}, [following]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
color='black'
|
||||
label={isFollowing ? 'Following' : 'Follow'}
|
||||
link={type === 'link'}
|
||||
onClick={(event) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
handleClick();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowButton;
|
|
@ -1,28 +1,10 @@
|
|||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import {ActivityPubAPI} from '../../api/activitypub';
|
||||
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
|
||||
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
import {useState} from 'react';
|
||||
|
||||
function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
const siteUrl = siteData?.url ?? window.location.origin;
|
||||
const api = new ActivityPubAPI(
|
||||
new URL(siteUrl),
|
||||
new URL('/ghost/api/admin/identities/', window.location.origin),
|
||||
handle
|
||||
);
|
||||
return useMutation({
|
||||
async mutationFn(username: string) {
|
||||
return api.follow(username);
|
||||
},
|
||||
onSuccess,
|
||||
onError
|
||||
});
|
||||
}
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
import {useFollow} from '../../hooks/useActivityPubQueries';
|
||||
|
||||
const FollowSite = NiceModal.create(() => {
|
||||
const {updateRoute} = useRouting();
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
import React from 'react';
|
||||
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import {Activity, ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, Heading, Modal} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import APAvatar from '../global/APAvatar';
|
||||
import FeedItem from '../feed/FeedItem';
|
||||
import FollowButton from '../global/FollowButton';
|
||||
|
||||
interface ProfileSearchResultModalProps {
|
||||
profile: {
|
||||
actor: ActorProperties;
|
||||
handle: string;
|
||||
isFollowing: boolean;
|
||||
posts: Activity[];
|
||||
};
|
||||
onFollow: () => void;
|
||||
onUnfollow: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
|
||||
profile,
|
||||
onFollow = noop,
|
||||
onUnfollow = noop
|
||||
}) => {
|
||||
const modal = useModal();
|
||||
const attachments = (profile.actor.attachment || [])
|
||||
.filter(attachment => attachment.type === 'PropertyValue');
|
||||
const posts = profile.posts; // @TODO: Do any filtering / manipulation here
|
||||
|
||||
return (
|
||||
<Modal
|
||||
align='right'
|
||||
animate={true}
|
||||
footer={<></>}
|
||||
height={'full'}
|
||||
padding={false}
|
||||
size='bleed'
|
||||
width={640}
|
||||
>
|
||||
<div className='sticky top-0 z-50 border-grey-200 bg-white py-3'>
|
||||
<div className='grid h-8 grid-cols-3'>
|
||||
<div className='col-[3/4] flex items-center justify-end space-x-6 px-8'>
|
||||
<Button icon='close' size='sm' unstyled onClick={() => modal.remove()}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='z-0 mx-auto mt-4 flex w-full max-w-[580px] flex-col items-center pb-16'>
|
||||
<div className='mx-auto w-full'>
|
||||
{profile.actor.image && (<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-grey-200 to-grey-100'>
|
||||
<img
|
||||
alt={profile.actor.name}
|
||||
className='h-full w-full object-cover'
|
||||
src={profile.actor.image.url}
|
||||
/>
|
||||
</div>)}
|
||||
<div className={`${profile.actor.image && '-mt-12'} px-4`}>
|
||||
<div className='flex items-end justify-between'>
|
||||
<div className='rounded-xl outline outline-4 outline-white'>
|
||||
<APAvatar
|
||||
author={profile.actor}
|
||||
size='lg'
|
||||
/>
|
||||
</div>
|
||||
<FollowButton
|
||||
following={profile.isFollowing}
|
||||
handle={profile.handle}
|
||||
onFollow={onFollow}
|
||||
onUnfollow={onUnfollow}
|
||||
/>
|
||||
</div>
|
||||
<Heading className='mt-4' level={3}>{profile.actor.name}</Heading>
|
||||
<span className='mt-1 inline-block text-[1.5rem] text-grey-800'>{profile.handle}</span>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{__html: profile.actor.summary}}
|
||||
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
|
||||
/>
|
||||
{attachments.map(attachment => (
|
||||
<span className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
|
||||
<span className={`text-xs font-semibold`}>{attachment.name}</span>
|
||||
<span dangerouslySetInnerHTML={{__html: attachment.value}} className='ap-profile-content truncate'/>
|
||||
</span>
|
||||
))}
|
||||
|
||||
<Heading className='mt-8' level={5}>Posts</Heading>
|
||||
|
||||
{posts.map((post) => {
|
||||
if (post.type === 'Announce') {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<FeedItem
|
||||
actor={profile.actor}
|
||||
comments={post.object.replies}
|
||||
layout='feed'
|
||||
object={post.object}
|
||||
type={post.type}
|
||||
onCommentClick={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NiceModal.create(ProfileSearchResultModal);
|
|
@ -1,5 +1,5 @@
|
|||
import {Activity} from '../components/activities/ActivityItem';
|
||||
import {ActivityPubAPI} from '../api/activitypub';
|
||||
import {ActivityPubAPI, type ProfileSearchResult, type SearchResults} from '../api/activitypub';
|
||||
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
|
||||
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
|
||||
|
@ -231,3 +231,51 @@ export function useActivitiesForUser({
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchForUser(handle: string, query: string) {
|
||||
const siteUrl = useSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ['search', {handle, query}];
|
||||
|
||||
const searchQuery = useQuery({
|
||||
enabled: query !== '',
|
||||
queryKey,
|
||||
async queryFn() {
|
||||
return api.search(query);
|
||||
}
|
||||
});
|
||||
|
||||
const updateProfileSearchResult = (id: string, updated: Partial<ProfileSearchResult>) => {
|
||||
queryClient.setQueryData(queryKey, (current: SearchResults | undefined) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
profiles: current.profiles.map((item: ProfileSearchResult) => {
|
||||
if (item.actor.id === id) {
|
||||
return {...item, ...updated};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {searchQuery, updateProfileSearchResult};
|
||||
}
|
||||
|
||||
export function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
|
||||
const siteUrl = useSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return useMutation({
|
||||
async mutationFn(username: string) {
|
||||
return api.follow(username);
|
||||
},
|
||||
onSuccess,
|
||||
onError
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -24,12 +24,12 @@ animation: bump 0.3s ease-in-out;
|
|||
fill: #F50B23;
|
||||
}
|
||||
|
||||
.ap-note-content a {
|
||||
.ap-note-content a, .ap-profile-content a {
|
||||
color: rgb(236 72 153) !important;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ap-note-content a:hover {
|
||||
.ap-note-content a:hover, .ap-profile-content a:hover {
|
||||
color: rgb(190, 25, 99) !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
|
|
@ -23,13 +23,22 @@ export type ObjectProperties = {
|
|||
|
||||
export type ActorProperties = {
|
||||
'@context': string | (string | object)[];
|
||||
attachment: object[];
|
||||
attachment?: {
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
discoverable: boolean;
|
||||
featured: string;
|
||||
followers: string;
|
||||
following: string;
|
||||
id: string | null;
|
||||
image: string;
|
||||
image: {
|
||||
url: string;
|
||||
};
|
||||
icon: {
|
||||
url: string;
|
||||
};
|
||||
inbox: string;
|
||||
manuallyApprovesFollowers: boolean;
|
||||
name: string;
|
||||
|
|
|
@ -30695,6 +30695,11 @@ use-callback-ref@^1.3.0:
|
|||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-debounce@10.0.3:
|
||||
version "10.0.3"
|
||||
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.3.tgz#636094a37f7aa2bcc77b26b961481a0b571bf7ea"
|
||||
integrity sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg==
|
||||
|
||||
use-isomorphic-layout-effect@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
|
||||
|
|
Loading…
Add table
Reference in a new issue