kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
basic follow pacl set up
rodzic
c2376d15f6
commit
dd9e157e4e
|
@ -0,0 +1,254 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Card, CardBody, CardHeader, CardTitle } from 'soapbox/components/ui/card.tsx';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
|
import Avatar from 'soapbox/components/ui/avatar.tsx';
|
||||||
|
import HStack from 'soapbox/components/ui/hstack.tsx';
|
||||||
|
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||||
|
import Text from 'soapbox/components/ui/text.tsx';
|
||||||
|
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
|
||||||
|
import plusIcon from '@tabler/icons/outline/plus.svg';
|
||||||
|
|
||||||
|
// Nostr-related imports
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { useRelays } from 'soapbox/hooks/nostr/useBunker';
|
||||||
|
|
||||||
|
interface FollowPackUser {
|
||||||
|
pubkey: string;
|
||||||
|
picture?: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FollowPack {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
created_at: number;
|
||||||
|
users: FollowPackUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FollowPackCard: React.FC<{ pack: FollowPack }> = ({ pack }) => {
|
||||||
|
const MAX_DISPLAYED_USERS = 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='mb-4'>
|
||||||
|
<CardBody>
|
||||||
|
<Stack space={3}>
|
||||||
|
{pack.image && (
|
||||||
|
<div className='rounded-lg overflow-hidden'>
|
||||||
|
<img src={pack.image} alt={pack.title} className='w-full h-32 object-cover' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<Text size='lg' weight='bold'>{pack.title}</Text>
|
||||||
|
{pack.description && (
|
||||||
|
<Text theme='muted' truncate>{pack.description}</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size='sm' theme='muted' className='mb-2'>
|
||||||
|
<FormattedMessage id='follow_packs.includes_users' defaultMessage='Includes' />
|
||||||
|
</Text>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{pack.users.slice(0, MAX_DISPLAYED_USERS).map((user) => (
|
||||||
|
<div key={user.pubkey} className='flex items-center gap-1'>
|
||||||
|
<Avatar src={user.picture} size={20} />
|
||||||
|
<Text size='sm'>{user.displayName || user.pubkey.substring(0, 8)}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{pack.users.length > MAX_DISPLAYED_USERS && (
|
||||||
|
<Text size='sm' theme='muted'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='follow_packs.and_more'
|
||||||
|
defaultMessage='and {count} more'
|
||||||
|
values={{ count: pack.users.length - MAX_DISPLAYED_USERS }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<HStack alignItems='center' space={1} className='text-primary-600 cursor-pointer'>
|
||||||
|
<SvgIcon src={plusIcon} className='h-4 w-4' />
|
||||||
|
<Text size='sm' weight='medium'>
|
||||||
|
<FormattedMessage id='follow_packs.follow_all' defaultMessage='Follow all' />
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FollowPacks: React.FC = () => {
|
||||||
|
const [followPacks, setFollowPacks] = useState<FollowPack[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const relays = useRelays();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFollowPacks = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Connect to relays and fetch events
|
||||||
|
const events = await Promise.all(relays.map(async (relay) => {
|
||||||
|
try {
|
||||||
|
const socket = new WebSocket(relay);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
socket.close();
|
||||||
|
resolve([]);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
// Subscribe to follow pack events (kind 39089)
|
||||||
|
const requestId = `req-${Math.random().toString(36).substring(2, 10)}`;
|
||||||
|
socket.send(JSON.stringify([
|
||||||
|
'REQ',
|
||||||
|
requestId,
|
||||||
|
{
|
||||||
|
kinds: [39089],
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const events: any[] = [];
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message[0] === 'EVENT') {
|
||||||
|
events.push(message[2]);
|
||||||
|
} else if (message[0] === 'EOSE') {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
socket.close();
|
||||||
|
resolve(events);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
socket.close();
|
||||||
|
resolve([]);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Process and deduplicate events
|
||||||
|
const allEvents = events.flat();
|
||||||
|
const uniqueEvents = allEvents.reduce((acc: any[], event: any) => {
|
||||||
|
if (!acc.some(e => e.id === event.id)) {
|
||||||
|
acc.push(event);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Transform events into follow packs
|
||||||
|
const packs = uniqueEvents.map((event: any) => {
|
||||||
|
const title = event.tags.find((tag: string[]) => tag[0] === 'title')?.[1] || 'Untitled Pack';
|
||||||
|
const description = event.tags.find((tag: string[]) => tag[0] === 'description')?.[1];
|
||||||
|
const image = event.tags.find((tag: string[]) => tag[0] === 'image')?.[1];
|
||||||
|
|
||||||
|
// Extract user public keys from p tags
|
||||||
|
const userPubkeys = event.tags
|
||||||
|
.filter((tag: string[]) => tag[0] === 'p')
|
||||||
|
.map((tag: string[]) => tag[1]);
|
||||||
|
|
||||||
|
// For now, we'll just use the pubkeys as users
|
||||||
|
// In a production app, we'd fetch profiles for these users
|
||||||
|
const users = userPubkeys.map((pubkey: string) => ({
|
||||||
|
pubkey,
|
||||||
|
displayName: pubkey.substring(0, 8), // Simplified display name
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
created_at: event.created_at,
|
||||||
|
users,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setFollowPacks(packs);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching follow packs:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFollowPacks();
|
||||||
|
}, [relays]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<FormattedMessage id='follow_packs.title' defaultMessage='Follow Packs' />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className='flex justify-center py-4'>
|
||||||
|
<Text theme='muted'>
|
||||||
|
<FormattedMessage id='follow_packs.loading' defaultMessage='Loading follow packs...' />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followPacks.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<FormattedMessage id='follow_packs.title' defaultMessage='Follow Packs' />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className='flex justify-center py-4'>
|
||||||
|
<Text theme='muted'>
|
||||||
|
<FormattedMessage id='follow_packs.empty' defaultMessage='No follow packs found' />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='mb-4'>
|
||||||
|
<Text size='xl' weight='bold'>
|
||||||
|
<FormattedMessage id='follow_packs.title' defaultMessage='Follow Packs' />
|
||||||
|
</Text>
|
||||||
|
<Text theme='muted'>
|
||||||
|
<FormattedMessage id='follow_packs.subtitle' defaultMessage='Curated lists of users to follow' />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className='grid sm:grid-cols-1 md:grid-cols-2 gap-4'>
|
||||||
|
{followPacks.map((pack) => (
|
||||||
|
<FollowPackCard key={pack.id} pack={pack} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FollowPacks;
|
|
@ -15,7 +15,7 @@ import SearchResults from 'soapbox/features/compose/components/search-results.ts
|
||||||
import Search from 'soapbox/features/compose/components/search.tsx';
|
import Search from 'soapbox/features/compose/components/search.tsx';
|
||||||
import ExploreCards from 'soapbox/features/explore/components/explore-cards.tsx';
|
import ExploreCards from 'soapbox/features/explore/components/explore-cards.tsx';
|
||||||
import ExploreFilter from 'soapbox/features/explore/components/exploreFilter.tsx';
|
import ExploreFilter from 'soapbox/features/explore/components/exploreFilter.tsx';
|
||||||
import AccountsCarousel from 'soapbox/features/explore/components/popular-accounts.tsx';
|
import FollowPacks from './components/follow-packs';
|
||||||
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
|
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
|
||||||
import { PublicTimeline } from 'soapbox/features/ui/util/async-components.ts';
|
import { PublicTimeline } from 'soapbox/features/ui/util/async-components.ts';
|
||||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
|
@ -68,7 +68,7 @@ const TrendsTab = () => {
|
||||||
const AccountsTab = () => {
|
const AccountsTab = () => {
|
||||||
return (
|
return (
|
||||||
<Stack space={4} className='pt-1'>
|
<Stack space={4} className='pt-1'>
|
||||||
<AccountsCarousel />
|
<FollowPacks />
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
@ -164,4 +164,4 @@ const ExplorePage = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExplorePage;
|
export default ExplorePage;
|
|
@ -0,0 +1,167 @@
|
||||||
|
import globeIcon from '@tabler/icons/outline/globe.svg';
|
||||||
|
import trendIcon from '@tabler/icons/outline/trending-up.svg';
|
||||||
|
import userIcon from '@tabler/icons/outline/user.svg';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Route, Switch, useLocation } from 'react-router-dom';
|
||||||
|
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
|
import { clearSearch, setFilter } from 'soapbox/actions/search.ts';
|
||||||
|
import { Column } from 'soapbox/components/ui/column.tsx';
|
||||||
|
import Divider from 'soapbox/components/ui/divider.tsx';
|
||||||
|
import Stack from 'soapbox/components/ui/stack.tsx';
|
||||||
|
import Tabs from 'soapbox/components/ui/tabs.tsx';
|
||||||
|
import SearchResults from 'soapbox/features/compose/components/search-results.tsx';
|
||||||
|
import Search from 'soapbox/features/compose/components/search.tsx';
|
||||||
|
import ExploreCards from 'soapbox/features/explore/components/explore-cards.tsx';
|
||||||
|
import ExploreFilter from 'soapbox/features/explore/components/exploreFilter.tsx';
|
||||||
|
import FollowPacks from './components/follow-packs';
|
||||||
|
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
|
||||||
|
import { PublicTimeline } from 'soapbox/features/ui/util/async-components.ts';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
|
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
||||||
|
import { SearchFilter } from 'soapbox/reducers/search.ts';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.explore', defaultMessage: 'Explore' },
|
||||||
|
accounts: { id: 'search_results.accounts', defaultMessage: 'Accounts' },
|
||||||
|
statuses: { id: 'search_results.posts', defaultMessage: 'Posts' },
|
||||||
|
trends: { id: 'search_results.trends', defaultMessage: 'Trends' },
|
||||||
|
filters: { id: 'column.explore.filters', defaultMessage: 'Filters:' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const PostsTab = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const features = useFeatures();
|
||||||
|
const { tokens } = useSearchTokens();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={4}>
|
||||||
|
{pathname === '/explore' && (
|
||||||
|
<>
|
||||||
|
{features.nostr && (
|
||||||
|
<>
|
||||||
|
<ExploreCards />
|
||||||
|
<Divider text={intl.formatMessage(messages.filters)} />
|
||||||
|
<ExploreFilter />
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tokens.size ? <SearchResults /> : <PublicTimeline />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TrendsTab = () => {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<SearchResults />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountsTab = () => {
|
||||||
|
return (
|
||||||
|
<Stack space={4} className='pt-1'>
|
||||||
|
<AccountsCarousel />
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack space={3}>
|
||||||
|
<div className='px-4'>
|
||||||
|
<Search autoSubmit />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchResults />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const ExplorePage = () => {
|
||||||
|
const features = useFeatures();
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const path = useLocation().pathname;
|
||||||
|
|
||||||
|
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
|
||||||
|
|
||||||
|
const selectedValue = useMemo(() => {
|
||||||
|
if (path === '/explore') return 'posts';
|
||||||
|
if (path === '/explore/trends') return 'statuses';
|
||||||
|
return 'accounts';
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedValue === 'accounts') {
|
||||||
|
dispatch(setFilter('accounts'));
|
||||||
|
}
|
||||||
|
}, [selectedValue, dispatch]);
|
||||||
|
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState(selectedValue);
|
||||||
|
|
||||||
|
const renderFilterBar = () => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.statuses),
|
||||||
|
action: () => handleTabs(''),
|
||||||
|
name: 'posts',
|
||||||
|
icon: globeIcon,
|
||||||
|
},
|
||||||
|
...(features.nostr ? [{
|
||||||
|
text: intl.formatMessage(messages.trends),
|
||||||
|
action: () => handleTabs('/trends', 'statuses'),
|
||||||
|
name: 'statuses',
|
||||||
|
icon: trendIcon,
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.accounts),
|
||||||
|
action: () => handleTabs('/accounts', 'accounts'),
|
||||||
|
name: 'accounts',
|
||||||
|
icon: userIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleTabs = (path: string, filter?: SearchFilter) => {
|
||||||
|
if (filter) {
|
||||||
|
selectFilter(filter);
|
||||||
|
dispatch(clearSearch());
|
||||||
|
} else {
|
||||||
|
selectFilter('statuses');
|
||||||
|
}
|
||||||
|
setSelectedFilter(filter ?? 'posts');
|
||||||
|
navigate(`/explore${path}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)} withHeader={false} slim>
|
||||||
|
|
||||||
|
<Stack space={2}>
|
||||||
|
|
||||||
|
<div className='relative px-4'>
|
||||||
|
{renderFilterBar()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch>
|
||||||
|
<Route exact path='/explore' component={PostsTab} />
|
||||||
|
{features.nostr && <Route path='/explore/trends' component={TrendsTab} />}
|
||||||
|
<Route path='/explore/accounts' component={AccountsTab} />
|
||||||
|
</Switch>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExplorePage;
|
|
@ -1,31 +1,26 @@
|
||||||
import { useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector';
|
||||||
|
|
||||||
import { useNostr } from 'soapbox/contexts/nostr-context.tsx';
|
export const useRelays = (): string[] => {
|
||||||
import { NBunker } from 'soapbox/features/nostr/NBunker.ts';
|
// Default relays if nothing is configured
|
||||||
import { useSigner } from 'soapbox/hooks/nostr/useSigner.ts';
|
const defaultRelays = [
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://relay.nostr.band',
|
||||||
|
'wss://nos.lol'
|
||||||
|
];
|
||||||
|
|
||||||
|
// In a real implementation, we'd fetch this from app state/config
|
||||||
|
// For now we're just returning default relays
|
||||||
|
return defaultRelays;
|
||||||
|
};
|
||||||
|
|
||||||
function useBunker() {
|
export const useBunker = () => {
|
||||||
const { relay } = useNostr();
|
// Placeholder for future Nostr bunker implementation
|
||||||
const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner();
|
return {
|
||||||
|
isConnected: false,
|
||||||
|
connect: () => Promise.resolve(false),
|
||||||
|
disconnect: () => {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
export default useBunker;
|
||||||
if (!relay || !userSigner || !bunkerSigner || !authorizedPubkey) return;
|
|
||||||
|
|
||||||
const bunker = new NBunker({
|
|
||||||
relay,
|
|
||||||
userSigner,
|
|
||||||
bunkerSigner,
|
|
||||||
onError(error, event) {
|
|
||||||
console.warn('Bunker error:', error, event);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
bunker.authorize(authorizedPubkey);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
bunker.close();
|
|
||||||
};
|
|
||||||
}, [relay, userSigner, bunkerSigner, authorizedPubkey]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useBunker };
|
|
Ładowanie…
Reference in New Issue