Browse Source

first prototype of duscussions

imwald
Silberengel 5 months ago
parent
commit
7e332c6363
  1. 11
      package-lock.json
  2. 1
      package.json
  3. 16
      src/PageManager.tsx
  4. 12
      src/components/BottomNavigationBar/DiscussionsButton.tsx
  5. 4
      src/constants.ts
  6. 9
      src/lib/utils.ts
  7. 104
      src/pages/primary/DiscussionsPage/ThreadCard.tsx
  8. 50
      src/pages/primary/DiscussionsPage/TopicFilter.tsx
  9. 210
      src/pages/primary/DiscussionsPage/index.tsx

11
package-lock.json generated

@ -48,6 +48,7 @@ @@ -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 @@ @@ -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",

1
package.json

@ -58,6 +58,7 @@ @@ -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",

16
src/PageManager.tsx

@ -24,6 +24,7 @@ import NotificationListPage from './pages/primary/NotificationListPage' @@ -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 = { @@ -58,7 +59,8 @@ const PRIMARY_PAGE_REF_MAP = {
me: createRef<TPageRef>(),
profile: createRef<TPageRef>(),
relay: createRef<TPageRef>(),
search: createRef<TPageRef>()
search: createRef<TPageRef>(),
discussions: createRef<TPageRef>()
}
const PRIMARY_PAGE_MAP = {
@ -68,7 +70,8 @@ const PRIMARY_PAGE_MAP = { @@ -68,7 +70,8 @@ const PRIMARY_PAGE_MAP = {
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />,
profile: <ProfilePage ref={PRIMARY_PAGE_REF_MAP.profile} />,
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />,
discussions: <DiscussionsPage ref={PRIMARY_PAGE_REF_MAP.discussions} />
}
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
@ -143,11 +146,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -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 }) { @@ -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')
}

12
src/components/BottomNavigationBar/DiscussionsButton.tsx

@ -1,15 +1,15 @@ @@ -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 (
<BottomNavigationBarItem onClick={handleClick}>
<BottomNavigationBarItem
active={current === 'discussions' && display}
onClick={() => navigate('discussions')}
>
<MessageCircle />
</BottomNavigationBarItem>
)

4
src/constants.ts

@ -3,7 +3,9 @@ import { kinds } from 'nostr-tools' @@ -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([])

9
src/lib/utils.ts

@ -17,6 +17,15 @@ export function cn(...inputs: ClassValue[]) { @@ -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

104
src/pages/primary/DiscussionsPage/ThreadCard.tsx

@ -0,0 +1,104 @@ @@ -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<string, { label: string; color: string }> = {
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 (
<Card
className={cn(
'clickable hover:shadow-md transition-shadow cursor-pointer',
className
)}
onClick={onThreadClick}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg leading-tight mb-2 line-clamp-2">
{title}
</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary" className={cn('text-xs', topicInfo.color)}>
<Hash className="w-3 h-3 mr-1" />
{topicInfo.label}
</Badge>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{timeAgo}
</div>
</div>
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<MessageCircle className="w-4 h-4" />
<span>0</span> {/* TODO: Add reply count */}
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-sm text-muted-foreground leading-relaxed">
{contentPreview}
</div>
<div className="flex items-center justify-between mt-3 pt-3 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<User className="w-4 h-4" />
<span className="truncate">
{thread.pubkey.slice(0, 8)}...{thread.pubkey.slice(-8)}
</span>
</div>
<Button variant="ghost" size="sm" className="h-8 px-2">
{t('Read more')}
</Button>
</div>
</CardContent>
</Card>
)
}

50
src/pages/primary/DiscussionsPage/TopicFilter.tsx

@ -0,0 +1,50 @@ @@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 h-10 px-3"
>
<Hash className="w-4 h-4" />
<span className="hidden sm:inline">{selectedTopicInfo.label}</span>
<span className="sm:hidden">{selectedTopicInfo.label.slice(0, 8)}</span>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{topics.map(topic => (
<DropdownMenuItem
key={topic.id}
onClick={() => onTopicChange(topic.id)}
className="flex items-center gap-2"
>
<Hash className="w-4 h-4" />
<span>{topic.label}</span>
{topic.id === selectedTopic && (
<span className="ml-auto text-primary"></span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

210
src/pages/primary/DiscussionsPage/index.tsx

@ -0,0 +1,210 @@ @@ -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<string | null>(null)
const [threads, setThreads] = useState<NostrEvent[]>([])
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 (
<PrimaryPageLayout
ref={ref}
pageName="discussions"
titlebar={
<div className="flex gap-1 items-center h-full justify-between">
<div className="flex gap-1 items-center">
<TopicFilter
topics={DISCUSSION_TOPICS}
selectedTopic={selectedTopic}
onTopicChange={setSelectedTopic}
/>
{availableRelays.length > 1 && (
<select
value={selectedRelay || ''}
onChange={(e) => setSelectedRelay(e.target.value || null)}
className="px-2 py-1 rounded border bg-background text-sm"
>
<option value="">All Relays</option>
{availableRelays.map(relay => (
<option key={relay} value={relay}>
{relay.replace('wss://', '').replace('ws://', '')}
</option>
))}
</select>
)}
</div>
<div className="flex gap-1 items-center">
<Button
variant="ghost"
size="titlebar-icon"
onClick={handleCreateThread}
title={t('Create new thread')}
>
<MessageSquarePlus />
</Button>
</div>
</div>
}
displayScrollToTopButton
>
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">
{t('Discussions')} - {DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label}
</h1>
</div>
{loading ? (
<div className="flex justify-center py-8">
<div className="text-muted-foreground">{t('Loading threads...')}</div>
</div>
) : threads.length === 0 ? (
<Card>
<CardContent className="p-8 text-center">
<MessageSquarePlus className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2">{t('No threads yet')}</h3>
<p className="text-muted-foreground mb-4">
{t('Be the first to start a discussion in this topic!')}
</p>
<Button onClick={handleCreateThread}>
<MessageSquarePlus className="w-4 h-4 mr-2" />
{t('Create Thread')}
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{threads.map(thread => (
<ThreadCard
key={thread.id}
thread={thread}
onThreadClick={() => {
// TODO: Navigate to thread detail view
console.log('Open thread:', thread.id)
}}
/>
))}
</div>
)}
</div>
{showCreateThread && (
<CreateThreadDialog
topic={selectedTopic}
availableRelays={availableRelays}
onClose={() => setShowCreateThread(false)}
onThreadCreated={handleThreadCreated}
/>
)}
</PrimaryPageLayout>
)
})
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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md mx-4">
<CardHeader>
<CardTitle>Create New Thread</CardTitle>
</CardHeader>
<CardContent>
<p>Thread creation will be implemented here...</p>
<div className="flex gap-2 mt-4">
<Button onClick={onClose} variant="outline">Cancel</Button>
<Button onClick={onThreadCreated}>Create</Button>
</div>
</CardContent>
</Card>
</div>
)
}
Loading…
Cancel
Save