diff --git a/src/components/NoteInteractions/ReplySort.tsx b/src/components/NoteInteractions/ReplySort.tsx new file mode 100644 index 0000000..b7cee74 --- /dev/null +++ b/src/components/NoteInteractions/ReplySort.tsx @@ -0,0 +1,47 @@ +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { ChevronDown, Clock, TrendingUp, ArrowUpDown, Zap } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +export type ReplySortOption = 'newest' | 'oldest' | 'top' | 'controversial' | 'most-zapped' + +export default function ReplySort({ selectedSort, onSortChange }: { selectedSort: ReplySortOption; onSortChange: (sort: ReplySortOption) => void }) { + const { t } = useTranslation() + + const sortOptions = [ + { id: 'newest' as ReplySortOption, label: t('Newest'), icon: Clock }, + { id: 'oldest' as ReplySortOption, label: t('Oldest'), icon: Clock }, + { id: 'top' as ReplySortOption, label: t('Top'), icon: TrendingUp }, + { id: 'controversial' as ReplySortOption, label: t('Controversial'), icon: ArrowUpDown }, + { id: 'most-zapped' as ReplySortOption, label: t('Most Zapped'), icon: Zap }, + ] + + 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/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx index cfe30d6..c878c07 100644 --- a/src/components/NoteInteractions/index.tsx +++ b/src/components/NoteInteractions/index.tsx @@ -10,6 +10,7 @@ import ReplyNoteList from '../ReplyNoteList' import RepostList from '../RepostList' import ZapList from '../ZapList' import { Tabs, TTabValue } from './Tabs' +import ReplySort, { ReplySortOption } from './ReplySort' export default function NoteInteractions({ pageIndex, @@ -19,12 +20,13 @@ export default function NoteInteractions({ event: Event }) { const [type, setType] = useState('replies') + const [replySort, setReplySort] = useState('newest') const isDiscussion = event.kind === ExtendedKind.DISCUSSION let list switch (type) { case 'replies': - list = + list = break case 'quotes': if (isDiscussion) return null // Hide quotes for discussions @@ -52,6 +54,12 @@ export default function NoteInteractions({ + {type === 'replies' && isDiscussion && ( + <> + + + + )}
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 04c4b21..2e4ac8d 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -18,6 +18,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useReply } from '@/providers/ReplyProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' +import noteStatsService from '@/services/note-stats.service' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -32,7 +33,7 @@ type TRootInfo = const LIMIT = 100 const SHOW_COUNT = 10 -export default function ReplyNoteList({ index, event }: { index?: number; event: NEvent }) { +export default function ReplyNoteList({ index, event, sort = 'newest' }: { index?: number; event: NEvent; sort?: 'newest' | 'oldest' | 'top' | 'controversial' | 'most-zapped' }) { const { t } = useTranslation() const { push, currentIndex } = useSecondaryPage() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() @@ -41,6 +42,46 @@ export default function ReplyNoteList({ index, event }: { index?: number; event: const { relayList: userRelayList } = useNostr() const [rootInfo, setRootInfo] = useState(undefined) const { repliesMap, addReplies } = useReply() + + // Helper function to get vote score for a reply + const getReplyVoteScore = (reply: NEvent) => { + const stats = noteStatsService.getNoteStats(reply.id) + if (!stats?.likes) { + return 0 + } + + const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆️') + const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇️') + const score = upvoteReactions.length - downvoteReactions.length + + return score + } + + // Helper function to get controversy score for a reply + const getReplyControversyScore = (reply: NEvent) => { + const stats = noteStatsService.getNoteStats(reply.id) + if (!stats?.likes) { + return 0 + } + + const upvoteReactions = stats.likes.filter(r => r.emoji === '⬆️') + const downvoteReactions = stats.likes.filter(r => r.emoji === '⬇️') + + // Controversy = minimum of upvotes and downvotes (both need to be high) + const controversy = Math.min(upvoteReactions.length, downvoteReactions.length) + return controversy + } + + // Helper function to get total zap amount for a reply + const getReplyZapAmount = (reply: NEvent) => { + const stats = noteStatsService.getNoteStats(reply.id) + if (!stats?.zaps) { + return 0 + } + + const totalAmount = stats.zaps.reduce((sum, zap) => sum + zap.amount, 0) + return totalAmount + } const replies = useMemo(() => { const replyIdSet = new Set() const replyEvents: NEvent[] = [] @@ -60,8 +101,46 @@ export default function ReplyNoteList({ index, event }: { index?: number; event: }) parentEventKeys = events.map((evt) => evt.id) } - return replyEvents.sort((a, b) => a.created_at - b.created_at) - }, [event.id, repliesMap]) + // Apply sorting based on the sort parameter + switch (sort) { + case 'oldest': + return replyEvents.sort((a, b) => a.created_at - b.created_at) + case 'newest': + return replyEvents.sort((a, b) => b.created_at - a.created_at) + case 'top': + // Sort by vote score (upvotes - downvotes), then by newest if tied + return replyEvents.sort((a, b) => { + const scoreA = getReplyVoteScore(a) + const scoreB = getReplyVoteScore(b) + if (scoreA !== scoreB) { + return scoreB - scoreA // Higher scores first + } + return b.created_at - a.created_at // Newest first if tied + }) + case 'controversial': + // Sort by controversy score (min of upvotes and downvotes), then by newest if tied + return replyEvents.sort((a, b) => { + const controversyA = getReplyControversyScore(a) + const controversyB = getReplyControversyScore(b) + if (controversyA !== controversyB) { + return controversyB - controversyA // Higher controversy first + } + return b.created_at - a.created_at // Newest first if tied + }) + case 'most-zapped': + // Sort by total zap amount, then by newest if tied + return replyEvents.sort((a, b) => { + const zapAmountA = getReplyZapAmount(a) + const zapAmountB = getReplyZapAmount(b) + if (zapAmountA !== zapAmountB) { + return zapAmountB - zapAmountA // Higher zap amounts first + } + return b.created_at - a.created_at // Newest first if tied + }) + default: + return replyEvents.sort((a, b) => b.created_at - a.created_at) + } + }, [event.id, repliesMap, sort]) const [timelineKey, setTimelineKey] = useState(undefined) const [until, setUntil] = useState(undefined) const [loading, setLoading] = useState(false) diff --git a/src/pages/primary/DiscussionsPage/ThreadSort.tsx b/src/pages/primary/DiscussionsPage/ThreadSort.tsx index db453b1..16611c2 100644 --- a/src/pages/primary/DiscussionsPage/ThreadSort.tsx +++ b/src/pages/primary/DiscussionsPage/ThreadSort.tsx @@ -1,9 +1,9 @@ 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 { ChevronDown, Clock, TrendingUp, ArrowUpDown, Zap } from 'lucide-react' import { useTranslation } from 'react-i18next' -export type SortOption = 'newest' | 'oldest' | 'top' | 'controversial' +export type SortOption = 'newest' | 'oldest' | 'top' | 'controversial' | 'most-zapped' export default function ThreadSort({ selectedSort, onSortChange }: { selectedSort: SortOption; onSortChange: (sort: SortOption) => void }) { const { t } = useTranslation() @@ -13,6 +13,7 @@ export default function ThreadSort({ selectedSort, onSortChange }: { selectedSor { 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 }, + { id: 'most-zapped' as SortOption, label: t('Most Zapped'), icon: Zap }, ] const selectedOption = sortOptions.find(option => option.id === selectedSort) || sortOptions[0] diff --git a/src/pages/primary/DiscussionsPage/index.tsx b/src/pages/primary/DiscussionsPage/index.tsx index d9e0ced..e257ef9 100644 --- a/src/pages/primary/DiscussionsPage/index.tsx +++ b/src/pages/primary/DiscussionsPage/index.tsx @@ -113,6 +113,18 @@ const DiscussionsPage = forwardRef((_, ref) => { return controversy } + // Helper function to get total zap amount for a thread + const getThreadZapAmount = (thread: NostrEvent) => { + const stats = noteStatsService.getNoteStats(thread.id) + if (!stats?.zaps) { + return 0 + } + + const totalAmount = stats.zaps.reduce((sum, zap) => sum + zap.amount, 0) + console.log(`Thread ${thread.id}: ${stats.zaps.length} zaps, total amount: ${totalAmount}`) + return totalAmount + } + useEffect(() => { setCustomVoteStats({}) // Clear custom stats when relay changes fetchAllThreads() @@ -366,6 +378,24 @@ const DiscussionsPage = forwardRef((_, ref) => { console.log('Sorted by controversial') break + case 'most-zapped': + // Sort by total zap amount, then by newest if tied + const sortedMostZapped = [...threadsForTopic].sort((a, b) => { + const zapAmountA = getThreadZapAmount(a) + const zapAmountB = getThreadZapAmount(b) + console.log(`Comparing ${a.id.slice(0,8)} (zaps: ${zapAmountA}) vs ${b.id.slice(0,8)} (zaps: ${zapAmountB})`) + if (zapAmountA !== zapAmountB) { + return zapAmountB - zapAmountA // Higher zap amounts first + } + return b.created_at - a.created_at // Newest first if tied + }) + + // Replace the original array + threadsForTopic.length = 0 + threadsForTopic.push(...sortedMostZapped) + + console.log('Sorted by most zapped') + break default: const sortedDefault = [...threadsForTopic].sort((a, b) => b.created_at - a.created_at) threadsForTopic.length = 0