From b35e0cf8503d504c4e175987f5d0b555292c5eca Mon Sep 17 00:00:00 2001 From: Cody Tseng Date: Sun, 27 Jul 2025 12:05:50 +0800 Subject: [PATCH] feat: polls (#451) Co-authored-by: silberengel --- src/components/Note/Poll.tsx | 232 ++++++++++++++++++ src/components/Note/UnknownNote.tsx | 2 +- src/components/Note/index.tsx | 13 +- src/components/NoteList/index.tsx | 35 +-- src/components/PostEditor/PollEditor.tsx | 145 +++++++++++ src/components/PostEditor/PostContent.tsx | 136 ++++++++-- .../PostEditor/PostTextarea/Preview.tsx | 5 +- .../PostEditor/PostTextarea/index.tsx | 18 +- src/components/ui/button.tsx | 2 +- src/constants.ts | 7 + src/hooks/useFetchPollResults.tsx | 9 + src/i18n/locales/ar.ts | 19 +- src/i18n/locales/de.ts | 19 +- src/i18n/locales/en.ts | 19 +- src/i18n/locales/es.ts | 19 +- src/i18n/locales/fa.ts | 19 +- src/i18n/locales/fr.ts | 20 +- src/i18n/locales/it.ts | 19 +- src/i18n/locales/ja.ts | 19 +- src/i18n/locales/ko.ts | 19 +- src/i18n/locales/pl.ts | 20 +- src/i18n/locales/pt-BR.ts | 19 +- src/i18n/locales/pt-PT.ts | 19 +- src/i18n/locales/ru.ts | 19 +- src/i18n/locales/th.ts | 19 +- src/i18n/locales/zh.ts | 19 +- src/index.css | 4 + src/lib/draft-event.ts | 103 +++++++- src/lib/event-metadata.ts | 77 +++++- src/providers/NostrProvider/index.tsx | 17 +- src/services/poll-results.service.ts | 142 +++++++++++ src/services/post-content-cache.service.ts | 48 ---- src/services/post-editor-cache.service.ts | 75 ++++++ src/types.ts | 10 + vite.config.ts | 3 +- 35 files changed, 1240 insertions(+), 130 deletions(-) create mode 100644 src/components/Note/Poll.tsx create mode 100644 src/components/PostEditor/PollEditor.tsx create mode 100644 src/hooks/useFetchPollResults.tsx create mode 100644 src/services/poll-results.service.ts delete mode 100644 src/services/post-content-cache.service.ts create mode 100644 src/services/post-editor-cache.service.ts diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx new file mode 100644 index 0000000..dbfeda7 --- /dev/null +++ b/src/components/Note/Poll.tsx @@ -0,0 +1,232 @@ +import { Button } from '@/components/ui/button' +import { POLL_TYPE } from '@/constants' +import { useFetchPollResults } from '@/hooks/useFetchPollResults' +import { createPollResponseDraftEvent } from '@/lib/draft-event' +import { getPollMetadataFromEvent } from '@/lib/event-metadata' +import { cn } from '@/lib/utils' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import pollResultsService from '@/services/poll-results.service' +import dayjs from 'dayjs' +import { CheckCircle2, Loader2 } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +export default function Poll({ event, className }: { event: Event; className?: string }) { + const { t } = useTranslation() + const { pubkey, publish, startLogin } = useNostr() + const [isVoting, setIsVoting] = useState(false) + const [selectedOptionIds, setSelectedOptionIds] = useState([]) + const pollResults = useFetchPollResults(event.id) + const [isLoadingResults, setIsLoadingResults] = useState(false) + const poll = useMemo(() => getPollMetadataFromEvent(event), [event]) + const votedOptionIds = useMemo(() => { + if (!pollResults || !pubkey) return [] + return Object.entries(pollResults.results) + .filter(([, voters]) => voters.has(pubkey)) + .map(([optionId]) => optionId) + }, [pollResults, pubkey]) + const validPollOptionIds = useMemo(() => poll?.options.map((option) => option.id) || [], [poll]) + const isExpired = useMemo(() => poll?.endsAt && dayjs().unix() > poll.endsAt, [poll]) + const isMultipleChoice = useMemo(() => poll?.pollType === POLL_TYPE.MULTIPLE_CHOICE, [poll]) + const canVote = useMemo(() => !isExpired && !votedOptionIds.length, [isExpired, votedOptionIds]) + + if (!poll) { + return null + } + + const fetchResults = async () => { + setIsLoadingResults(true) + try { + const relays = await ensurePollRelays(event.pubkey, poll) + return await pollResultsService.fetchResults( + event.id, + relays, + validPollOptionIds, + isMultipleChoice, + poll.endsAt + ) + } catch (error) { + console.error('Failed to fetch poll results:', error) + toast.error('Failed to fetch poll results: ' + (error as Error).message) + } finally { + setIsLoadingResults(false) + } + } + + const handleOptionClick = (optionId: string) => { + if (isExpired) return + + if (isMultipleChoice) { + setSelectedOptionIds((prev) => + prev.includes(optionId) ? prev.filter((id) => id !== optionId) : [...prev, optionId] + ) + } else { + setSelectedOptionIds((prev) => (prev.includes(optionId) ? [] : [optionId])) + } + } + + const handleVote = async () => { + if (selectedOptionIds.length === 0) return + if (!pubkey) { + startLogin() + return + } + + setIsVoting(true) + try { + if (!pollResults) { + const _pollResults = await fetchResults() + if (_pollResults && _pollResults.voters.has(pubkey)) { + return + } + } + + const additionalRelayUrls = await ensurePollRelays(event.pubkey, poll) + + const draftEvent = createPollResponseDraftEvent(event, selectedOptionIds) + await publish(draftEvent, { + additionalRelayUrls + }) + + setSelectedOptionIds([]) + pollResultsService.addPollResponse(event.id, pubkey, selectedOptionIds) + } catch (error) { + console.error('Failed to vote:', error) + toast.error('Failed to vote: ' + (error as Error).message) + } finally { + setIsVoting(false) + } + } + + return ( +
+
+
+ {poll.pollType === POLL_TYPE.MULTIPLE_CHOICE && ( +

{t('Multiple choice (select one or more)')}

+ )} +
+ + {/* Poll Options */} +
+ {poll.options.map((option) => { + const votes = pollResults?.results?.[option.id]?.size ?? 0 + const totalVotes = pollResults?.totalVotes ?? 0 + const percentage = totalVotes > 0 ? (votes / totalVotes) * 100 : 0 + const isMax = + pollResults && pollResults.totalVotes > 0 + ? Object.values(pollResults.results).every((res) => res.size <= votes) + : false + + return ( + + ) + })} +
+ + {/* Results Summary */} +
+ {!!pollResults && t('{{number}} votes', { number: pollResults.totalVotes ?? 0 })} + {!!pollResults && !!poll.endsAt && ' · '} + {!!poll.endsAt && + (isExpired + ? t('Poll has ended') + : t('Poll ends at {{time}}', { + time: new Date(poll.endsAt * 1000).toLocaleString() + }))} +
+ + {(canVote || !pollResults) && ( +
+ {/* Vote Button */} + {canVote && ( + + )} + + {!pollResults && ( + + )} +
+ )} +
+
+ ) +} + +async function ensurePollRelays(creator: string, poll: { relayUrls: string[] }) { + const relays = poll.relayUrls.slice(0, 4) + if (!relays.length) { + const relayList = await client.fetchRelayList(creator) + relays.push(...relayList.read.slice(0, 4)) + } + return relays +} diff --git a/src/components/Note/UnknownNote.tsx b/src/components/Note/UnknownNote.tsx index 45d3457..962a048 100644 --- a/src/components/Note/UnknownNote.tsx +++ b/src/components/Note/UnknownNote.tsx @@ -3,7 +3,7 @@ import { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' import ClientSelect from '../ClientSelect' -export function UnknownNote({ event, className }: { event: Event; className?: string }) { +export default function UnknownNote({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() return ( diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 9eb210c..a1c25b7 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -29,7 +29,8 @@ import LiveEvent from './LiveEvent' import LongFormArticle from './LongFormArticle' import MutedNote from './MutedNote' import NsfwNote from './NsfwNote' -import { UnknownNote } from './UnknownNote' +import Poll from './Poll' +import UnknownNote from './UnknownNote' export default function Note({ event, @@ -69,7 +70,8 @@ export default function Note({ kinds.CommunityDefinition, ExtendedKind.GROUP_METADATA, ExtendedKind.PICTURE, - ExtendedKind.COMMENT + ExtendedKind.COMMENT, + ExtendedKind.POLL ].includes(event.kind) ) { content = @@ -87,6 +89,13 @@ export default function Note({ content = } else if (event.kind === kinds.CommunityDefinition) { content = + } else if (event.kind === ExtendedKind.POLL) { + content = ( + <> + + + + ) } else { content = } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 8ebf758..d75b554 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -24,6 +24,14 @@ import Tabs from '../Tabs' const LIMIT = 100 const ALGO_LIMIT = 500 const SHOW_COUNT = 10 +const KINDS = [ + kinds.ShortTextNote, + kinds.Repost, + kinds.Highlights, + kinds.LongFormArticle, + ExtendedKind.COMMENT, + ExtendedKind.POLL +] export default function NoteList({ relayUrls = [], @@ -115,13 +123,7 @@ export default function NoteList({ subRequests.push({ urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5), filter: { - kinds: [ - kinds.ShortTextNote, - kinds.Repost, - kinds.Highlights, - ExtendedKind.COMMENT, - kinds.LongFormArticle - ], + kinds: KINDS, authors: [pubkey], '#p': [author], limit: LIMIT @@ -130,13 +132,7 @@ export default function NoteList({ subRequests.push({ urls: targetRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5), filter: { - kinds: [ - kinds.ShortTextNote, - kinds.Repost, - kinds.Highlights, - ExtendedKind.COMMENT, - kinds.LongFormArticle - ], + kinds: KINDS, authors: [author], '#p': [pubkey], limit: LIMIT @@ -149,16 +145,7 @@ export default function NoteList({ } const _filter = { ...filter, - kinds: - filterType === 'pictures' - ? [ExtendedKind.PICTURE] - : [ - kinds.ShortTextNote, - kinds.Repost, - kinds.Highlights, - ExtendedKind.COMMENT, - kinds.LongFormArticle - ], + kinds: filterType === 'pictures' ? [ExtendedKind.PICTURE] : KINDS, limit: areAlgoRelays ? ALGO_LIMIT : LIMIT } if (relayUrls.length === 0 && (_filter.authors?.length || author)) { diff --git a/src/components/PostEditor/PollEditor.tsx b/src/components/PostEditor/PollEditor.tsx new file mode 100644 index 0000000..9ee036e --- /dev/null +++ b/src/components/PostEditor/PollEditor.tsx @@ -0,0 +1,145 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { normalizeUrl } from '@/lib/url' +import { TPollCreateData } from '@/types' +import dayjs from 'dayjs' +import { AlertCircle, Eraser, X } from 'lucide-react' +import { Dispatch, SetStateAction, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function PollEditor({ + pollCreateData, + setPollCreateData, + setIsPoll +}: { + pollCreateData: TPollCreateData + setPollCreateData: Dispatch> + setIsPoll: Dispatch> +}) { + const { t } = useTranslation() + const [isMultipleChoice, setIsMultipleChoice] = useState(pollCreateData.isMultipleChoice) + const [options, setOptions] = useState(pollCreateData.options) + const [endsAt, setEndsAt] = useState( + pollCreateData.endsAt ? dayjs(pollCreateData.endsAt * 1000).format('YYYY-MM-DDTHH:mm') : '' + ) + const [relayUrls, setRelayUrls] = useState(pollCreateData.relays.join(', ')) + + useEffect(() => { + setPollCreateData({ + isMultipleChoice, + options, + endsAt: endsAt ? dayjs(endsAt).startOf('minute').unix() : undefined, + relays: relayUrls + ? relayUrls + .split(',') + .map((url) => normalizeUrl(url.trim())) + .filter(Boolean) + : [] + }) + }, [isMultipleChoice, options, endsAt, relayUrls]) + + const handleAddOption = () => { + setOptions([...options, '']) + } + + const handleRemoveOption = (index: number) => { + if (options.length > 2) { + setOptions(options.filter((_, i) => i !== index)) + } + } + + const handleOptionChange = (index: number, value: string) => { + const newOptions = [...options] + newOptions[index] = value + setOptions(newOptions) + } + + return ( +
+
+ {options.map((option, index) => ( +
+ handleOptionChange(index, e.target.value)} + placeholder={t('Option {{number}}', { number: index + 1 })} + /> + +
+ ))} + +
+ +
+ + +
+ +
+ +
+ setEndsAt(e.target.value)} + /> + +
+
+ +
+ + setRelayUrls(e.target.value)} + placeholder="wss://relay1.com, wss://relay2.com" + /> +
+ +
+
+
+ +
{t('This is a poll note.')}
+
+
+ {t( + 'Unlike regular notes, polls are not widely supported and may not display on other clients.' + )} +
+
+ + +
+
+ ) +} diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index d2031b7..a71d1f8 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -1,17 +1,23 @@ import Note from '@/components/Note' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' -import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event' +import { + createCommentDraftEvent, + createPollDraftEvent, + createShortTextNoteDraftEvent +} from '@/lib/draft-event' import { isTouchDevice } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' -import postContentCache from '@/services/post-content-cache.service' -import { ImageUp, LoaderCircle, Settings, Smile } from 'lucide-react' +import postEditorCache from '@/services/post-editor-cache.service' +import { TPollCreateData } from '@/types' +import { ImageUp, ListTodo, LoaderCircle, Settings, Smile } from 'lucide-react' import { Event, kinds } from 'nostr-tools' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import EmojiPickerDialog from '../EmojiPickerDialog' import Mentions from './Mentions' +import PollEditor from './PollEditor' import { usePostEditor } from './PostEditorProvider' import PostOptions from './PostOptions' import PostTextarea, { TPostTextareaHandle } from './PostTextarea' @@ -28,7 +34,7 @@ export default function PostContent({ close: () => void }) { const { t } = useTranslation() - const { publish, checkLogin } = useNostr() + const { pubkey, publish, checkLogin } = useNostr() const { uploadingFiles, setUploadingFiles } = usePostEditor() const [text, setText] = useState('') const textareaRef = useRef(null) @@ -38,7 +44,63 @@ export default function PostContent({ const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState(undefined) const [mentions, setMentions] = useState([]) const [isNsfw, setIsNsfw] = useState(false) - const canPost = !!text && !posting && !uploadingFiles + const [isPoll, setIsPoll] = useState(false) + const [pollCreateData, setPollCreateData] = useState({ + isMultipleChoice: false, + options: ['', ''], + endsAt: undefined, + relays: [] + }) + const isFirstRender = useRef(true) + const canPost = + !!pubkey && + !!text && + !posting && + !uploadingFiles && + (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + const cachedSettings = postEditorCache.getPostSettingsCache({ + defaultContent, + parentEvent + }) + if (cachedSettings) { + setIsNsfw(cachedSettings.isNsfw ?? false) + setIsPoll(cachedSettings.isPoll ?? false) + setPollCreateData( + cachedSettings.pollCreateData ?? { + isMultipleChoice: false, + options: ['', ''], + endsAt: undefined, + relays: [] + } + ) + setSpecifiedRelayUrls(cachedSettings.specifiedRelayUrls) + setAddClientTag(cachedSettings.addClientTag ?? false) + } + return + } + postEditorCache.setPostSettingsCache( + { defaultContent, parentEvent }, + { + isNsfw, + isPoll, + pollCreateData, + specifiedRelayUrls, + addClientTag + } + ) + }, [ + defaultContent, + parentEvent, + isNsfw, + isPoll, + pollCreateData, + specifiedRelayUrls, + addClientTag + ]) const post = async (e?: React.MouseEvent) => { e?.stopPropagation() @@ -54,14 +116,23 @@ export default function PostContent({ protectedEvent: !!specifiedRelayUrls, isNsfw }) - : await createShortTextNoteDraftEvent(text, mentions, { - parentEvent, - addClientTag, - protectedEvent: !!specifiedRelayUrls, - isNsfw - }) - await publish(draftEvent, { specifiedRelayUrls }) - postContentCache.clearPostCache({ defaultContent, parentEvent }) + : isPoll + ? await createPollDraftEvent(pubkey, text, mentions, pollCreateData, { + addClientTag, + isNsfw + }) + : await createShortTextNoteDraftEvent(text, mentions, { + parentEvent, + addClientTag, + protectedEvent: !!specifiedRelayUrls, + isNsfw + }) + + await publish(draftEvent, { + specifiedRelayUrls, + additionalRelayUrls: isPoll ? pollCreateData.relays : [] + }) + postEditorCache.clearPostCache({ defaultContent, parentEvent }) close() } catch (error) { if (error instanceof AggregateError) { @@ -78,6 +149,12 @@ export default function PostContent({ }) } + const handlePollToggle = () => { + if (parentEvent) return + + setIsPoll((prev) => !prev) + } + return (
{parentEvent && ( @@ -94,12 +171,22 @@ export default function PostContent({ defaultContent={defaultContent} parentEvent={parentEvent} onSubmit={() => post()} + className={isPoll ? 'h-20' : 'min-h-52'} /> - + {isPoll && ( + + )} + {!isPoll && ( + + )}
)} + {!parentEvent && ( + + )}