From 7e332c636347b535d1df8728e1ea93cd9a2c2bad Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 4 Oct 2025 21:48:55 +0200 Subject: [PATCH] first prototype of duscussions --- package-lock.json | 11 + package.json | 1 + src/PageManager.tsx | 16 +- .../BottomNavigationBar/DiscussionsButton.tsx | 12 +- src/constants.ts | 4 +- src/lib/utils.ts | 9 + .../primary/DiscussionsPage/ThreadCard.tsx | 104 +++++++++ .../primary/DiscussionsPage/TopicFilter.tsx | 50 +++++ src/pages/primary/DiscussionsPage/index.tsx | 210 ++++++++++++++++++ 9 files changed, 408 insertions(+), 9 deletions(-) create mode 100644 src/pages/primary/DiscussionsPage/ThreadCard.tsx create mode 100644 src/pages/primary/DiscussionsPage/TopicFilter.tsx create mode 100644 src/pages/primary/DiscussionsPage/index.tsx diff --git a/package-lock.json b/package-lock.json index 6e35c40..6a9cda9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "dataloader": "^2.2.3", + "date-fns": "^4.1.0", "dayjs": "^1.11.13", "embla-carousel-react": "^8.6.0", "embla-carousel-wheel-gestures": "^8.1.0", @@ -6847,6 +6848,16 @@ "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", diff --git a/package.json b/package.json index b2e3dd0..091e25c 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "dataloader": "^2.2.3", + "date-fns": "^4.1.0", "dayjs": "^1.11.13", "embla-carousel-react": "^8.6.0", "embla-carousel-wheel-gestures": "^8.1.0", diff --git a/src/PageManager.tsx b/src/PageManager.tsx index b9de6fc..966cd13 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -24,6 +24,7 @@ import NotificationListPage from './pages/primary/NotificationListPage' import ProfilePage from './pages/primary/ProfilePage' import RelayPage from './pages/primary/RelayPage' import SearchPage from './pages/primary/SearchPage' +import DiscussionsPage from './pages/primary/DiscussionsPage' import { NotificationProvider } from './providers/NotificationProvider' import { useScreenSize } from './providers/ScreenSizeProvider' import { routes } from './routes' @@ -58,7 +59,8 @@ const PRIMARY_PAGE_REF_MAP = { me: createRef(), profile: createRef(), relay: createRef(), - search: createRef() + search: createRef(), + discussions: createRef() } const PRIMARY_PAGE_MAP = { @@ -68,7 +70,8 @@ const PRIMARY_PAGE_MAP = { me: , profile: , relay: , - search: + search: , + discussions: } const PrimaryPageContext = createContext(undefined) @@ -143,11 +146,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } else { const searchParams = new URLSearchParams(window.location.search) const r = searchParams.get('r') + const page = searchParams.get('page') + if (r) { const url = normalizeUrl(r) if (url) { navigatePrimaryPage('relay', { url }) } + } else if (page && page in PRIMARY_PAGE_MAP) { + navigatePrimaryPage(page as TPrimaryPageName) } } @@ -237,6 +244,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { return prev }) setCurrentPrimaryPage(page) + + // Update URL for primary pages (except home) + const newUrl = page === 'home' ? '/' : `/?page=${page}` + window.history.pushState(null, '', newUrl) + if (needScrollToTop) { PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth') } diff --git a/src/components/BottomNavigationBar/DiscussionsButton.tsx b/src/components/BottomNavigationBar/DiscussionsButton.tsx index de9d6b5..5bb3ae9 100644 --- a/src/components/BottomNavigationBar/DiscussionsButton.tsx +++ b/src/components/BottomNavigationBar/DiscussionsButton.tsx @@ -1,15 +1,15 @@ +import { usePrimaryPage } from '@/PageManager' import { MessageCircle } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' export default function DiscussionsButton() { - // TODO: Implement discussions navigation when the component is built - const handleClick = () => { - // Placeholder for future discussions functionality - console.log('Discussions button clicked - component to be implemented') - } + const { navigate, current, display } = usePrimaryPage() return ( - + navigate('discussions')} + > ) diff --git a/src/constants.ts b/src/constants.ts index a4a14e7..647bc8f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,7 +3,9 @@ import { kinds } from 'nostr-tools' export const JUMBLE_API_BASE_URL = 'https://api.jumble.social' export const DEFAULT_FAVORITE_RELAYS = [ - 'wss://theforest.nostr1.com/','wss://orly-relay.imwald.eu' + 'wss://theforest.nostr1.com/', + 'wss://orly-relay.imwald.eu', + 'wss://nostr.land' ] export const RECOMMENDED_RELAYS = DEFAULT_FAVORITE_RELAYS.concat([]) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4b0ecd7..fecc7a3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -17,6 +17,15 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } +export function truncateText(text: string, maxWords: number): string { + if (!text) return '' + + const words = text.trim().split(/\s+/) + if (words.length <= maxWords) return text + + return words.slice(0, maxWords).join(' ') + '...' +} + export function isSafari() { if (typeof window === 'undefined' || !window.navigator) return false const ua = window.navigator.userAgent diff --git a/src/pages/primary/DiscussionsPage/ThreadCard.tsx b/src/pages/primary/DiscussionsPage/ThreadCard.tsx new file mode 100644 index 0000000..53d079b --- /dev/null +++ b/src/pages/primary/DiscussionsPage/ThreadCard.tsx @@ -0,0 +1,104 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { MessageCircle, User, Clock, Hash } from 'lucide-react' +import { NostrEvent } from 'nostr-tools' +import { formatDistanceToNow } from 'date-fns' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { truncateText } from '@/lib/utils' + +interface ThreadCardProps { + thread: NostrEvent + onThreadClick: () => void + className?: string +} + +export default function ThreadCard({ thread, onThreadClick, className }: ThreadCardProps) { + const { t } = useTranslation() + + // Extract title from tags + const titleTag = thread.tags.find(tag => tag[0] === 'title' && tag[1]) + const title = titleTag?.[1] || t('Untitled') + + // Extract topic from tags + const topicTag = thread.tags.find(tag => tag[0] === 't' && tag[1]) + const topic = topicTag?.[1] || 'general' + + // Get first 250 words of content + const contentPreview = truncateText(thread.content, 250) + + // Format creation time + const createdAt = new Date(thread.created_at * 1000) + const timeAgo = formatDistanceToNow(createdAt, { addSuffix: true }) + + // Get topic display info + const getTopicInfo = (topicId: string) => { + const topicMap: Record = { + general: { label: 'General', color: 'bg-gray-100 text-gray-800' }, + meetups: { label: 'Meetups', color: 'bg-blue-100 text-blue-800' }, + devs: { label: 'Developers', color: 'bg-green-100 text-green-800' }, + finance: { label: 'Finance', color: 'bg-yellow-100 text-yellow-800' }, + politics: { label: 'Politics', color: 'bg-red-100 text-red-800' }, + literature: { label: 'Literature', color: 'bg-purple-100 text-purple-800' }, + philosophy: { label: 'Philosophy', color: 'bg-indigo-100 text-indigo-800' }, + tech: { label: 'Technology', color: 'bg-cyan-100 text-cyan-800' }, + sports: { label: 'Sports', color: 'bg-orange-100 text-orange-800' } + } + return topicMap[topicId] || { label: topicId, color: 'bg-gray-100 text-gray-800' } + } + + const topicInfo = getTopicInfo(topic) + + return ( + + +
+
+

+ {title} +

+
+ + + {topicInfo.label} + +
+ + {timeAgo} +
+
+
+
+ + 0 {/* TODO: Add reply count */} +
+
+
+ + +
+ {contentPreview} +
+ +
+
+ + + {thread.pubkey.slice(0, 8)}...{thread.pubkey.slice(-8)} + +
+ +
+
+
+ ) +} diff --git a/src/pages/primary/DiscussionsPage/TopicFilter.tsx b/src/pages/primary/DiscussionsPage/TopicFilter.tsx new file mode 100644 index 0000000..842aeea --- /dev/null +++ b/src/pages/primary/DiscussionsPage/TopicFilter.tsx @@ -0,0 +1,50 @@ +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Hash, ChevronDown } from 'lucide-react' + +interface Topic { + id: string + label: string + icon: any +} + +interface TopicFilterProps { + topics: Topic[] + selectedTopic: string + onTopicChange: (topicId: string) => void +} + +export default function TopicFilter({ topics, selectedTopic, onTopicChange }: TopicFilterProps) { + const selectedTopicInfo = topics.find(topic => topic.id === selectedTopic) || topics[0] + + return ( + + + + + + {topics.map(topic => ( + onTopicChange(topic.id)} + className="flex items-center gap-2" + > + + {topic.label} + {topic.id === selectedTopic && ( + + )} + + ))} + + + ) +} diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx new file mode 100644 index 0000000..6c6d871 --- /dev/null +++ b/src/pages/primary/DiscussionsPage/index.tsx @@ -0,0 +1,210 @@ +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { DEFAULT_FAVORITE_RELAYS } from '@/constants' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' +import { forwardRef, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' +import { MessageSquarePlus, Hash } from 'lucide-react' +import ThreadCard from '@/pages/primary/DiscussionsPage/ThreadCard' +import TopicFilter from '@/pages/primary/DiscussionsPage/TopicFilter' +import { NostrEvent } from 'nostr-tools' +import client from '@/services/client.service' + +const DISCUSSION_TOPICS = [ + { id: 'general', label: 'General', icon: Hash }, + { id: 'meetups', label: 'Meetups', icon: Hash }, + { id: 'devs', label: 'Developers', icon: Hash }, + { id: 'finance', label: 'Finance & Economics', icon: Hash }, + { id: 'politics', label: 'Politics & Breaking News', icon: Hash }, + { id: 'literature', label: 'Literature & Art', icon: Hash }, + { id: 'philosophy', label: 'Philosophy & Theology', icon: Hash }, + { id: 'tech', label: 'Technology & Science', icon: Hash }, + { id: 'sports', label: 'Sports', icon: Hash } +] + +const DiscussionsPage = forwardRef((_, ref) => { + const { t } = useTranslation() + const { favoriteRelays } = useFavoriteRelays() + const { pubkey } = useNostr() + const [selectedTopic, setSelectedTopic] = useState('general') + const [selectedRelay, setSelectedRelay] = useState(null) + const [threads, setThreads] = useState([]) + const [loading, setLoading] = useState(false) + const [showCreateThread, setShowCreateThread] = useState(false) + + // Use DEFAULT_FAVORITE_RELAYS for logged-out users, or user's favorite relays for logged-in users + const availableRelays = pubkey && favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS + + useEffect(() => { + fetchThreads() + }, [selectedTopic, selectedRelay]) + + const fetchThreads = async () => { + setLoading(true) + try { + // Filter by relay if selected, otherwise use all available relays + const relayUrls = selectedRelay ? [selectedRelay] : availableRelays + + const events = await client.fetchEvents(relayUrls, [ + { + kinds: [11], // Thread events + '#t': [selectedTopic], + '#-': ['-'], // Must have the "-" tag for relay privacy + limit: 50 + } + ]) + + // Filter and sort threads + const filteredThreads = events + .filter(event => { + // Ensure it has a title tag + const titleTag = event.tags.find(tag => tag[0] === 'title' && tag[1]) + return titleTag && event.content.trim().length > 0 + }) + .sort((a, b) => b.created_at - a.created_at) + + setThreads(filteredThreads) + } catch (error) { + console.error('Error fetching threads:', error) + setThreads([]) + } finally { + setLoading(false) + } + } + + const handleCreateThread = () => { + setShowCreateThread(true) + } + + const handleThreadCreated = () => { + setShowCreateThread(false) + fetchThreads() // Refresh the list + } + + return ( + +
+ + {availableRelays.length > 1 && ( + + )} +
+
+ +
+ + } + displayScrollToTopButton + > +
+
+

+ {t('Discussions')} - {DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label} +

+
+ + {loading ? ( +
+
{t('Loading threads...')}
+
+ ) : threads.length === 0 ? ( + + + +

{t('No threads yet')}

+

+ {t('Be the first to start a discussion in this topic!')} +

+ +
+
+ ) : ( +
+ {threads.map(thread => ( + { + // TODO: Navigate to thread detail view + console.log('Open thread:', thread.id) + }} + /> + ))} +
+ )} +
+ + {showCreateThread && ( + setShowCreateThread(false)} + onThreadCreated={handleThreadCreated} + /> + )} +
+ ) +}) + +DiscussionsPage.displayName = 'DiscussionsPage' +export default DiscussionsPage + +// Placeholder components - to be implemented +function CreateThreadDialog({ + onClose, + onThreadCreated +}: { + topic: string + availableRelays: string[] + onClose: () => void + onThreadCreated: () => void +}) { + // TODO: Implement thread creation dialog + return ( +
+ + + Create New Thread + + +

Thread creation will be implemented here...

+
+ + +
+
+
+
+ ) +}