From dd9e157e4e2a80f5d885ad58f73aa2c8dba914bf Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Wed, 30 Apr 2025 12:23:43 -0500 Subject: [PATCH] basic follow pacl set up --- .../explore/components/follow-packs.tsx | 254 ++++++++++++++++++ src/features/explore/index.tsx | 6 +- src/features/explore/index.tsx.bak | 167 ++++++++++++ src/hooks/nostr/useBunker.ts | 51 ++-- 4 files changed, 447 insertions(+), 31 deletions(-) create mode 100644 src/features/explore/components/follow-packs.tsx create mode 100644 src/features/explore/index.tsx.bak diff --git a/src/features/explore/components/follow-packs.tsx b/src/features/explore/components/follow-packs.tsx new file mode 100644 index 000000000..6f33d1f18 --- /dev/null +++ b/src/features/explore/components/follow-packs.tsx @@ -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 ( + + + + {pack.image && ( +
+ {pack.title} +
+ )} +
+
+ {pack.title} + {pack.description && ( + {pack.description} + )} +
+
+
+ + + +
+ {pack.users.slice(0, MAX_DISPLAYED_USERS).map((user) => ( +
+ + {user.displayName || user.pubkey.substring(0, 8)} +
+ ))} + {pack.users.length > MAX_DISPLAYED_USERS && ( + + + + )} +
+
+
+ + + + + + +
+
+
+
+ ); +}; + +const FollowPacks: React.FC = () => { + const [followPacks, setFollowPacks] = useState([]); + const [isLoading, setIsLoading] = useState(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 ( + + + + + + + +
+ + + +
+
+
+ ); + } + + if (followPacks.length === 0) { + return ( + + + + + + + +
+ + + +
+
+
+ ); + } + + return ( +
+
+ + + + + + +
+
+ {followPacks.map((pack) => ( + + ))} +
+
+ ); +}; + +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..184a8f6cc 100644 --- a/src/features/explore/index.tsx +++ b/src/features/explore/index.tsx @@ -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 ( - + @@ -164,4 +164,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/hooks/nostr/useBunker.ts b/src/hooks/nostr/useBunker.ts index 6a226ed64..6a86a00b1 100644 --- a/src/hooks/nostr/useBunker.ts +++ b/src/hooks/nostr/useBunker.ts @@ -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; \ No newline at end of file