-
+
diff --git a/src/components/NoteStats/DiscussionNoteStats.tsx b/src/components/NoteStats/DiscussionNoteStats.tsx
new file mode 100644
index 0000000..791f5c3
--- /dev/null
+++ b/src/components/NoteStats/DiscussionNoteStats.tsx
@@ -0,0 +1,69 @@
+import { cn } from '@/lib/utils'
+import { useNostr } from '@/providers/NostrProvider'
+import { useScreenSize } from '@/providers/ScreenSizeProvider'
+import noteStatsService from '@/services/note-stats.service'
+import { Event } from 'nostr-tools'
+import { useEffect, useState } from 'react'
+import VoteButtons from './VoteButtons'
+import ReplyButton from './ReplyButton'
+import SeenOnButton from './SeenOnButton'
+
+export default function DiscussionNoteStats({
+ event,
+ className,
+ classNames,
+ fetchIfNotExisting = false
+}: {
+ event: Event
+ className?: string
+ classNames?: {
+ buttonBar?: string
+ }
+ fetchIfNotExisting?: boolean
+}) {
+ const { isSmallScreen } = useScreenSize()
+ const { pubkey } = useNostr()
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ if (!fetchIfNotExisting) return
+ setLoading(true)
+ noteStatsService.fetchNoteStats(event, pubkey).finally(() => setLoading(false))
+ }, [event, fetchIfNotExisting])
+
+ if (isSmallScreen) {
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
e.stopPropagation()}
+ >
+
+
+
e.stopPropagation()}>
+
+
+
+
+
+ )
+}
diff --git a/src/components/NoteStats/VoteButtons.tsx b/src/components/NoteStats/VoteButtons.tsx
new file mode 100644
index 0000000..294a22b
--- /dev/null
+++ b/src/components/NoteStats/VoteButtons.tsx
@@ -0,0 +1,119 @@
+import { Button } from '@/components/ui/button'
+import { createReactionDraftEvent } from '@/lib/draft-event'
+import { useNostr } from '@/providers/NostrProvider'
+import client from '@/services/client.service'
+import noteStatsService from '@/services/note-stats.service'
+import { Event } from 'nostr-tools'
+import { ChevronDown, ChevronUp } from 'lucide-react'
+import { useMemo, useState } from 'react'
+import { useNoteStatsById } from '@/hooks/useNoteStatsById'
+
+export default function VoteButtons({ event }: { event: Event }) {
+ const { pubkey, publish, checkLogin } = useNostr()
+ const [voting, setVoting] = useState
(null)
+ const noteStats = useNoteStatsById(event.id)
+
+ // Calculate vote counts and user's current vote
+ const { userVote, score } = useMemo(() => {
+ const stats = noteStats || {}
+ const reactions = stats.likes || []
+
+ const upvoteReactions = reactions.filter(r => r.emoji === '⬆️')
+ const downvoteReactions = reactions.filter(r => r.emoji === '⬇️')
+
+ const score = upvoteReactions.length - downvoteReactions.length
+
+ // Check if current user has voted
+ let userVote: 'up' | 'down' | null = null
+ if (pubkey) {
+ if (upvoteReactions.some(r => r.pubkey === pubkey)) {
+ userVote = 'up'
+ } else if (downvoteReactions.some(r => r.pubkey === pubkey)) {
+ userVote = 'down'
+ }
+ }
+
+ return { userVote, score }
+ }, [noteStats, pubkey])
+
+ const vote = async (type: 'up' | 'down') => {
+ checkLogin(async () => {
+ if (voting || !pubkey) return
+
+ setVoting(type)
+ const timer = setTimeout(() => setVoting(null), 10_000)
+
+ try {
+ if (!noteStats?.updatedAt) {
+ await noteStatsService.fetchNoteStats(event, pubkey)
+ }
+
+ // If user is voting the same way again, remove the vote (toggle)
+ const emoji = type === 'up' ? '⬆️' : '⬇️'
+
+ // Check if user already voted this way
+ const existingVote = userVote === type
+ if (existingVote) {
+ // Remove vote by creating a reaction with the same emoji (this will toggle it off)
+ const reaction = createReactionDraftEvent(event, emoji)
+ const seenOn = client.getSeenEventRelayUrls(event.id)
+ const evt = await publish(reaction, { additionalRelayUrls: seenOn })
+ noteStatsService.updateNoteStatsByEvents([evt])
+ } else {
+ // If user voted the opposite way, first remove the old vote
+ if (userVote) {
+ const oldEmoji = userVote === 'up' ? '⬆️' : '⬇️'
+ const removeReaction = createReactionDraftEvent(event, oldEmoji)
+ const seenOn = client.getSeenEventRelayUrls(event.id)
+ await publish(removeReaction, { additionalRelayUrls: seenOn })
+ }
+
+ // Then add the new vote
+ const reaction = createReactionDraftEvent(event, emoji)
+ const seenOn = client.getSeenEventRelayUrls(event.id)
+ const evt = await publish(reaction, { additionalRelayUrls: seenOn })
+ noteStatsService.updateNoteStatsByEvents([evt])
+ }
+ } catch (error) {
+ console.error('vote failed', error)
+ } finally {
+ setVoting(null)
+ clearTimeout(timer)
+ }
+ })
+ }
+
+ return (
+
+
+
+ 0 ? 'text-orange-600' : score < 0 ? 'text-blue-600' : 'text-muted-foreground'
+ }`}>
+ {score}
+
+
+
+
+ )
+}
diff --git a/src/pages/primary/DiscussionsPage/ThreadCard.tsx b/src/pages/primary/DiscussionsPage/ThreadCard.tsx
index 727592a..f10b258 100644
--- a/src/pages/primary/DiscussionsPage/ThreadCard.tsx
+++ b/src/pages/primary/DiscussionsPage/ThreadCard.tsx
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils'
import { truncateText } from '@/lib/utils'
import { DISCUSSION_TOPICS } from './CreateThreadDialog'
import Username from '@/components/Username'
+import VoteButtons from '@/components/NoteStats/VoteButtons'
interface ThreadWithRelaySource extends NostrEvent {
_relaySource?: string
@@ -67,7 +68,8 @@ export default function ThreadCard({ thread, onThreadClick, className }: ThreadC
onClick={onThreadClick}
>
-
+
+
diff --git a/src/pages/primary/DiscussionsPage/ThreadSort.tsx b/src/pages/primary/DiscussionsPage/ThreadSort.tsx
new file mode 100644
index 0000000..db453b1
--- /dev/null
+++ b/src/pages/primary/DiscussionsPage/ThreadSort.tsx
@@ -0,0 +1,46 @@
+import { Button } from '@/components/ui/button'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
+import { ChevronDown, Clock, TrendingUp, ArrowUpDown } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+export type SortOption = 'newest' | 'oldest' | 'top' | 'controversial'
+
+export default function ThreadSort({ selectedSort, onSortChange }: { selectedSort: SortOption; onSortChange: (sort: SortOption) => void }) {
+ const { t } = useTranslation()
+
+ const sortOptions = [
+ { id: 'newest' as SortOption, label: t('Newest'), icon: Clock },
+ { id: 'oldest' as SortOption, label: t('Oldest'), icon: Clock },
+ { id: 'top' as SortOption, label: t('Top'), icon: TrendingUp },
+ { id: 'controversial' as SortOption, label: t('Controversial'), icon: ArrowUpDown },
+ ]
+
+ const selectedOption = sortOptions.find(option => option.id === selectedSort) || sortOptions[0]
+
+ return (
+
+
+
+
+
+ {sortOptions.map(option => (
+ onSortChange(option.id)}
+ className="flex items-center gap-2"
+ >
+
+ {option.label}
+ {option.id === selectedSort && (
+ ✓
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/src/pages/primary/DiscussionsPage/TopicFilter.tsx b/src/pages/primary/DiscussionsPage/TopicFilter.tsx
index 9c57414..6bd98ea 100644
--- a/src/pages/primary/DiscussionsPage/TopicFilter.tsx
+++ b/src/pages/primary/DiscussionsPage/TopicFilter.tsx
@@ -64,7 +64,6 @@ export default function TopicFilter({ topics, selectedTopic, onTopicChange, thre
variant="outline"
className="flex items-center gap-2 h-10 px-3 min-w-44"
>
-
{selectedTopicInfo.label}
diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx
index 5928b67..6336802 100644
--- a/src/pages/primary/DiscussionsPage/index.tsx
+++ b/src/pages/primary/DiscussionsPage/index.tsx
@@ -9,6 +9,7 @@ import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { MessageSquarePlus } from 'lucide-react'
import ThreadCard from '@/pages/primary/DiscussionsPage/ThreadCard'
import TopicFilter from '@/pages/primary/DiscussionsPage/TopicFilter'
+import ThreadSort, { SortOption } from '@/pages/primary/DiscussionsPage/ThreadSort'
import CreateThreadDialog, { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/CreateThreadDialog'
import { NostrEvent } from 'nostr-tools'
import client from '@/services/client.service'
@@ -22,6 +23,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
const { push } = useSecondaryPage()
const [selectedTopic, setSelectedTopic] = useState('general')
const [selectedRelay, setSelectedRelay] = useState(null)
+ const [selectedSort, setSelectedSort] = useState('newest')
const [allThreads, setAllThreads] = useState([])
const [threads, setThreads] = useState([])
const [loading, setLoading] = useState(false)
@@ -39,7 +41,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
useEffect(() => {
filterThreadsByTopic()
- }, [allThreads, selectedTopic])
+ }, [allThreads, selectedTopic, selectedSort])
const fetchAllThreads = async () => {
setLoading(true)
@@ -68,7 +70,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
...event,
_relaySource: selectedRelay || 'multiple' // Track which relay(s) it was found on
}))
- .sort((a, b) => b.created_at - a.created_at)
+ .sort((a, b) => b.created_at - a.created_at) // Sort by newest first (will be overridden by vote-based sorting in the UI)
setAllThreads(validThreads)
} catch (error) {
@@ -101,7 +103,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
})
// Filter threads for the selected topic (or show all if "all" is selected)
- const threadsForTopic = selectedTopic === 'all'
+ let threadsForTopic = selectedTopic === 'all'
? categorizedThreads.map(thread => {
// Remove the temporary categorization property but keep relay source
const { _categorizedTopic, ...cleanThread } = thread
@@ -115,6 +117,28 @@ const DiscussionsPage = forwardRef((_, ref) => {
return cleanThread
})
+ // Apply sorting based on selectedSort
+ switch (selectedSort) {
+ case 'newest':
+ threadsForTopic.sort((a, b) => b.created_at - a.created_at)
+ break
+ case 'oldest':
+ threadsForTopic.sort((a, b) => a.created_at - b.created_at)
+ break
+ case 'top':
+ // For now, sort by newest since we don't have vote data readily available
+ // TODO: Implement proper vote-based sorting when vote data is available
+ threadsForTopic.sort((a, b) => b.created_at - a.created_at)
+ break
+ case 'controversial':
+ // For now, sort by newest since we don't have vote data readily available
+ // TODO: Implement controversial sorting (high upvotes AND downvotes)
+ threadsForTopic.sort((a, b) => b.created_at - a.created_at)
+ break
+ default:
+ threadsForTopic.sort((a, b) => b.created_at - a.created_at)
+ }
+
setThreads(threadsForTopic)
}
@@ -175,6 +199,12 @@ const DiscussionsPage = forwardRef((_, ref) => {
{t('Discussions')} - {selectedTopic === 'all' ? t('All Topics') : DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label}
+
+
+
{loading ? (