Merge branch 'follow-packs' into 'main'

Add Follow packs

See merge request soapbox-pub/soapbox!3368
merge-requests/3368/merge
Mary Kate 2025-04-30 19:08:51 +00:00
commit 965b15d0b7
5 zmienionych plików z 776 dodań i 33 usunięć

Wyświetl plik

@ -0,0 +1,581 @@
import arrowIcon from '@tabler/icons/outline/chevron-down.svg';
import plusIcon from '@tabler/icons/outline/plus.svg';
import groupIcon from '@tabler/icons/outline/users.svg';
import React, { useEffect, useState, useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import Avatar from 'soapbox/components/ui/avatar.tsx';
import { Card, CardBody } from 'soapbox/components/ui/card.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import IconButton from 'soapbox/components/ui/icon-button.tsx';
import Spinner from 'soapbox/components/ui/spinner.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
import Text from 'soapbox/components/ui/text.tsx';
// Define standard relays for production
const STANDARD_RELAYS = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
'wss://nostr.wine',
'wss://relay.nostr.org',
];
interface FollowPackUser {
pubkey: string;
picture?: string;
displayName?: string;
name?: string;
nip05?: string;
loaded?: boolean;
}
interface FollowPack {
id: string;
pubkey: string;
title: string;
description?: string;
image?: string;
created_at: number;
url?: string;
users: FollowPackUser[];
}
const ImageWithFallback: React.FC<{ src?: string; alt: string; className?: string }> = ({
src,
alt,
className = '',
}) => {
const [isImgLoaded, setIsImgLoaded] = useState(false);
const [imgError, setImgError] = useState(false);
// Default gradient background
const gradientStyle = {
background: 'linear-gradient(45deg, #6364ff, #563acc)',
};
const handleError = () => {
setImgError(true);
setIsImgLoaded(true);
};
const handleLoad = () => {
setIsImgLoaded(true);
};
return (
<div
className={`relative overflow-hidden ${className}`}
style={imgError ? gradientStyle : {}}
>
{!isImgLoaded && (
<div className='absolute inset-0 flex items-center justify-center bg-primary-100'>
<Spinner size={20} />
</div>
)}
{imgError ? (
<div className='flex size-full items-center justify-center'>
<SvgIcon src={groupIcon} className='size-12 text-white opacity-80' />
</div>
) : (
<img
src={src}
alt={alt}
className={`size-full object-cover transition-opacity duration-300 ${isImgLoaded ? 'opacity-100' : 'opacity-0'}`}
onError={handleError}
onLoad={handleLoad}
/>
)}
</div>
);
};
const FollowPackUserDisplay: React.FC<{ user: FollowPackUser; socket?: WebSocket }> = ({ user, socket }) => {
const [profileData, setProfileData] = useState(user);
const [isLoading, setIsLoading] = useState(!user.loaded);
useEffect(() => {
// Only fetch if we don't have profile data and have a socket
if (!user.loaded && socket && socket.readyState === WebSocket.OPEN) {
// Request metadata for this user
const requestId = `req-user-${user.pubkey.substring(0, 6)}`;
socket.send(JSON.stringify([
'REQ',
requestId,
{
kinds: [0], // Metadata events
authors: [user.pubkey],
limit: 1,
},
]));
// Listen for the response
const handleMessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[2]?.kind === 0 && data[2]?.pubkey === user.pubkey) {
// We got a metadata event
try {
const content = JSON.parse(data[2].content);
setProfileData(prev => ({
...prev,
name: content.name,
displayName: content.display_name || content.name,
picture: content.picture,
nip05: content.nip05,
loaded: true,
}));
setIsLoading(false);
} catch (e) {
// Invalid JSON in content
setIsLoading(false);
}
} else if (data[0] === 'EOSE' && data[1] === requestId) {
// End of stored events - if we didn't get metadata, stop loading
setIsLoading(false);
}
} catch (e) {
// Parsing error
setIsLoading(false);
}
};
socket.addEventListener('message', handleMessage);
return () => {
socket.removeEventListener('message', handleMessage);
};
}
return undefined;
}, [user, socket]);
return (
<div className='flex items-center gap-1'>
{isLoading ? (
<>
<div className='flex size-4 items-center justify-center rounded-full bg-gray-100 sm:size-5'>
<Spinner size={10} />
</div>
<Text size='xs' className='sm:text-sm' theme='muted'>{user.pubkey.substring(0, 6)}</Text>
</>
) : (
<>
<Avatar src={profileData.picture} size={16} className='sm:size-5' />
<Text size='xs' className='sm:text-sm'>{profileData.displayName || profileData.name || user.pubkey.substring(0, 6)}</Text>
</>
)}
</div>
);
};
const FollowPackCard: React.FC<{ pack: FollowPack; metadataSocket?: WebSocket }> = ({ pack, metadataSocket }) => {
const MAX_DISPLAYED_USERS = 3;
// If the pack has a URL, render the card as a link to following.space
const CardWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
if (pack.url) {
return (
<a
href={pack.url}
target='_blank'
rel='noopener noreferrer'
className='block text-inherit no-underline'
>
{children}
</a>
);
}
return <>{children}</>;
};
return (
<CardWrapper>
<Card className='mb-3 max-w-full cursor-pointer overflow-hidden border border-primary-200 shadow-sm transition-shadow duration-200 hover:shadow-md sm:mb-4'>
<CardBody className='p-0'>
<Stack space={0}>
<ImageWithFallback
src={pack.image}
alt={pack.title}
className='h-24 w-full sm:h-28 md:h-32'
/>
<div className='p-3 sm:p-4'>
<Stack space={2} className='sm:space-y-3'>
<div className='flex items-center justify-between'>
<div className='w-full overflow-hidden'>
<Text size='sm' weight='bold' className='line-clamp-1 sm:text-base md:text-lg'>{pack.title}</Text>
{pack.description && (
<Text theme='muted' size='xs' className='line-clamp-1 sm:text-sm'>{pack.description}</Text>
)}
</div>
</div>
<div>
<Text size='xs' theme='muted' className='mb-1 sm:mb-2 sm:text-sm'>
<FormattedMessage id='follow_packs.includes_users' defaultMessage='Includes' />
</Text>
<div className='flex flex-wrap gap-1 sm:gap-2'>
{pack.users.slice(0, MAX_DISPLAYED_USERS).map((user) => (
<FollowPackUserDisplay
key={user.pubkey}
user={user}
socket={metadataSocket}
/>
))}
{pack.users.length > MAX_DISPLAYED_USERS && (
<Text size='xs' theme='muted' className='sm:text-sm'>
<FormattedMessage
id='follow_packs.and_more'
defaultMessage='and {count} more'
values={{ count: pack.users.length - MAX_DISPLAYED_USERS }}
/>
</Text>
)}
</div>
</div>
<div className='mt-1 flex justify-end'>
<HStack alignItems='center' space={1} className='cursor-pointer text-primary-600 hover:underline'>
<SvgIcon src={plusIcon} className='size-3 sm:size-4' />
<Text size='xs' weight='medium' className='sm:text-sm'>
<FormattedMessage id='follow_packs.follow_all' defaultMessage='Follow all' />
</Text>
</HStack>
</div>
</Stack>
</div>
</Stack>
</CardBody>
</Card>
</CardWrapper>
);
};
const FollowPacks: React.FC = () => {
const [followPacks, setFollowPacks] = useState<FollowPack[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isOpen, setIsOpen] = useState(true);
const activeConnections = useRef<WebSocket[]>([]);
const metadataSocket = useRef<WebSocket | null>(null);
const intl = useIntl();
// Load isOpen state from localStorage on mount
useEffect(() => {
const isOpenStatus = localStorage.getItem('soapbox:explore:followpacks:status');
if (isOpenStatus) {
setIsOpen(JSON.parse(isOpenStatus));
}
}, []);
const handleClick = () => {
setIsOpen((prev) => {
const newValue = !prev;
localStorage.setItem('soapbox:explore:followpacks:status', JSON.stringify(newValue));
return newValue;
});
};
// Set up a dedicated socket for metadata
useEffect(() => {
// Clean up before creating a new one
if (metadataSocket.current) {
try {
metadataSocket.current.close();
} catch (e) { /* empty */ }
metadataSocket.current = null;
}
// Create a new metadata socket
const socket = new WebSocket('wss://relay.damus.io'); // Use a reliable relay for metadata
metadataSocket.current = socket;
return () => {
if (metadataSocket.current) {
try {
metadataSocket.current.close();
} catch (e) { /* empty */ }
metadataSocket.current = null;
}
};
}, []);
// Production implementation for fetching from Nostr relays
useEffect(() => {
const fetchFollowPacks = async () => {
try {
setIsLoading(true);
// Cleanup any existing connections
activeConnections.current.forEach(socket => {
try {
socket.close();
} catch (e) { /* empty */ }
});
activeConnections.current = [];
const events: any[] = [];
// Connect to relays and send subscription requests
const subscriptions = STANDARD_RELAYS.map(relay => {
return new Promise<void>((resolve) => {
try {
const socket = new WebSocket(relay);
activeConnections.current.push(socket);
const timeout = setTimeout(() => {
try {
socket.close();
} catch (e) { /* empty */ }
resolve();
}, 8000); // Longer timeout for production
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: 30,
},
]));
};
socket.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === 'EVENT' && data[2]) {
events.push(data[2]);
// Process and update events in batches as they come in
if (events.length % 5 === 0) {
processAndUpdatePacks(events);
}
} else if (data[0] === 'EOSE') {
clearTimeout(timeout);
try {
socket.close();
} catch (e) { /* empty */ }
resolve();
}
} catch (error) {
// Ignore parsing errors
}
};
socket.onerror = () => {
clearTimeout(timeout);
resolve();
};
socket.onclose = () => {
clearTimeout(timeout);
resolve();
};
} catch (error) {
resolve();
}
});
});
// Helper function to process and update packs
const processAndUpdatePacks = (eventsToProcess: any[]) => {
// Deduplicate events
const uniqueEvents: any[] = [];
const eventIds = new Set();
for (const event of eventsToProcess) {
if (!eventIds.has(event.id)) {
eventIds.add(event.id);
uniqueEvents.push(event);
}
}
// Transform events into follow packs
const packs = uniqueEvents
.filter(event => {
// Filter valid follow packs (must have title and at least 1 user)
const hasTitle = event.tags.some((tag: string[]) => tag[0] === 'title');
const hasUsers = event.tags.some((tag: string[]) => tag[0] === 'p');
return hasTitle && hasUsers;
})
.map((event: any) => {
// Extract data from tags according to the event format
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];
const dTag = event.tags.find((tag: string[]) => tag[0] === 'd')?.[1];
// Generate following.space URL if d tag exists
const url = dTag ? `https://following.space/d/${dTag}` : undefined;
// Extract user public keys from p tags
const userPubkeys = event.tags
.filter((tag: string[]) => tag[0] === 'p')
.map((tag: string[]) => tag[1]);
const users = userPubkeys.map((pubkey: string) => ({
pubkey,
// Extract nickname from the tag if available (NIP-02)
displayName: event.tags.find((tag: string[]) =>
tag[0] === 'p' &&
tag[1] === pubkey &&
tag[3] === 'nick' &&
tag[2],
)?.[2] || pubkey.substring(0, 8),
}));
return {
id: event.id,
pubkey: event.pubkey,
title,
description,
image,
created_at: event.created_at,
url,
users,
};
});
// Sort by created_at (newest first)
packs.sort((a, b) => b.created_at - a.created_at);
if (packs.length > 0) {
// Take max 4 packs to display as requested
setFollowPacks(packs.slice(0, 4));
}
};
// Wait for all relay subscriptions to complete
await Promise.all(subscriptions);
// Final processing of all events
if (events.length > 0) {
processAndUpdatePacks(events);
}
setIsLoading(false);
// Cleanup connections
activeConnections.current.forEach(socket => {
try {
socket.close();
} catch (e) { /* empty */ }
});
activeConnections.current = [];
} catch (error) {
console.error('Error fetching follow packs:', error);
setIsLoading(false);
}
};
// Fetch data on component mount
fetchFollowPacks();
// Clean up on unmount
return () => {
activeConnections.current.forEach(socket => {
try {
socket.close();
} catch (e) { /* empty */ }
});
};
}, []);
return (
<Stack space={4} className='px-2 sm:px-4'>
<HStack alignItems='center' justifyContent='between'>
<Text size='lg' className='sm:text-xl' weight='bold'>
<FormattedMessage id='follow_packs.title' defaultMessage='Follow Packs' />
</Text>
<IconButton
src={arrowIcon}
theme='transparent'
className={`transition-transform duration-300 ${isOpen ? 'rotate-180' : 'rotate-0'}`}
onClick={handleClick}
aria-label={intl.formatMessage({
id: isOpen ? 'follow_packs.collapse' : 'follow_packs.expand',
defaultMessage: isOpen ? 'Collapse follow packs' : 'Expand follow packs',
})}
/>
</HStack>
<div className={`transition-all duration-500 ease-in-out ${isOpen ? 'max-h-[5000px] opacity-100' : 'hidden max-h-0 opacity-0'}`}>
{(() => {
if (isLoading) {
return (
<div className='flex justify-center py-8'>
<Spinner size={40} />
</div>
);
}
if (followPacks.length > 0) {
return (
<Stack space={4}>
<div className='mx-auto grid w-full grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2'>
{followPacks.map((pack) => (
<div className='w-full max-w-full' key={pack.id}>
<FollowPackCard
pack={pack}
metadataSocket={metadataSocket.current || undefined}
/>
</div>
))}
</div>
<div className='mb-4 mt-2 flex justify-center'>
<a
href='https://following.space/'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1 rounded-full bg-primary-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-700 sm:gap-2 sm:px-6 sm:py-3 sm:text-base'
>
<FormattedMessage id='follow_packs.explore_more' defaultMessage='Explore more Follow Packs' />
<SvgIcon src={arrowIcon} className='size-3 -rotate-90 sm:size-4' />
</a>
</div>
</Stack>
);
}
return (
<div className='flex flex-col items-center justify-center px-4 py-12 text-center'>
<SvgIcon src={groupIcon} className='mb-4 size-12 text-gray-400' />
<Text size='xl' weight='medium' className='mb-2'>
<FormattedMessage id='follow_packs.no_packs' defaultMessage='No Follow Packs Found' />
</Text>
<Text theme='muted'>
<FormattedMessage
id='follow_packs.empty_message'
defaultMessage='Follow Packs will appear here as they become available'
/>
</Text>
<a
href='https://following.space'
target='_blank'
rel='noopener noreferrer'
className='mt-4 text-primary-600 hover:underline'
>
<Text size='sm'>
<FormattedMessage
id='follow_packs.visit'
defaultMessage='Create a Follow Pack at following.space'
/>
</Text>
</a>
</div>
);
})()}
</div>
</Stack>
);
};
export default FollowPacks;

Wyświetl plik

@ -15,13 +15,14 @@ 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 { 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';
import FollowPacks from './components/follow-packs.tsx';
const messages = defineMessages({
heading: { id: 'column.explore', defaultMessage: 'Explore' },
accounts: { id: 'search_results.accounts', defaultMessage: 'Accounts' },
@ -68,7 +69,7 @@ const TrendsTab = () => {
const AccountsTab = () => {
return (
<Stack space={4} className='pt-1'>
<AccountsCarousel />
<FollowPacks />
<Divider />
@ -164,4 +165,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

@ -241,7 +241,7 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
};
return (
<div className='media-modal pointer-events-auto fixed inset-0 z-[9999] flex size-full bg-gray-900/90'>
<div className='pointer-events-auto fixed inset-0 z-[9999] flex size-full bg-gray-900/90'>
<div
className='absolute inset-0'
role='presentation'

Wyświetl plik

@ -1,31 +1,25 @@
import { useEffect } from 'react';
export const useRelays = (): string[] => {
// Default relays if nothing is configured
const defaultRelays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
'wss://nostr.wine',
'wss://relay.nostr.org',
];
// In a real implementation, we'd fetch this from app state/config
// For now we're just returning default relays
return defaultRelays;
};
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 useBunker = () => {
// Placeholder for future Nostr bunker implementation
return {
isConnected: false,
connect: () => Promise.resolve(false),
disconnect: () => {},
};
};
function useBunker() {
const { relay } = useNostr();
const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner();
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;