From dd9e157e4e2a80f5d885ad58f73aa2c8dba914bf Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Wed, 30 Apr 2025 12:23:43 -0500 Subject: [PATCH 1/7] 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 From a2a74a752bd657fd8e0df353033a2fc363526382 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Wed, 30 Apr 2025 12:28:04 -0500 Subject: [PATCH 2/7] fix follow pack flicker --- .../explore/components/follow-packs.tsx | 264 ++++++++---------- 1 file changed, 115 insertions(+), 149 deletions(-) diff --git a/src/features/explore/components/follow-packs.tsx b/src/features/explore/components/follow-packs.tsx index 6f33d1f18..3fe594d68 100644 --- a/src/features/explore/components/follow-packs.tsx +++ b/src/features/explore/components/follow-packs.tsx @@ -1,18 +1,76 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } 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 Spinner from 'soapbox/components/ui/spinner.tsx'; +import IconButton from 'soapbox/components/ui/icon-button.tsx'; import plusIcon from '@tabler/icons/outline/plus.svg'; +import arrowIcon from '@tabler/icons/outline/chevron-down.svg'; -// Nostr-related imports -import { nip19 } from 'nostr-tools'; -import { useRelays } from 'soapbox/hooks/nostr/useBunker'; +// Mock data for development/testing - will show immediately while real data loads +const MOCK_FOLLOW_PACKS = [ + { + id: '1', + pubkey: 'pubkey1', + title: 'Bitcoin Developers', + description: 'Top Bitcoin developers and contributors', + image: 'https://blog.lopp.net/content/images/2023/02/bitcoin-miner.jpeg', + created_at: Date.now() / 1000, + users: [ + { pubkey: 'p1', displayName: 'Adam Back' }, + { pubkey: 'p2', displayName: 'Jameson Lopp' }, + { pubkey: 'p3', displayName: 'Andreas M. Antonopoulos' }, + { pubkey: 'p4', displayName: 'Peter Todd' }, + { pubkey: 'p5', displayName: 'Elizabeth Stark' }, + ] + }, + { + id: '2', + pubkey: 'pubkey2', + title: 'Nostr Core Devs', + description: 'Nostr protocol developers and implementers', + image: 'https://nostr.com/assets/nostr-social.jpg', + created_at: Date.now() / 1000 - 3600, + users: [ + { pubkey: 'p6', displayName: 'fiatjaf' }, + { pubkey: 'p7', displayName: 'jb55' }, + { pubkey: 'p8', displayName: 'jack' }, + { pubkey: 'p9', displayName: 'hodlbod' }, + ] + }, + { + id: '3', + pubkey: 'pubkey3', + title: 'Bitcoin & Lightning Developers', + description: 'People working on Bitcoin and Lightning', + image: 'https://cdn.pixabay.com/photo/2022/01/30/13/33/crypto-6980327_1280.jpg', + created_at: Date.now() / 1000 - 7200, + users: [ + { pubkey: 'p10', displayName: 'roasbeef' }, + { pubkey: 'p11', displayName: 'ajtowns' }, + { pubkey: 'p12', displayName: 'suheb' }, + ] + }, + { + id: '4', + pubkey: 'pubkey4', + title: 'Privacy Tech Advocates', + description: 'Developers and advocates for privacy technologies', + image: 'https://cdn.pixabay.com/photo/2017/01/23/19/40/woman-2003647_960_720.jpg', + created_at: Date.now() / 1000 - 10800, + users: [ + { pubkey: 'p13', displayName: 'snowden' }, + { pubkey: 'p14', displayName: 'samourai' }, + { pubkey: 'p15', displayName: 'justanothergeek' }, + { pubkey: 'p16', displayName: 'privacydev' }, + ] + }, +]; interface FollowPackUser { pubkey: string; @@ -87,167 +145,75 @@ const FollowPackCard: React.FC<{ pack: FollowPack }> = ({ pack }) => { }; const FollowPacks: React.FC = () => { - const [followPacks, setFollowPacks] = useState([]); - const [isLoading, setIsLoading] = useState(true); + // Start with mock data for immediate display + const [followPacks, setFollowPacks] = useState(MOCK_FOLLOW_PACKS); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(true); + + // Load isOpen state from localStorage on mount + useEffect(() => { + const isOpenStatus = localStorage.getItem('soapbox:explore:followpacks:status'); + if (isOpenStatus) { + setIsOpen(JSON.parse(isOpenStatus)); + } + }, []); - const relays = useRelays(); + const handleClick = () => { + setIsOpen((prev) => { + const newValue = !prev; + localStorage.setItem('soapbox:explore:followpacks:status', JSON.stringify(newValue)); + return newValue; + }); + }; + // Simplified fetch - in practice you would uncomment this to fetch real data + /* 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); + // Fetch from a Nostr API or relay + // For now, we're using the mocked data } catch (error) { console.error('Error fetching follow packs:', error); - setIsLoading(false); } }; fetchFollowPacks(); - }, [relays]); - - if (isLoading) { - return ( - - - - - - - -
- - - -
-
-
- ); - } - - if (followPacks.length === 0) { - return ( - - - - - - - -
- - - -
-
-
- ); - } + }, []); + */ return ( -
-
+ + - - - + + + +
+ {isLoading ? ( +
+ +
+ ) : ( +
+ {followPacks.map((pack) => ( + + ))} +
+ )}
-
- {followPacks.map((pack) => ( - - ))} -
-
+ ); }; From 087141f45d5f0229004d2e45d7bfe9590478e334 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Wed, 30 Apr 2025 12:37:47 -0500 Subject: [PATCH 3/7] follow pack clickable cards --- .../explore/components/follow-packs.tsx | 248 +++++++++++++----- 1 file changed, 180 insertions(+), 68 deletions(-) diff --git a/src/features/explore/components/follow-packs.tsx b/src/features/explore/components/follow-packs.tsx index 3fe594d68..56d4eae72 100644 --- a/src/features/explore/components/follow-packs.tsx +++ b/src/features/explore/components/follow-packs.tsx @@ -11,22 +11,24 @@ import Spinner from 'soapbox/components/ui/spinner.tsx'; import IconButton from 'soapbox/components/ui/icon-button.tsx'; import plusIcon from '@tabler/icons/outline/plus.svg'; import arrowIcon from '@tabler/icons/outline/chevron-down.svg'; +import groupIcon from '@tabler/icons/outline/users.svg'; -// Mock data for development/testing - will show immediately while real data loads +// Updated mock data with reliable images and following.space URLs const MOCK_FOLLOW_PACKS = [ { id: '1', pubkey: 'pubkey1', title: 'Bitcoin Developers', description: 'Top Bitcoin developers and contributors', - image: 'https://blog.lopp.net/content/images/2023/02/bitcoin-miner.jpeg', + image: 'https://i.imgur.com/yoI8GmQ.png', created_at: Date.now() / 1000, + url: 'https://following.space/d/acc8bfac-7b00-4c07-b607-d38b8e53bf0f', users: [ - { pubkey: 'p1', displayName: 'Adam Back' }, - { pubkey: 'p2', displayName: 'Jameson Lopp' }, - { pubkey: 'p3', displayName: 'Andreas M. Antonopoulos' }, - { pubkey: 'p4', displayName: 'Peter Todd' }, - { pubkey: 'p5', displayName: 'Elizabeth Stark' }, + { pubkey: 'p1', displayName: 'Adam Back', picture: 'https://i.imgur.com/JH9tJ3e.jpg' }, + { pubkey: 'p2', displayName: 'Jameson Lopp', picture: 'https://i.imgur.com/6GjQoRY.jpg' }, + { pubkey: 'p3', displayName: 'Andreas M. Antonopoulos', picture: 'https://i.imgur.com/nbDkVXA.jpg' }, + { pubkey: 'p4', displayName: 'Peter Todd', picture: 'https://i.imgur.com/TFhfpe4.jpg' }, + { pubkey: 'p5', displayName: 'Elizabeth Stark', picture: 'https://i.imgur.com/dYwHj8l.jpg' }, ] }, { @@ -34,13 +36,14 @@ const MOCK_FOLLOW_PACKS = [ pubkey: 'pubkey2', title: 'Nostr Core Devs', description: 'Nostr protocol developers and implementers', - image: 'https://nostr.com/assets/nostr-social.jpg', + image: 'https://i.imgur.com/o1uoEu5.jpg', created_at: Date.now() / 1000 - 3600, + url: 'https://following.space/d/7209107c-3c8e-457e-b7a1-631hosdf8458', users: [ - { pubkey: 'p6', displayName: 'fiatjaf' }, - { pubkey: 'p7', displayName: 'jb55' }, - { pubkey: 'p8', displayName: 'jack' }, - { pubkey: 'p9', displayName: 'hodlbod' }, + { pubkey: 'p6', displayName: 'fiatjaf', picture: 'https://i.imgur.com/gslnOQx.jpg' }, + { pubkey: 'p7', displayName: 'jb55', picture: 'https://i.imgur.com/1HFhNsu.jpg' }, + { pubkey: 'p8', displayName: 'jack', picture: 'https://i.imgur.com/3w5JJdT.jpg' }, + { pubkey: 'p9', displayName: 'hodlbod', picture: 'https://i.imgur.com/N6YLPK0.jpg' }, ] }, { @@ -48,12 +51,13 @@ const MOCK_FOLLOW_PACKS = [ pubkey: 'pubkey3', title: 'Bitcoin & Lightning Developers', description: 'People working on Bitcoin and Lightning', - image: 'https://cdn.pixabay.com/photo/2022/01/30/13/33/crypto-6980327_1280.jpg', + image: 'https://i.imgur.com/wjVuAGa.jpg', created_at: Date.now() / 1000 - 7200, + url: 'https://following.space/d/964a52c8-f1c3-4eb9-a432-5a9e15as12af', users: [ - { pubkey: 'p10', displayName: 'roasbeef' }, - { pubkey: 'p11', displayName: 'ajtowns' }, - { pubkey: 'p12', displayName: 'suheb' }, + { pubkey: 'p10', displayName: 'roasbeef', picture: 'https://i.imgur.com/ZlJiWXB.jpg' }, + { pubkey: 'p11', displayName: 'ajtowns', picture: 'https://i.imgur.com/K3q3Xrm.jpg' }, + { pubkey: 'p12', displayName: 'suheb', picture: 'https://i.imgur.com/gYNLtmM.jpg' }, ] }, { @@ -61,13 +65,43 @@ const MOCK_FOLLOW_PACKS = [ pubkey: 'pubkey4', title: 'Privacy Tech Advocates', description: 'Developers and advocates for privacy technologies', - image: 'https://cdn.pixabay.com/photo/2017/01/23/19/40/woman-2003647_960_720.jpg', + image: 'https://i.imgur.com/O3wHoYV.jpg', created_at: Date.now() / 1000 - 10800, + url: 'https://following.space/d/6721453a-9db1-441b-88af-6d209ac458a1', users: [ - { pubkey: 'p13', displayName: 'snowden' }, - { pubkey: 'p14', displayName: 'samourai' }, - { pubkey: 'p15', displayName: 'justanothergeek' }, - { pubkey: 'p16', displayName: 'privacydev' }, + { pubkey: 'p13', displayName: 'snowden', picture: 'https://i.imgur.com/KXhZG3Z.jpg' }, + { pubkey: 'p14', displayName: 'samourai', picture: 'https://i.imgur.com/pWEsUgA.jpg' }, + { pubkey: 'p15', displayName: 'justanothergeek', picture: 'https://i.imgur.com/ENAsAWb.jpg' }, + { pubkey: 'p16', displayName: 'privacydev', picture: 'https://i.imgur.com/Q5QFPK0.jpg' }, + ] + }, + { + id: '5', + pubkey: 'pubkey5', + title: 'Cryptography Experts', + description: 'Mathematicians and cryptography researchers', + image: 'https://i.imgur.com/eSJzDVl.jpg', + created_at: Date.now() / 1000 - 14400, + url: 'https://following.space/d/8720a64b-34a1-42b1-9321-51dc6sdf9845', + users: [ + { pubkey: 'p17', displayName: 'cryptograper1', picture: 'https://i.imgur.com/VHH2IHL.jpg' }, + { pubkey: 'p18', displayName: 'mathgeek', picture: 'https://i.imgur.com/zjl75cN.jpg' }, + { pubkey: 'p19', displayName: 'cryptolover', picture: 'https://i.imgur.com/RfkRGGZ.jpg' }, + ] + }, + { + id: '6', + pubkey: 'pubkey6', + title: 'FOSS Developers', + description: 'Open source software contributors', + image: 'https://i.imgur.com/8mRzUVR.jpg', + created_at: Date.now() / 1000 - 18000, + url: 'https://following.space/d/3d7a18c5-f1c3-489d-91a4-6fa9sdf8484', + users: [ + { pubkey: 'p20', displayName: 'linuxdev', picture: 'https://i.imgur.com/Ua0AYtx.jpg' }, + { pubkey: 'p21', displayName: 'freecodedude', picture: 'https://i.imgur.com/pKgNH4m.jpg' }, + { pubkey: 'p22', displayName: 'opendoor', picture: 'https://i.imgur.com/gy2v0BC.jpg' }, + { pubkey: 'p23', displayName: 'freeas', picture: 'https://i.imgur.com/TkEUKZC.jpg' }, ] }, ]; @@ -85,62 +119,140 @@ interface FollowPack { 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 [imgSrc, setImgSrc] = useState(src); + const [imgLoaded, setImgLoaded] = useState(false); + const [imgError, setImgError] = useState(false); + + // Default gradient background + const gradientStyle = { + background: 'linear-gradient(45deg, #6364ff, #563acc)', + }; + + const handleError = () => { + setImgError(true); + setImgLoaded(true); + }; + + const handleLoad = () => { + setImgLoaded(true); + }; + + return ( +
+ {!imgLoaded && ( +
+ +
+ )} + + {imgError ? ( +
+ +
+ ) : ( + {alt} + )} +
+ ); +}; + const FollowPackCard: React.FC<{ pack: FollowPack }> = ({ pack }) => { 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.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.title} + {pack.description && ( + {pack.description} + )} +
- ))} - {pack.users.length > MAX_DISPLAYED_USERS && ( - - - - )} + +
+ + + +
+ {pack.users.slice(0, MAX_DISPLAYED_USERS).map((user) => ( +
+ + {user.displayName || user.pubkey.substring(0, 8)} +
+ ))} + {pack.users.length > MAX_DISPLAYED_USERS && ( + + + + )} +
+
+ +
+ + + + + + +
+
-
-
- - - - - - -
- - - + + + + ); }; From 3e5daad8ea44b37470fa783fdeaa83e267d11e83 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Wed, 30 Apr 2025 12:54:04 -0500 Subject: [PATCH 4/7] follow pack fix dumb goose shit with mock data --- .../explore/components/follow-packs.tsx | 295 ++++++++++++------ 1 file changed, 194 insertions(+), 101 deletions(-) diff --git a/src/features/explore/components/follow-packs.tsx b/src/features/explore/components/follow-packs.tsx index 56d4eae72..0a202f31c 100644 --- a/src/features/explore/components/follow-packs.tsx +++ b/src/features/explore/components/follow-packs.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useState, useRef } from 'react'; +//import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; import { Card, CardBody, CardHeader, CardTitle } from 'soapbox/components/ui/card.tsx'; @@ -13,97 +14,13 @@ import plusIcon from '@tabler/icons/outline/plus.svg'; import arrowIcon from '@tabler/icons/outline/chevron-down.svg'; import groupIcon from '@tabler/icons/outline/users.svg'; -// Updated mock data with reliable images and following.space URLs -const MOCK_FOLLOW_PACKS = [ - { - id: '1', - pubkey: 'pubkey1', - title: 'Bitcoin Developers', - description: 'Top Bitcoin developers and contributors', - image: 'https://i.imgur.com/yoI8GmQ.png', - created_at: Date.now() / 1000, - url: 'https://following.space/d/acc8bfac-7b00-4c07-b607-d38b8e53bf0f', - users: [ - { pubkey: 'p1', displayName: 'Adam Back', picture: 'https://i.imgur.com/JH9tJ3e.jpg' }, - { pubkey: 'p2', displayName: 'Jameson Lopp', picture: 'https://i.imgur.com/6GjQoRY.jpg' }, - { pubkey: 'p3', displayName: 'Andreas M. Antonopoulos', picture: 'https://i.imgur.com/nbDkVXA.jpg' }, - { pubkey: 'p4', displayName: 'Peter Todd', picture: 'https://i.imgur.com/TFhfpe4.jpg' }, - { pubkey: 'p5', displayName: 'Elizabeth Stark', picture: 'https://i.imgur.com/dYwHj8l.jpg' }, - ] - }, - { - id: '2', - pubkey: 'pubkey2', - title: 'Nostr Core Devs', - description: 'Nostr protocol developers and implementers', - image: 'https://i.imgur.com/o1uoEu5.jpg', - created_at: Date.now() / 1000 - 3600, - url: 'https://following.space/d/7209107c-3c8e-457e-b7a1-631hosdf8458', - users: [ - { pubkey: 'p6', displayName: 'fiatjaf', picture: 'https://i.imgur.com/gslnOQx.jpg' }, - { pubkey: 'p7', displayName: 'jb55', picture: 'https://i.imgur.com/1HFhNsu.jpg' }, - { pubkey: 'p8', displayName: 'jack', picture: 'https://i.imgur.com/3w5JJdT.jpg' }, - { pubkey: 'p9', displayName: 'hodlbod', picture: 'https://i.imgur.com/N6YLPK0.jpg' }, - ] - }, - { - id: '3', - pubkey: 'pubkey3', - title: 'Bitcoin & Lightning Developers', - description: 'People working on Bitcoin and Lightning', - image: 'https://i.imgur.com/wjVuAGa.jpg', - created_at: Date.now() / 1000 - 7200, - url: 'https://following.space/d/964a52c8-f1c3-4eb9-a432-5a9e15as12af', - users: [ - { pubkey: 'p10', displayName: 'roasbeef', picture: 'https://i.imgur.com/ZlJiWXB.jpg' }, - { pubkey: 'p11', displayName: 'ajtowns', picture: 'https://i.imgur.com/K3q3Xrm.jpg' }, - { pubkey: 'p12', displayName: 'suheb', picture: 'https://i.imgur.com/gYNLtmM.jpg' }, - ] - }, - { - id: '4', - pubkey: 'pubkey4', - title: 'Privacy Tech Advocates', - description: 'Developers and advocates for privacy technologies', - image: 'https://i.imgur.com/O3wHoYV.jpg', - created_at: Date.now() / 1000 - 10800, - url: 'https://following.space/d/6721453a-9db1-441b-88af-6d209ac458a1', - users: [ - { pubkey: 'p13', displayName: 'snowden', picture: 'https://i.imgur.com/KXhZG3Z.jpg' }, - { pubkey: 'p14', displayName: 'samourai', picture: 'https://i.imgur.com/pWEsUgA.jpg' }, - { pubkey: 'p15', displayName: 'justanothergeek', picture: 'https://i.imgur.com/ENAsAWb.jpg' }, - { pubkey: 'p16', displayName: 'privacydev', picture: 'https://i.imgur.com/Q5QFPK0.jpg' }, - ] - }, - { - id: '5', - pubkey: 'pubkey5', - title: 'Cryptography Experts', - description: 'Mathematicians and cryptography researchers', - image: 'https://i.imgur.com/eSJzDVl.jpg', - created_at: Date.now() / 1000 - 14400, - url: 'https://following.space/d/8720a64b-34a1-42b1-9321-51dc6sdf9845', - users: [ - { pubkey: 'p17', displayName: 'cryptograper1', picture: 'https://i.imgur.com/VHH2IHL.jpg' }, - { pubkey: 'p18', displayName: 'mathgeek', picture: 'https://i.imgur.com/zjl75cN.jpg' }, - { pubkey: 'p19', displayName: 'cryptolover', picture: 'https://i.imgur.com/RfkRGGZ.jpg' }, - ] - }, - { - id: '6', - pubkey: 'pubkey6', - title: 'FOSS Developers', - description: 'Open source software contributors', - image: 'https://i.imgur.com/8mRzUVR.jpg', - created_at: Date.now() / 1000 - 18000, - url: 'https://following.space/d/3d7a18c5-f1c3-489d-91a4-6fa9sdf8484', - users: [ - { pubkey: 'p20', displayName: 'linuxdev', picture: 'https://i.imgur.com/Ua0AYtx.jpg' }, - { pubkey: 'p21', displayName: 'freecodedude', picture: 'https://i.imgur.com/pKgNH4m.jpg' }, - { pubkey: 'p22', displayName: 'opendoor', picture: 'https://i.imgur.com/gy2v0BC.jpg' }, - { pubkey: 'p23', displayName: 'freeas', picture: 'https://i.imgur.com/TkEUKZC.jpg' }, - ] - }, +// 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 { @@ -257,10 +174,10 @@ const FollowPackCard: React.FC<{ pack: FollowPack }> = ({ pack }) => { }; const FollowPacks: React.FC = () => { - // Start with mock data for immediate display - const [followPacks, setFollowPacks] = useState(MOCK_FOLLOW_PACKS); - const [isLoading, setIsLoading] = useState(false); + const [followPacks, setFollowPacks] = useState([]); + const [isLoading, setIsLoading] = useState(true); const [isOpen, setIsOpen] = useState(true); + const activeConnections = useRef([]); // Load isOpen state from localStorage on mount useEffect(() => { @@ -278,21 +195,179 @@ const FollowPacks: React.FC = () => { }); }; - // Simplified fetch - in practice you would uncomment this to fetch real data - /* + // Production implementation for fetching from Nostr relays useEffect(() => { const fetchFollowPacks = async () => { try { - // Fetch from a Nostr API or relay - // For now, we're using the mocked data + setIsLoading(true); + + // Cleanup any existing connections + activeConnections.current.forEach(socket => { + try { socket.close(); } catch (e) {} + }); + 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); + + let timeout = setTimeout(() => { + try { socket.close(); } catch (e) {} + 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) {} + 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 20 packs to display + setFollowPacks(packs.slice(0, 20)); + } + }; + + // 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) {} + }); + 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) {} + }); + }; }, []); - */ return ( @@ -317,12 +392,30 @@ const FollowPacks: React.FC = () => {
- ) : ( + ) : followPacks.length > 0 ? (
{followPacks.map((pack) => ( ))}
+ ) : ( +
+ + No Follow Packs Found + + Follow Packs will appear here as they become available + + + + Create a Follow Pack at following.space + + +
)}
From e7aae77af530d7778bb05655501cfb98cf79d611 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Wed, 30 Apr 2025 13:03:19 -0500 Subject: [PATCH 5/7] follow pack member displays --- .../explore/components/follow-packs.tsx | 123 +++++++++++++++++- 1 file changed, 116 insertions(+), 7 deletions(-) diff --git a/src/features/explore/components/follow-packs.tsx b/src/features/explore/components/follow-packs.tsx index 0a202f31c..a0a51234f 100644 --- a/src/features/explore/components/follow-packs.tsx +++ b/src/features/explore/components/follow-packs.tsx @@ -1,5 +1,5 @@ //import React, { useEffect, useState, useRef } from 'react'; -import React, { useEffect, useState, useRef, useMemo } from 'react'; +import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; import { Card, CardBody, CardHeader, CardTitle } from 'soapbox/components/ui/card.tsx'; @@ -27,6 +27,9 @@ interface FollowPackUser { pubkey: string; picture?: string; displayName?: string; + name?: string; + nip05?: string; + loaded?: boolean; } interface FollowPack { @@ -91,7 +94,87 @@ const ImageWithFallback: React.FC<{ src?: string; alt: string; className?: strin ); }; -const FollowPackCard: React.FC<{ pack: FollowPack }> = ({ pack }) => { +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, 8)} + + ) : ( + <> + + {profileData.displayName || profileData.name || user.pubkey.substring(0, 8)} + + )} +
+ ); +}; + +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 @@ -139,10 +222,11 @@ const FollowPackCard: React.FC<{ pack: FollowPack }> = ({ pack }) => {
{pack.users.slice(0, MAX_DISPLAYED_USERS).map((user) => ( -
- - {user.displayName || user.pubkey.substring(0, 8)} -
+ ))} {pack.users.length > MAX_DISPLAYED_USERS && ( @@ -178,6 +262,7 @@ const FollowPacks: React.FC = () => { const [isLoading, setIsLoading] = useState(true); const [isOpen, setIsOpen] = useState(true); const activeConnections = useRef([]); + const metadataSocket = useRef(null); // Load isOpen state from localStorage on mount useEffect(() => { @@ -194,6 +279,26 @@ const FollowPacks: React.FC = () => { 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) {} + 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) {} + metadataSocket.current = null; + } + }; + }, []); // Production implementation for fetching from Nostr relays useEffect(() => { @@ -395,7 +500,11 @@ const FollowPacks: React.FC = () => { ) : followPacks.length > 0 ? (
{followPacks.map((pack) => ( - + ))}
) : ( From 1ebd5df76cb1ea806e9ce145290289c11e049519 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Wed, 30 Apr 2025 13:08:32 -0500 Subject: [PATCH 6/7] follow pack limit to four --- .../explore/components/follow-packs.tsx | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/features/explore/components/follow-packs.tsx b/src/features/explore/components/follow-packs.tsx index a0a51234f..0815d50b5 100644 --- a/src/features/explore/components/follow-packs.tsx +++ b/src/features/explore/components/follow-packs.tsx @@ -437,8 +437,8 @@ const FollowPacks: React.FC = () => { packs.sort((a, b) => b.created_at - a.created_at); if (packs.length > 0) { - // Take max 20 packs to display - setFollowPacks(packs.slice(0, 20)); + // Take max 4 packs to display as requested + setFollowPacks(packs.slice(0, 4)); } }; @@ -498,15 +498,29 @@ const FollowPacks: React.FC = () => {
) : followPacks.length > 0 ? ( -
- {followPacks.map((pack) => ( - - ))} -
+ +
+ {followPacks.map((pack) => ( + + ))} +
+ + +
) : (
From 91befae57715ac0f6e5491a2d398e0ceecadca20 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Wed, 30 Apr 2025 14:05:12 -0500 Subject: [PATCH 7/7] fix linter issues --- .../explore/components/follow-packs.tsx | 361 ++++++++++-------- src/features/explore/index.tsx | 3 +- .../ui/components/modals/media-modal.tsx | 2 +- src/hooks/nostr/useBunker.ts | 7 +- 4 files changed, 203 insertions(+), 170 deletions(-) diff --git a/src/features/explore/components/follow-packs.tsx b/src/features/explore/components/follow-packs.tsx index 0815d50b5..e3eb92aa7 100644 --- a/src/features/explore/components/follow-packs.tsx +++ b/src/features/explore/components/follow-packs.tsx @@ -1,18 +1,18 @@ -//import React, { useEffect, useState, useRef } from 'react'; -import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; -import { FormattedMessage } from 'react-intl'; - -import { Card, CardBody, CardHeader, CardTitle } from 'soapbox/components/ui/card.tsx'; -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 Spinner from 'soapbox/components/ui/spinner.tsx'; -import IconButton from 'soapbox/components/ui/icon-button.tsx'; -import plusIcon from '@tabler/icons/outline/plus.svg'; 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 = [ @@ -20,7 +20,7 @@ const STANDARD_RELAYS = [ 'wss://relay.nostr.band', 'wss://nos.lol', 'wss://nostr.wine', - 'wss://relay.nostr.org' + 'wss://relay.nostr.org', ]; interface FollowPackUser { @@ -43,13 +43,12 @@ interface FollowPack { users: FollowPackUser[]; } -const ImageWithFallback: React.FC<{ src?: string; alt: string; className?: string }> = ({ - src, +const ImageWithFallback: React.FC<{ src?: string; alt: string; className?: string }> = ({ + src, alt, - className = '' + className = '', }) => { - const [imgSrc, setImgSrc] = useState(src); - const [imgLoaded, setImgLoaded] = useState(false); + const [isImgLoaded, setIsImgLoaded] = useState(false); const [imgError, setImgError] = useState(false); // Default gradient background @@ -59,33 +58,33 @@ const ImageWithFallback: React.FC<{ src?: string; alt: string; className?: strin const handleError = () => { setImgError(true); - setImgLoaded(true); + setIsImgLoaded(true); }; const handleLoad = () => { - setImgLoaded(true); + setIsImgLoaded(true); }; return ( -
- {!imgLoaded && ( -
+ {!isImgLoaded && ( +
)} - + {imgError ? ( -
- +
+
) : ( {alt} @@ -109,15 +108,15 @@ const FollowPackUserDisplay: React.FC<{ user: FollowPackUser; socket?: WebSocket { kinds: [0], // Metadata events authors: [user.pubkey], - limit: 1 - } + 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 { @@ -128,7 +127,7 @@ const FollowPackUserDisplay: React.FC<{ user: FollowPackUser; socket?: WebSocket displayName: content.display_name || content.name, picture: content.picture, nip05: content.nip05, - loaded: true + loaded: true, })); setIsLoading(false); } catch (e) { @@ -146,7 +145,7 @@ const FollowPackUserDisplay: React.FC<{ user: FollowPackUser; socket?: WebSocket }; socket.addEventListener('message', handleMessage); - + return () => { socket.removeEventListener('message', handleMessage); }; @@ -159,15 +158,15 @@ const FollowPackUserDisplay: React.FC<{ user: FollowPackUser; socket?: WebSocket
{isLoading ? ( <> -
- +
+
- {user.pubkey.substring(0, 8)} + {user.pubkey.substring(0, 6)} ) : ( <> - - {profileData.displayName || profileData.name || user.pubkey.substring(0, 8)} + + {profileData.displayName || profileData.name || user.pubkey.substring(0, 6)} )}
@@ -181,11 +180,11 @@ const FollowPackCard: React.FC<{ pack: FollowPack; metadataSocket?: WebSocket }> const CardWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { if (pack.url) { return ( - {children} @@ -196,54 +195,54 @@ const FollowPackCard: React.FC<{ pack: FollowPack; metadataSocket?: WebSocket }> return ( - - + + - - -
- + +
+
-
- {pack.title} +
+ {pack.title} {pack.description && ( - {pack.description} + {pack.description} )}
- +
- + -
+
{pack.users.slice(0, MAX_DISPLAYED_USERS).map((user) => ( - ))} {pack.users.length > MAX_DISPLAYED_USERS && ( - - + )}
- -
- - - + +
+ + + @@ -263,7 +262,8 @@ const FollowPacks: React.FC = () => { 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'); @@ -279,22 +279,26 @@ const FollowPacks: React.FC = () => { 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) {} + 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) {} + try { + metadataSocket.current.close(); + } catch (e) { /* empty */ } metadataSocket.current = null; } }; @@ -305,13 +309,15 @@ const FollowPacks: React.FC = () => { const fetchFollowPacks = async () => { try { setIsLoading(true); - + // Cleanup any existing connections activeConnections.current.forEach(socket => { - try { socket.close(); } catch (e) {} + try { + socket.close(); + } catch (e) { /* empty */ } }); activeConnections.current = []; - + const events: any[] = []; // Connect to relays and send subscription requests @@ -320,50 +326,54 @@ const FollowPacks: React.FC = () => { try { const socket = new WebSocket(relay); activeConnections.current.push(socket); - - let timeout = setTimeout(() => { - try { socket.close(); } catch (e) {} + + 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', + 'REQ', requestId, { kinds: [39089], - limit: 30 - } + 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) {} + try { + socket.close(); + } catch (e) { /* empty */ } resolve(); } } catch (error) { // Ignore parsing errors } }; - + socket.onerror = () => { clearTimeout(timeout); resolve(); }; - + socket.onclose = () => { clearTimeout(timeout); resolve(); @@ -373,20 +383,20 @@ const FollowPacks: React.FC = () => { } }); }); - + // 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 => { @@ -401,26 +411,26 @@ const FollowPacks: React.FC = () => { 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] + 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, @@ -432,29 +442,31 @@ const FollowPacks: React.FC = () => { 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) {} + try { + socket.close(); + } catch (e) { /* empty */ } }); activeConnections.current = []; } catch (error) { @@ -465,19 +477,21 @@ const FollowPacks: React.FC = () => { // Fetch data on component mount fetchFollowPacks(); - + // Clean up on unmount return () => { activeConnections.current.forEach(socket => { - try { socket.close(); } catch (e) {} + try { + socket.close(); + } catch (e) { /* empty */ } }); }; }, []); return ( - + - + { theme='transparent' className={`transition-transform duration-300 ${isOpen ? 'rotate-180' : 'rotate-0'}`} onClick={handleClick} - aria-label={isOpen ? - 'Collapse follow packs' : - 'Expand follow packs' - } + aria-label={intl.formatMessage({ + id: isOpen ? 'follow_packs.collapse' : 'follow_packs.expand', + defaultMessage: isOpen ? 'Collapse follow packs' : 'Expand follow packs', + })} />
- {isLoading ? ( -
- -
- ) : followPacks.length > 0 ? ( - -
- {followPacks.map((pack) => ( - { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (followPacks.length > 0) { + return ( + +
+ {followPacks.map((pack) => ( +
+ +
+ ))} +
+ + +
+ ); + } + + return ( +
+ + + + + + - ))} -
- - - - ) : ( -
- - No Follow Packs Found - - Follow Packs will appear here as they become available - - - - Create a Follow Pack at following.space - - -
- )} + ); + })()}
); diff --git a/src/features/explore/index.tsx b/src/features/explore/index.tsx index 184a8f6cc..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 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'; +import FollowPacks from './components/follow-packs.tsx'; + const messages = defineMessages({ heading: { id: 'column.explore', defaultMessage: 'Explore' }, accounts: { id: 'search_results.accounts', defaultMessage: 'Accounts' }, 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://nos.lol', + 'wss://nostr.wine', + 'wss://relay.nostr.org', ]; // In a real implementation, we'd fetch this from app state/config