mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Updated following/followers in activitypub profile preview to be dynamic (#21170)
refs [AP-442](https://linear.app/tryghost/issue/AP-442/add-dynamic-following-followers-to-search-result-profile), [TryGhost/ActivityPub#53](https://github.com/TryGhost/ActivityPub/pull/53) Updated following/followers in activitypub profile preview to be dynamic
This commit is contained in:
parent
bce5d9d588
commit
d3d2ea44e4
5 changed files with 756 additions and 26 deletions
|
@ -623,4 +623,472 @@ describe('ActivityPubAPI', function () {
|
|||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFollowersForProfile', function () {
|
||||
test('It returns an array of followers for a profile', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: {
|
||||
response: JSONResponse({
|
||||
followers: [
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/bar'
|
||||
},
|
||||
isFollowing: false
|
||||
},
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/baz'
|
||||
},
|
||||
isFollowing: false
|
||||
}
|
||||
],
|
||||
next: null
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowersForProfile(handle);
|
||||
|
||||
expect(actual.followers).toEqual([
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/bar'
|
||||
},
|
||||
isFollowing: false
|
||||
},
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/baz'
|
||||
},
|
||||
isFollowing: false
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('It returns next if it is present in the response', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: {
|
||||
response: JSONResponse({
|
||||
followers: [],
|
||||
next: 'abc123'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowersForProfile(handle);
|
||||
|
||||
expect(actual.next).toEqual('abc123');
|
||||
});
|
||||
|
||||
test('It includes next in the query when provided', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
const next = 'abc123';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers?next=${next}`]: {
|
||||
response: JSONResponse({
|
||||
followers: [
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/qux'
|
||||
},
|
||||
isFollowing: false
|
||||
}
|
||||
],
|
||||
next: null
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowersForProfile(handle, next);
|
||||
const expected = {
|
||||
followers: [
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/qux'
|
||||
},
|
||||
isFollowing: false
|
||||
}
|
||||
],
|
||||
next: null
|
||||
};
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
test('It returns a default return value when the response is null', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: {
|
||||
response: JSONResponse(null)
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowersForProfile(handle);
|
||||
const expected = {
|
||||
followers: [],
|
||||
next: null
|
||||
};
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
test('It returns a default return value if followers is not present in the response', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: {
|
||||
response: JSONResponse({})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowersForProfile(handle);
|
||||
const expected = {
|
||||
followers: [],
|
||||
next: null
|
||||
};
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
test('It returns an empty array of followers if followers in the response is not an array', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: {
|
||||
response: JSONResponse({
|
||||
followers: {}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowersForProfile(handle);
|
||||
|
||||
expect(actual.followers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFollowingForProfile', function () {
|
||||
test('It returns a following arrayfor a profile', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: {
|
||||
response: JSONResponse({
|
||||
following: [
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/bar'
|
||||
},
|
||||
isFollowing: false
|
||||
},
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/baz'
|
||||
},
|
||||
isFollowing: false
|
||||
}
|
||||
],
|
||||
next: null
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowingForProfile(handle);
|
||||
|
||||
expect(actual.following).toEqual([
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/bar'
|
||||
},
|
||||
isFollowing: false
|
||||
},
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/baz'
|
||||
},
|
||||
isFollowing: false
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('It returns next if it is present in the response', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: {
|
||||
response: JSONResponse({
|
||||
following: [],
|
||||
next: 'abc123'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowingForProfile(handle);
|
||||
|
||||
expect(actual.next).toEqual('abc123');
|
||||
});
|
||||
|
||||
test('It includes next in the query when provided', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
const next = 'abc123';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/following?next=${next}`]: {
|
||||
response: JSONResponse({
|
||||
following: [
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/qux'
|
||||
},
|
||||
isFollowing: false
|
||||
}
|
||||
],
|
||||
next: null
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowingForProfile(handle, next);
|
||||
const expected = {
|
||||
following: [
|
||||
{
|
||||
actor: {
|
||||
id: 'https://example.com/users/qux'
|
||||
},
|
||||
isFollowing: false
|
||||
}
|
||||
],
|
||||
next: null
|
||||
};
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
test('It returns a default return value when the response is null', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: {
|
||||
response: JSONResponse(null)
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowingForProfile(handle);
|
||||
const expected = {
|
||||
following: [],
|
||||
next: null
|
||||
};
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
test('It returns a default return value if following is not present in the response', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: {
|
||||
response: JSONResponse({})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowingForProfile(handle);
|
||||
const expected = {
|
||||
following: [],
|
||||
next: null
|
||||
};
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
test('It returns an empty following array if following in the response is not an array', async function () {
|
||||
const handle = '@foo@bar.baz';
|
||||
|
||||
const fakeFetch = Fetch({
|
||||
'https://auth.api/': {
|
||||
response: JSONResponse({
|
||||
identities: [{
|
||||
token: 'fake-token'
|
||||
}]
|
||||
})
|
||||
},
|
||||
[`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: {
|
||||
response: JSONResponse({
|
||||
following: {}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const api = new ActivityPubAPI(
|
||||
new URL('https://activitypub.api'),
|
||||
new URL('https://auth.api'),
|
||||
'index',
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
const actual = await api.getFollowingForProfile(handle);
|
||||
|
||||
expect(actual.following).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,6 +15,22 @@ export interface SearchResults {
|
|||
profiles: ProfileSearchResult[];
|
||||
}
|
||||
|
||||
export interface GetFollowersForProfileResponse {
|
||||
followers: {
|
||||
actor: Actor;
|
||||
isFollowing: boolean;
|
||||
}[];
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
export interface GetFollowingForProfileResponse {
|
||||
following: {
|
||||
actor: Actor;
|
||||
isFollowing: boolean;
|
||||
}[];
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
export class ActivityPubAPI {
|
||||
constructor(
|
||||
private readonly apiUrl: URL,
|
||||
|
@ -125,6 +141,68 @@ export class ActivityPubAPI {
|
|||
return 0;
|
||||
}
|
||||
|
||||
async getFollowersForProfile(handle: string, next?: string): Promise<GetFollowersForProfileResponse> {
|
||||
const url = new URL(`.ghost/activitypub/profile/${handle}/followers`, this.apiUrl);
|
||||
if (next) {
|
||||
url.searchParams.set('next', next);
|
||||
}
|
||||
|
||||
const json = await this.fetchJSON(url);
|
||||
|
||||
if (json === null) {
|
||||
return {
|
||||
followers: [],
|
||||
next: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!('followers' in json)) {
|
||||
return {
|
||||
followers: [],
|
||||
next: null
|
||||
};
|
||||
}
|
||||
|
||||
const followers = Array.isArray(json.followers) ? json.followers : [];
|
||||
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
|
||||
|
||||
return {
|
||||
followers,
|
||||
next: nextPage
|
||||
};
|
||||
}
|
||||
|
||||
async getFollowingForProfile(handle: string, next?: string): Promise<GetFollowingForProfileResponse> {
|
||||
const url = new URL(`.ghost/activitypub/profile/${handle}/following`, this.apiUrl);
|
||||
if (next) {
|
||||
url.searchParams.set('next', next);
|
||||
}
|
||||
|
||||
const json = await this.fetchJSON(url);
|
||||
|
||||
if (json === null) {
|
||||
return {
|
||||
following: [],
|
||||
next: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!('following' in json)) {
|
||||
return {
|
||||
following: [],
|
||||
next: null
|
||||
};
|
||||
}
|
||||
|
||||
const following = Array.isArray(json.following) ? json.following : [];
|
||||
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
|
||||
|
||||
return {
|
||||
following,
|
||||
next: nextPage
|
||||
};
|
||||
}
|
||||
|
||||
async follow(username: string): Promise<void> {
|
||||
const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl);
|
||||
await this.fetchJSON(url, 'POST');
|
||||
|
|
|
@ -18,6 +18,7 @@ interface SearchResultItem {
|
|||
actor: ActorProperties;
|
||||
handle: string;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
isFollowing: boolean;
|
||||
posts: Activity[];
|
||||
}
|
||||
|
@ -84,10 +85,10 @@ const Search: React.FC<SearchProps> = ({}) => {
|
|||
icon: {
|
||||
url: 'https://anujahooja.com/assets/images/image12.jpg?v=601ebe30'
|
||||
}
|
||||
|
||||
} as ActorProperties,
|
||||
handle: '@quillmatiq@mastodon.social',
|
||||
followerCount: 436,
|
||||
followingCount: 634,
|
||||
isFollowing: false,
|
||||
posts: []
|
||||
},
|
||||
|
@ -105,6 +106,7 @@ const Search: React.FC<SearchProps> = ({}) => {
|
|||
} as ActorProperties,
|
||||
handle: '@miaq@flipboard.social',
|
||||
followerCount: 533,
|
||||
followingCount: 335,
|
||||
isFollowing: false,
|
||||
posts: []
|
||||
},
|
||||
|
@ -122,6 +124,7 @@ const Search: React.FC<SearchProps> = ({}) => {
|
|||
} as ActorProperties,
|
||||
handle: '@mallory@techpolicy.social',
|
||||
followerCount: 1100,
|
||||
followingCount: 11,
|
||||
isFollowing: false,
|
||||
posts: []
|
||||
}
|
||||
|
|
|
@ -1,17 +1,146 @@
|
|||
import React from 'react';
|
||||
import React, {useEffect, useRef, useState} 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 {Button, Heading, List, LoadingIndicator, Modal, NoValueLabel, Tab,TabView} from '@tryghost/admin-x-design-system';
|
||||
import {UseInfiniteQueryResult} from '@tanstack/react-query';
|
||||
|
||||
import {type GetFollowersForProfileResponse, type GetFollowingForProfileResponse} from '../../api/activitypub';
|
||||
import {useFollowersForProfile, useFollowingForProfile} from '../../hooks/useActivityPubQueries';
|
||||
|
||||
import APAvatar from '../global/APAvatar';
|
||||
import ActivityItem from '../activities/ActivityItem';
|
||||
import FeedItem from '../feed/FeedItem';
|
||||
import FollowButton from '../global/FollowButton';
|
||||
import getUsername from '../../utils/get-username';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
type QueryPageData = GetFollowersForProfileResponse | GetFollowingForProfileResponse;
|
||||
|
||||
type QueryFn = (handle: string) => UseInfiniteQueryResult<QueryPageData, unknown>;
|
||||
|
||||
type ActorListProps = {
|
||||
handle: string,
|
||||
noResultsMessage: string,
|
||||
queryFn: QueryFn,
|
||||
resolveDataFn: (data: QueryPageData) => GetFollowersForProfileResponse['followers'] | GetFollowingForProfileResponse['following'];
|
||||
};
|
||||
|
||||
const ActorList: React.FC<ActorListProps> = ({
|
||||
handle,
|
||||
noResultsMessage,
|
||||
queryFn,
|
||||
resolveDataFn
|
||||
}) => {
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading
|
||||
} = queryFn(handle);
|
||||
|
||||
const actorData = (data?.pages.flatMap(resolveDataFn) ?? []);
|
||||
|
||||
// Intersection observer to fetch more data when the user scrolls
|
||||
// to the bottom of the list
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
});
|
||||
|
||||
if (loadMoreRef.current) {
|
||||
observerRef.current.observe(loadMoreRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
actorData.length === 0 && !isLoading ? (
|
||||
<NoValueLabel icon='user-add'>
|
||||
{noResultsMessage}
|
||||
</NoValueLabel>
|
||||
) : (
|
||||
<List>
|
||||
{actorData.map(({actor, isFollowing}) => {
|
||||
return (
|
||||
<ActivityItem key={actor.id} url={actor.url}>
|
||||
<APAvatar author={actor} />
|
||||
<div>
|
||||
<div className='text-grey-600'>
|
||||
<span className='mr-1 font-bold text-black'>{actor.name || actor.preferredUsername || 'Unknown'}</span>
|
||||
<div className='text-sm'>{getUsername(actor)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<FollowButton
|
||||
className='ml-auto'
|
||||
following={isFollowing}
|
||||
handle={getUsername(actor)}
|
||||
type='link'
|
||||
/>
|
||||
</ActivityItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
<div ref={loadMoreRef} className='h-1'></div>
|
||||
{
|
||||
(isFetchingNextPage || isLoading) && (
|
||||
<div className='mt-6 flex flex-col items-center justify-center space-y-4 text-center'>
|
||||
<LoadingIndicator size='md' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FollowersTab: React.FC<{handle: string}> = ({handle}) => {
|
||||
return (
|
||||
<ActorList
|
||||
handle={handle}
|
||||
noResultsMessage={`${handle} has no followers yet`}
|
||||
queryFn={useFollowersForProfile}
|
||||
resolveDataFn={page => ('followers' in page ? page.followers : [])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const FollowingTab: React.FC<{handle: string}> = ({handle}) => {
|
||||
return (
|
||||
<ActorList
|
||||
handle={handle}
|
||||
noResultsMessage={`${handle} is not following anyone yet`}
|
||||
queryFn={useFollowingForProfile}
|
||||
resolveDataFn={page => ('following' in page ? page.following : [])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ProfileSearchResultModalProps {
|
||||
profile: {
|
||||
actor: ActorProperties;
|
||||
handle: string;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
isFollowing: boolean;
|
||||
posts: Activity[];
|
||||
};
|
||||
|
@ -19,7 +148,7 @@ interface ProfileSearchResultModalProps {
|
|||
onUnfollow: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
type ProfileTab = 'posts' | 'following' | 'followers';
|
||||
|
||||
const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
|
||||
profile,
|
||||
|
@ -27,9 +156,47 @@ const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
|
|||
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
|
||||
const [selectedTab, setSelectedTab] = useState<ProfileTab>('posts');
|
||||
|
||||
const attachments = (profile.actor.attachment || []);
|
||||
const posts = (profile.posts || []).filter(post => post.type !== 'Announce');
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'posts',
|
||||
title: 'Posts',
|
||||
contents: (
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<FeedItem
|
||||
actor={profile.actor}
|
||||
comments={post.object.replies}
|
||||
layout='feed'
|
||||
object={post.object}
|
||||
type={post.type}
|
||||
onCommentClick={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'following',
|
||||
title: 'Following',
|
||||
contents: (
|
||||
<FollowingTab handle={profile.handle} />
|
||||
),
|
||||
counter: profile.followingCount
|
||||
},
|
||||
{
|
||||
id: 'followers',
|
||||
title: 'Followers',
|
||||
contents: (
|
||||
<FollowersTab handle={profile.handle} />
|
||||
),
|
||||
counter: profile.followerCount
|
||||
}
|
||||
].filter(Boolean) as Tab<ProfileTab>[];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
@ -84,25 +251,12 @@ const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
|
|||
<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={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
<TabView<ProfileTab>
|
||||
containerClassName='mt-6'
|
||||
selectedTab={selectedTab}
|
||||
tabs={tabs}
|
||||
onTabChange={setSelectedTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -279,3 +279,30 @@ export function useFollow(handle: string, onSuccess: () => void, onError: () =>
|
|||
});
|
||||
}
|
||||
|
||||
export function useFollowersForProfile(handle: string) {
|
||||
const siteUrl = useSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return useInfiniteQuery({
|
||||
queryKey: [`followers:${handle}`],
|
||||
async queryFn({pageParam}: {pageParam?: string}) {
|
||||
return api.getFollowersForProfile(handle, pageParam);
|
||||
},
|
||||
getNextPageParam(prevPage) {
|
||||
return prevPage.next;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useFollowingForProfile(handle: string) {
|
||||
const siteUrl = useSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return useInfiniteQuery({
|
||||
queryKey: [`following:${handle}`],
|
||||
async queryFn({pageParam}: {pageParam?: string}) {
|
||||
return api.getFollowingForProfile(handle, pageParam);
|
||||
},
|
||||
getNextPageParam(prevPage) {
|
||||
return prevPage.next;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue