From 410030258172880e650c5d619ef43fe4be24ca16 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 5 Oct 2025 00:07:31 +0200 Subject: [PATCH] sorting implemented --- src/components/NoteCard/MainNoteCard.tsx | 10 +- src/components/NoteInteractions/Tabs.tsx | 15 ++- src/components/NoteInteractions/index.tsx | 7 +- .../NoteStats/DiscussionNoteStats.tsx | 69 ++++++++++ src/components/NoteStats/VoteButtons.tsx | 119 ++++++++++++++++++ .../primary/DiscussionsPage/ThreadCard.tsx | 4 +- .../primary/DiscussionsPage/ThreadSort.tsx | 46 +++++++ .../primary/DiscussionsPage/TopicFilter.tsx | 1 - src/pages/primary/DiscussionsPage/index.tsx | 36 +++++- 9 files changed, 296 insertions(+), 11 deletions(-) create mode 100644 src/components/NoteStats/DiscussionNoteStats.tsx create mode 100644 src/components/NoteStats/VoteButtons.tsx create mode 100644 src/pages/primary/DiscussionsPage/ThreadSort.tsx diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 3b0252c..4b949a1 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -1,10 +1,12 @@ import { Separator } from '@/components/ui/separator' +import { ExtendedKind } from '@/constants' import { toNote } from '@/lib/link' import { useSecondaryPage } from '@/PageManager' import { Event } from 'nostr-tools' import Collapsible from '../Collapsible' import Note from '../Note' import NoteStats from '../NoteStats' +import DiscussionNoteStats from '../NoteStats/DiscussionNoteStats' import RepostDescription from './RepostDescription' export default function MainNoteCard({ @@ -40,7 +42,13 @@ export default function MainNoteCard({ originalNoteId={originalNoteId} /> - {!embedded && } + {!embedded && ( + event.kind === ExtendedKind.DISCUSSION ? ( + + ) : ( + + ) + )} {!embedded && } diff --git a/src/components/NoteInteractions/Tabs.tsx b/src/components/NoteInteractions/Tabs.tsx index 1c4ba58..6b572f1 100644 --- a/src/components/NoteInteractions/Tabs.tsx +++ b/src/components/NoteInteractions/Tabs.tsx @@ -13,18 +13,25 @@ const TABS = [ export function Tabs({ selectedTab, - onTabChange + onTabChange, + hideRepostsAndQuotes = false }: { selectedTab: TTabValue onTabChange: (tab: TTabValue) => void + hideRepostsAndQuotes?: boolean }) { const { t } = useTranslation() const tabRefs = useRef<(HTMLDivElement | null)[]>([]) const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 }) + // Filter tabs based on hideRepostsAndQuotes + const visibleTabs = hideRepostsAndQuotes + ? TABS.filter(tab => tab.value !== 'reposts' && tab.value !== 'quotes') + : TABS + useEffect(() => { setTimeout(() => { - const activeIndex = TABS.findIndex((tab) => tab.value === selectedTab) + const activeIndex = visibleTabs.findIndex((tab) => tab.value === selectedTab) if (activeIndex >= 0 && tabRefs.current[activeIndex]) { const activeTab = tabRefs.current[activeIndex] const { offsetWidth, offsetLeft } = activeTab @@ -35,12 +42,12 @@ export function Tabs({ }) } }, 20) // ensure tabs are rendered before calculating - }, [selectedTab]) + }, [selectedTab, visibleTabs]) return (
- {TABS.map((tab, index) => ( + {visibleTabs.map((tab, index) => (
(tabRefs.current[index] = el)} diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx index cfad0bf..cfe30d6 100644 --- a/src/components/NoteInteractions/index.tsx +++ b/src/components/NoteInteractions/index.tsx @@ -1,5 +1,6 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { Separator } from '@/components/ui/separator' +import { ExtendedKind } from '@/constants' import { Event } from 'nostr-tools' import { useState } from 'react' import HideUntrustedContentButton from '../HideUntrustedContentButton' @@ -18,18 +19,22 @@ export default function NoteInteractions({ event: Event }) { const [type, setType] = useState('replies') + const isDiscussion = event.kind === ExtendedKind.DISCUSSION + let list switch (type) { case 'replies': list = break case 'quotes': + if (isDiscussion) return null // Hide quotes for discussions list = break case 'reactions': list = break case 'reposts': + if (isDiscussion) return null // Hide reposts for discussions list = break case 'zaps': @@ -43,7 +48,7 @@ export default function NoteInteractions({ <>
- + 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 ? (