basic follow pacl set up

follow-packs
Mary Kate Fain 2025-04-30 12:23:43 -05:00
rodzic c2376d15f6
commit dd9e157e4e
4 zmienionych plików z 447 dodań i 31 usunięć

Wyświetl plik

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

Wyświetl plik

@ -15,7 +15,7 @@ import SearchResults from 'soapbox/features/compose/components/search-results.ts
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 AccountsCarousel from 'soapbox/features/explore/components/popular-accounts.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';
@ -68,7 +68,7 @@ const TrendsTab = () => {
const AccountsTab = () => {
return (
<Stack space={4} className='pt-1'>
<AccountsCarousel />
<FollowPacks />
<Divider />
@ -164,4 +164,4 @@ const ExplorePage = () => {
);
};
export default ExplorePage;
export default ExplorePage;

Wyświetl plik

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

Wyświetl plik

@ -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';
import { NBunker } from 'soapbox/features/nostr/NBunker.ts';
import { useSigner } from 'soapbox/hooks/nostr/useSigner.ts';
export const useRelays = (): string[] => {
// Default relays if nothing is configured
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() {
const { relay } = useNostr();
const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner();
export const useBunker = () => {
// Placeholder for future Nostr bunker implementation
return {
isConnected: false,
connect: () => Promise.resolve(false),
disconnect: () => {},
};
};
useEffect(() => {
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 };
export default useBunker;