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 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;
|
|
@ -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';
|
||||
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;
|
Ładowanie…
Reference in New Issue