diff --git a/src/features/explore/components/follow-packs.tsx b/src/features/explore/components/follow-packs.tsx new file mode 100644 index 000000000..e3eb92aa7 --- /dev/null +++ b/src/features/explore/components/follow-packs.tsx @@ -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 ( +
+ {!isImgLoaded && ( +
+ +
+ )} + + {imgError ? ( +
+ +
+ ) : ( + {alt} + )} +
+ ); +}; + +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 ( +
+ {isLoading ? ( + <> +
+ +
+ {user.pubkey.substring(0, 6)} + + ) : ( + <> + + {profileData.displayName || profileData.name || user.pubkey.substring(0, 6)} + + )} +
+ ); +}; + +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 ( + + {children} + + ); + } + return <>{children}; + }; + + return ( + + + + + + +
+ +
+
+ {pack.title} + {pack.description && ( + {pack.description} + )} +
+
+ +
+ + + +
+ {pack.users.slice(0, MAX_DISPLAYED_USERS).map((user) => ( + + ))} + {pack.users.length > MAX_DISPLAYED_USERS && ( + + + + )} +
+
+ +
+ + + + + + +
+
+
+
+
+
+
+ ); +}; + +const FollowPacks: React.FC = () => { + const [followPacks, setFollowPacks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isOpen, setIsOpen] = useState(true); + const activeConnections = useRef([]); + const metadataSocket = useRef(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((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 ( + + + + + + + + +
+ {(() => { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (followPacks.length > 0) { + return ( + +
+ {followPacks.map((pack) => ( +
+ +
+ ))} +
+ + +
+ ); + } + + return ( +
+ + + + + + + + + + + + +
+ ); + })()} +
+
+ ); +}; + +export default FollowPacks; \ No newline at end of file diff --git a/src/features/explore/index.tsx b/src/features/explore/index.tsx index e0c6bac02..3eade2895 100644 --- a/src/features/explore/index.tsx +++ b/src/features/explore/index.tsx @@ -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 ( - + @@ -164,4 +165,4 @@ const ExplorePage = () => { ); }; -export default ExplorePage; +export default ExplorePage; \ No newline at end of file diff --git a/src/features/explore/index.tsx.bak b/src/features/explore/index.tsx.bak new file mode 100644 index 000000000..d8aaf85fb --- /dev/null +++ b/src/features/explore/index.tsx.bak @@ -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 ( + + {pathname === '/explore' && ( + <> + {features.nostr && ( + <> + + + + + + )} + + {tokens.size ? : } + + )} + + + ); +}; + +const TrendsTab = () => { + return ( + + + + ); +}; + +const AccountsTab = () => { + return ( + + + + + + +
+ +
+ + +
+
+ ); +}; + + +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 ; + }; + + return ( + + + + +
+ {renderFilterBar()} +
+ + + + {features.nostr && } + + + +
+ +
+ ); +}; + +export default ExplorePage; diff --git a/src/features/ui/components/modals/media-modal.tsx b/src/features/ui/components/modals/media-modal.tsx index 0654a444b..b28bace2a 100644 --- a/src/features/ui/components/modals/media-modal.tsx +++ b/src/features/ui/components/modals/media-modal.tsx @@ -241,7 +241,7 @@ const MediaModal: React.FC = (props) => { }; return ( -
+
{ + // 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; \ No newline at end of file