feat: polls (#451)

Co-authored-by: silberengel <silberengel7@protonmail.com>
This commit is contained in:
Cody Tseng
2025-07-27 12:05:50 +08:00
committed by GitHub
parent 636ceacdad
commit b35e0cf850
35 changed files with 1240 additions and 130 deletions

View File

@@ -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<string[]>([])
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 (
<div className={className}>
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
{poll.pollType === POLL_TYPE.MULTIPLE_CHOICE && (
<p>{t('Multiple choice (select one or more)')}</p>
)}
</div>
{/* Poll Options */}
<div className="grid gap-2">
{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 (
<button
key={option.id}
title={option.label}
className={cn(
'relative w-full px-4 py-3 rounded-lg border transition-all flex items-center gap-2',
canVote ? 'cursor-pointer' : 'cursor-not-allowed',
canVote &&
(selectedOptionIds.includes(option.id)
? 'border-primary bg-primary/20'
: 'hover:border-primary/40 hover:bg-primary/5')
)}
onClick={(e) => {
e.stopPropagation()
handleOptionClick(option.id)
}}
disabled={!canVote}
>
{/* Content */}
<div className="flex items-center gap-2 flex-1 w-0 z-10">
<div className={cn('line-clamp-2 text-left', isMax ? 'font-semibold' : '')}>
{option.label}
</div>
{votedOptionIds.includes(option.id) && (
<CheckCircle2 className="size-4 shrink-0" />
)}
</div>
{!!pollResults && (
<div
className={cn(
'text-muted-foreground shrink-0 z-10',
isMax ? 'font-semibold text-foreground' : ''
)}
>
{percentage.toFixed(1)}%
</div>
)}
{/* Progress Bar Background */}
<div
className={cn(
'absolute inset-0 rounded-md transition-all duration-700 ease-out',
isMax ? 'bg-primary/60' : 'bg-muted/90'
)}
style={{ width: `${percentage}%` }}
/>
</button>
)
})}
</div>
{/* Results Summary */}
<div className="text-sm text-muted-foreground">
{!!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()
}))}
</div>
{(canVote || !pollResults) && (
<div className="flex items-center justify-between gap-2">
{/* Vote Button */}
{canVote && (
<Button
onClick={(e) => {
e.stopPropagation()
if (selectedOptionIds.length === 0) return
handleVote()
}}
disabled={!selectedOptionIds.length || isVoting}
className="flex-1"
>
{isVoting && <Loader2 className="animate-spin" />}
{t('Vote')}
</Button>
)}
{!pollResults && (
<Button
variant="secondary"
onClick={(e) => {
e.stopPropagation()
fetchResults()
}}
disabled={isLoadingResults}
>
{isLoadingResults && <Loader2 className="animate-spin" />}
{t('Load results')}
</Button>
)}
</div>
)}
</div>
</div>
)
}
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
}

View File

@@ -3,7 +3,7 @@ import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect' 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() const { t } = useTranslation()
return ( return (

View File

@@ -29,7 +29,8 @@ import LiveEvent from './LiveEvent'
import LongFormArticle from './LongFormArticle' import LongFormArticle from './LongFormArticle'
import MutedNote from './MutedNote' import MutedNote from './MutedNote'
import NsfwNote from './NsfwNote' import NsfwNote from './NsfwNote'
import { UnknownNote } from './UnknownNote' import Poll from './Poll'
import UnknownNote from './UnknownNote'
export default function Note({ export default function Note({
event, event,
@@ -69,7 +70,8 @@ export default function Note({
kinds.CommunityDefinition, kinds.CommunityDefinition,
ExtendedKind.GROUP_METADATA, ExtendedKind.GROUP_METADATA,
ExtendedKind.PICTURE, ExtendedKind.PICTURE,
ExtendedKind.COMMENT ExtendedKind.COMMENT,
ExtendedKind.POLL
].includes(event.kind) ].includes(event.kind)
) { ) {
content = <UnknownNote className="mt-2" event={event} /> content = <UnknownNote className="mt-2" event={event} />
@@ -87,6 +89,13 @@ export default function Note({
content = <GroupMetadata className="mt-2" event={event} originalNoteId={originalNoteId} /> content = <GroupMetadata className="mt-2" event={event} originalNoteId={originalNoteId} />
} else if (event.kind === kinds.CommunityDefinition) { } else if (event.kind === kinds.CommunityDefinition) {
content = <CommunityDefinition className="mt-2" event={event} /> content = <CommunityDefinition className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.POLL) {
content = (
<>
<Content className="mt-2" event={event} />
<Poll className="mt-2" event={event} />
</>
)
} else { } else {
content = <Content className="mt-2" event={event} /> content = <Content className="mt-2" event={event} />
} }

View File

@@ -24,6 +24,14 @@ import Tabs from '../Tabs'
const LIMIT = 100 const LIMIT = 100
const ALGO_LIMIT = 500 const ALGO_LIMIT = 500
const SHOW_COUNT = 10 const SHOW_COUNT = 10
const KINDS = [
kinds.ShortTextNote,
kinds.Repost,
kinds.Highlights,
kinds.LongFormArticle,
ExtendedKind.COMMENT,
ExtendedKind.POLL
]
export default function NoteList({ export default function NoteList({
relayUrls = [], relayUrls = [],
@@ -115,13 +123,7 @@ export default function NoteList({
subRequests.push({ subRequests.push({
urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5), urls: myRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
filter: { filter: {
kinds: [ kinds: KINDS,
kinds.ShortTextNote,
kinds.Repost,
kinds.Highlights,
ExtendedKind.COMMENT,
kinds.LongFormArticle
],
authors: [pubkey], authors: [pubkey],
'#p': [author], '#p': [author],
limit: LIMIT limit: LIMIT
@@ -130,13 +132,7 @@ export default function NoteList({
subRequests.push({ subRequests.push({
urls: targetRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5), urls: targetRelayList.write.concat(BIG_RELAY_URLS).slice(0, 5),
filter: { filter: {
kinds: [ kinds: KINDS,
kinds.ShortTextNote,
kinds.Repost,
kinds.Highlights,
ExtendedKind.COMMENT,
kinds.LongFormArticle
],
authors: [author], authors: [author],
'#p': [pubkey], '#p': [pubkey],
limit: LIMIT limit: LIMIT
@@ -149,16 +145,7 @@ export default function NoteList({
} }
const _filter = { const _filter = {
...filter, ...filter,
kinds: kinds: filterType === 'pictures' ? [ExtendedKind.PICTURE] : KINDS,
filterType === 'pictures'
? [ExtendedKind.PICTURE]
: [
kinds.ShortTextNote,
kinds.Repost,
kinds.Highlights,
ExtendedKind.COMMENT,
kinds.LongFormArticle
],
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
} }
if (relayUrls.length === 0 && (_filter.authors?.length || author)) { if (relayUrls.length === 0 && (_filter.authors?.length || author)) {

View File

@@ -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<SetStateAction<TPollCreateData>>
setIsPoll: Dispatch<SetStateAction<boolean>>
}) {
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 (
<div className="space-y-4 border rounded-lg p-3">
<div className="space-y-2">
{options.map((option, index) => (
<div key={index} className="flex gap-2">
<Input
value={option}
onChange={(e) => handleOptionChange(index, e.target.value)}
placeholder={t('Option {{number}}', { number: index + 1 })}
/>
<Button
type="button"
variant="ghost-destructive"
size="icon"
onClick={() => handleRemoveOption(index)}
disabled={options.length <= 2}
>
<X />
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={handleAddOption}>
{t('Add Option')}
</Button>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="multiple-choice">{t('Allow multiple choices')}</Label>
<Switch
id="multiple-choice"
checked={isMultipleChoice}
onCheckedChange={setIsMultipleChoice}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ends-at">{t('End Date (optional)')}</Label>
<div className="flex items-center gap-2">
<Input
id="ends-at"
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
/>
<Button
type="button"
variant="ghost-destructive"
size="icon"
onClick={() => setEndsAt('')}
disabled={!endsAt}
title={t('Clear end date')}
>
<Eraser />
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="relay-urls">{t('Relay URLs (optional, comma-separated)')}</Label>
<Input
id="relay-urls"
value={relayUrls}
onChange={(e) => setRelayUrls(e.target.value)}
placeholder="wss://relay1.com, wss://relay2.com"
/>
</div>
<div className="grid gap-2">
<div className="p-3 rounded-lg text-sm bg-destructive [&_svg]:size-4">
<div className="flex items-center gap-2">
<AlertCircle />
<div className="font-medium">{t('This is a poll note.')}</div>
</div>
<div className="pl-6">
{t(
'Unlike regular notes, polls are not widely supported and may not display on other clients.'
)}
</div>
</div>
<Button variant="ghost-destructive" className="w-full" onClick={() => setIsPoll(false)}>
{t('Remove poll')}
</Button>
</div>
</div>
)
}

View File

@@ -1,17 +1,23 @@
import Note from '@/components/Note' import Note from '@/components/Note'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' 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 { isTouchDevice } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import postContentCache from '@/services/post-content-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import { ImageUp, LoaderCircle, Settings, Smile } from 'lucide-react' import { TPollCreateData } from '@/types'
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import EmojiPickerDialog from '../EmojiPickerDialog' import EmojiPickerDialog from '../EmojiPickerDialog'
import Mentions from './Mentions' import Mentions from './Mentions'
import PollEditor from './PollEditor'
import { usePostEditor } from './PostEditorProvider' import { usePostEditor } from './PostEditorProvider'
import PostOptions from './PostOptions' import PostOptions from './PostOptions'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
@@ -28,7 +34,7 @@ export default function PostContent({
close: () => void close: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { uploadingFiles, setUploadingFiles } = usePostEditor() const { uploadingFiles, setUploadingFiles } = usePostEditor()
const [text, setText] = useState('') const [text, setText] = useState('')
const textareaRef = useRef<TPostTextareaHandle>(null) const textareaRef = useRef<TPostTextareaHandle>(null)
@@ -38,7 +44,63 @@ export default function PostContent({
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined) const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
const [mentions, setMentions] = useState<string[]>([]) const [mentions, setMentions] = useState<string[]>([])
const [isNsfw, setIsNsfw] = useState(false) const [isNsfw, setIsNsfw] = useState(false)
const canPost = !!text && !posting && !uploadingFiles const [isPoll, setIsPoll] = useState(false)
const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({
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) => { const post = async (e?: React.MouseEvent) => {
e?.stopPropagation() e?.stopPropagation()
@@ -54,14 +116,23 @@ export default function PostContent({
protectedEvent: !!specifiedRelayUrls, protectedEvent: !!specifiedRelayUrls,
isNsfw isNsfw
}) })
: isPoll
? await createPollDraftEvent(pubkey, text, mentions, pollCreateData, {
addClientTag,
isNsfw
})
: await createShortTextNoteDraftEvent(text, mentions, { : await createShortTextNoteDraftEvent(text, mentions, {
parentEvent, parentEvent,
addClientTag, addClientTag,
protectedEvent: !!specifiedRelayUrls, protectedEvent: !!specifiedRelayUrls,
isNsfw isNsfw
}) })
await publish(draftEvent, { specifiedRelayUrls })
postContentCache.clearPostCache({ defaultContent, parentEvent }) await publish(draftEvent, {
specifiedRelayUrls,
additionalRelayUrls: isPoll ? pollCreateData.relays : []
})
postEditorCache.clearPostCache({ defaultContent, parentEvent })
close() close()
} catch (error) { } catch (error) {
if (error instanceof AggregateError) { if (error instanceof AggregateError) {
@@ -78,6 +149,12 @@ export default function PostContent({
}) })
} }
const handlePollToggle = () => {
if (parentEvent) return
setIsPoll((prev) => !prev)
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{parentEvent && ( {parentEvent && (
@@ -94,12 +171,22 @@ export default function PostContent({
defaultContent={defaultContent} defaultContent={defaultContent}
parentEvent={parentEvent} parentEvent={parentEvent}
onSubmit={() => post()} onSubmit={() => post()}
className={isPoll ? 'h-20' : 'min-h-52'}
/> />
{isPoll && (
<PollEditor
pollCreateData={pollCreateData}
setPollCreateData={setPollCreateData}
setIsPoll={setIsPoll}
/>
)}
{!isPoll && (
<SendOnlyToSwitch <SendOnlyToSwitch
parentEvent={parentEvent} parentEvent={parentEvent}
specifiedRelayUrls={specifiedRelayUrls} specifiedRelayUrls={specifiedRelayUrls}
setSpecifiedRelayUrls={setSpecifiedRelayUrls} setSpecifiedRelayUrls={setSpecifiedRelayUrls}
/> />
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Uploader <Uploader
@@ -125,6 +212,17 @@ export default function PostContent({
</Button> </Button>
</EmojiPickerDialog> </EmojiPickerDialog>
)} )}
{!parentEvent && (
<Button
variant="ghost"
size="icon"
title={t('Create Poll')}
className={isPoll ? 'bg-accent' : ''}
onClick={handlePollToggle}
>
<ListTodo />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -1,10 +1,11 @@
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import { cn } from '@/lib/utils'
import Content from '../../Content' import Content from '../../Content'
export default function Preview({ content }: { content: string }) { export default function Preview({ content, className }: { content: string; className?: string }) {
return ( return (
<Card className="p-3 min-h-52"> <Card className={cn('p-3', className)}>
<Content event={createFakeEvent({ content })} className="pointer-events-none h-full" /> <Content event={createFakeEvent({ content })} className="pointer-events-none h-full" />
</Card> </Card>
) )

View File

@@ -1,6 +1,7 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { parseEditorJsonToText } from '@/lib/tiptap' import { parseEditorJsonToText } from '@/lib/tiptap'
import postContentCache from '@/services/post-content-cache.service' import { cn } from '@/lib/utils'
import postEditorCache from '@/services/post-editor-cache.service'
import Document from '@tiptap/extension-document' import Document from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break' import { HardBreak } from '@tiptap/extension-hard-break'
import History from '@tiptap/extension-history' import History from '@tiptap/extension-history'
@@ -31,8 +32,9 @@ const PostTextarea = forwardRef<
defaultContent?: string defaultContent?: string
parentEvent?: Event parentEvent?: Event
onSubmit?: () => void onSubmit?: () => void
className?: string
} }
>(({ text = '', setText, defaultContent, parentEvent, onSubmit }, ref) => { >(({ text = '', setText, defaultContent, parentEvent, onSubmit, className }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { setUploadingFiles } = usePostEditor() const { setUploadingFiles } = usePostEditor()
const editor = useEditor({ const editor = useEditor({
@@ -56,8 +58,10 @@ const PostTextarea = forwardRef<
], ],
editorProps: { editorProps: {
attributes: { attributes: {
class: class: cn(
'border rounded-lg p-3 min-h-52 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' 'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
className
)
}, },
handleKeyDown: (_view, event) => { handleKeyDown: (_view, event) => {
// Handle Ctrl+Enter or Cmd+Enter for submit // Handle Ctrl+Enter or Cmd+Enter for submit
@@ -69,10 +73,10 @@ const PostTextarea = forwardRef<
return false return false
} }
}, },
content: postContentCache.getPostCache({ defaultContent, parentEvent }), content: postEditorCache.getPostContentCache({ defaultContent, parentEvent }),
onUpdate(props) { onUpdate(props) {
setText(parseEditorJsonToText(props.editor.getJSON())) setText(parseEditorJsonToText(props.editor.getJSON()))
postContentCache.setPostCache({ defaultContent, parentEvent }, props.editor.getJSON()) postEditorCache.setPostContentCache({ defaultContent, parentEvent }, props.editor.getJSON())
}, },
onCreate(props) { onCreate(props) {
setText(parseEditorJsonToText(props.editor.getJSON())) setText(parseEditorJsonToText(props.editor.getJSON()))
@@ -122,7 +126,7 @@ const PostTextarea = forwardRef<
<EditorContent className="tiptap" editor={editor} /> <EditorContent className="tiptap" editor={editor} />
</TabsContent> </TabsContent>
<TabsContent value="preview"> <TabsContent value="preview">
<Preview content={text} /> <Preview content={text} className={className} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) )

View File

@@ -16,7 +16,7 @@ const buttonVariants = cva(
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-primary', 'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-primary',
ghost: 'clickable hover:text-accent-foreground', ghost: 'clickable hover:text-accent-foreground',
'ghost-destructive': 'cursor-pointer hover:bg-destructive/10 text-destructive', 'ghost-destructive': 'cursor-pointer hover:bg-destructive/20 text-destructive',
link: 'text-primary underline-offset-4 hover:underline' link: 'text-primary underline-offset-4 hover:underline'
}, },
size: { size: {

View File

@@ -62,6 +62,8 @@ export const GROUP_METADATA_EVENT_KIND = 39000
export const ExtendedKind = { export const ExtendedKind = {
PICTURE: 20, PICTURE: 20,
POLL: 1068,
POLL_RESPONSE: 1018,
COMMENT: 1111, COMMENT: 1111,
FAVORITE_RELAYS: 10012, FAVORITE_RELAYS: 10012,
BLOSSOM_SERVER_LIST: 10063, BLOSSOM_SERVER_LIST: 10063,
@@ -105,3 +107,8 @@ export const DEFAULT_NOSTRCONNECT_RELAY = [
'wss://nos.lol/', 'wss://nos.lol/',
'wss://relay.primal.net' 'wss://relay.primal.net'
] ]
export const POLL_TYPE = {
MULTIPLE_CHOICE: 'multiplechoice',
SINGLE_CHOICE: 'singlechoice'
} as const

View File

@@ -0,0 +1,9 @@
import pollResults from '@/services/poll-results.service'
import { useSyncExternalStore } from 'react'
export function useFetchPollResults(pollEventId: string) {
return useSyncExternalStore(
(cb) => pollResults.subscribePollResults(pollEventId, cb),
() => pollResults.getPollResults(pollEventId)
)
}

View File

@@ -293,6 +293,23 @@ export default {
'تحتاج إلى إضافة خادم Blossom واحد على الأقل لتحميل ملفات الوسائط.', 'تحتاج إلى إضافة خادم Blossom واحد على الأقل لتحميل ملفات الوسائط.',
'Recommended blossom servers': 'خوادم Blossom الموصى بها', 'Recommended blossom servers': 'خوادم Blossom الموصى بها',
'Enter Blossom server URL': 'أدخل عنوان خادم Blossom URL', 'Enter Blossom server URL': 'أدخل عنوان خادم Blossom URL',
Preferred: 'المفضل' Preferred: 'المفضل',
'Multiple choice (select one or more)': 'اختيار متعدد (اختر واحداً أو أكثر)',
Vote: 'صوت',
'{{number}} votes': '{{number}} أصوات',
'Total votes': 'إجمالي الأصوات',
'Poll has ended': 'انتهى الاستطلاع',
'Poll ends at {{time}}': 'ينتهي الاستطلاع في {{time}}',
'Load results': 'تحميل النتائج',
'This is a poll note.': 'هذه ملاحظة استطلاع.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'على عكس الملاحظات العادية، الاستطلاعات غير مدعومة على نطاق واسع وقد لا تظهر في العملاء الآخرين.',
'Option {{number}}': 'الخيار {{number}}',
'Add Option': 'إضافة خيار',
'Allow multiple choices': 'السماح بخيارات متعددة',
'End Date (optional)': 'تاريخ الانتهاء (اختياري)',
'Clear end date': 'مسح تاريخ الانتهاء',
'Relay URLs (optional, comma-separated)': 'عناوين المرحلات (اختياري، مفصولة بفواصل)',
'Remove poll': 'إزالة الاستطلاع'
} }
} }

View File

@@ -300,6 +300,23 @@ export default {
'Du musst mindestens einen Blossom-Server hinzufügen, um Mediendateien hochladen zu können.', 'Du musst mindestens einen Blossom-Server hinzufügen, um Mediendateien hochladen zu können.',
'Recommended blossom servers': 'Empfohlene Blossom-Server', 'Recommended blossom servers': 'Empfohlene Blossom-Server',
'Enter Blossom server URL': 'Blossom-Server-URL eingeben', 'Enter Blossom server URL': 'Blossom-Server-URL eingeben',
Preferred: 'Bevorzugt' Preferred: 'Bevorzugt',
'Multiple choice (select one or more)': 'Mehrfachauswahl (eine oder mehrere auswählen)',
Vote: 'Abstimmen',
'{{number}} votes': '{{number}} Stimmen',
'Total votes': 'Gesamtstimmen',
'Poll has ended': 'Umfrage beendet',
'Poll ends at {{time}}': 'Umfrage endet am {{time}}',
'Load results': 'Ergebnisse laden',
'This is a poll note.': 'Dies ist eine Umfrage-Notiz.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'Im Gegensatz zu regulären Notizen werden Umfragen nicht weit verbreitet unterstützt und werden möglicherweise nicht in anderen Clients angezeigt.',
'Option {{number}}': 'Option {{number}}',
'Add Option': 'Option hinzufügen',
'Allow multiple choices': 'Mehrfachauswahl erlauben',
'End Date (optional)': 'Enddatum (optional)',
'Clear end date': 'Enddatum löschen',
'Relay URLs (optional, comma-separated)': 'Relay-URLs (optional, durch Kommas getrennt)',
'Remove poll': 'Umfrage entfernen'
} }
} }

View File

@@ -293,6 +293,23 @@ export default {
'You need to add at least one blossom server in order to upload media files.', 'You need to add at least one blossom server in order to upload media files.',
'Recommended blossom servers': 'Recommended blossom servers', 'Recommended blossom servers': 'Recommended blossom servers',
'Enter Blossom server URL': 'Enter Blossom server URL', 'Enter Blossom server URL': 'Enter Blossom server URL',
Preferred: 'Preferred' Preferred: 'Preferred',
'Multiple choice (select one or more)': 'Multiple choice (select one or more)',
Vote: 'Vote',
'{{number}} votes': '{{number}} votes',
'Total votes': 'Total votes',
'Poll has ended': 'Poll has ended',
'Poll ends at {{time}}': 'Poll ends at {{time}}',
'Load results': 'Load results',
'This is a poll note.': 'This is a poll note.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'Unlike regular notes, polls are not widely supported and may not display on other clients.',
'Option {{number}}': 'Option {{number}}',
'Add Option': 'Add Option',
'Allow multiple choices': 'Allow multiple choices',
'End Date (optional)': 'End Date (optional)',
'Clear end date': 'Clear end date',
'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)',
'Remove poll': 'Remove poll'
} }
} }

View File

@@ -298,6 +298,23 @@ export default {
'Necesitas agregar al menos un servidor Blossom para poder cargar archivos multimedia.', 'Necesitas agregar al menos un servidor Blossom para poder cargar archivos multimedia.',
'Recommended blossom servers': 'Servidores Blossom recomendados', 'Recommended blossom servers': 'Servidores Blossom recomendados',
'Enter Blossom server URL': 'Ingresar URL del servidor Blossom', 'Enter Blossom server URL': 'Ingresar URL del servidor Blossom',
Preferred: 'Preferido' Preferred: 'Preferido',
'Multiple choice (select one or more)': 'Opción múltiple (selecciona una o más)',
Vote: 'Votar',
'{{number}} votes': '{{number}} votos',
'Total votes': 'Total de votos',
'Poll has ended': 'La encuesta ha terminado',
'Poll ends at {{time}}': 'La encuesta termina el {{time}}',
'Load results': 'Cargar resultados',
'This is a poll note.': 'Esta es una nota de encuesta.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'A diferencia de las notas regulares, las encuestas no son ampliamente compatibles y pueden no mostrarse en otros clientes.',
'Option {{number}}': 'Opción {{number}}',
'Add Option': 'Agregar Opción',
'Allow multiple choices': 'Permitir múltiples opciones',
'End Date (optional)': 'Fecha de finalización (opcional)',
'Clear end date': 'Borrar fecha de finalización',
'Relay URLs (optional, comma-separated)': 'URLs de relé (opcional, separadas por comas)',
'Remove poll': 'Eliminar encuesta'
} }
} }

View File

@@ -295,6 +295,23 @@ export default {
'برای آپلود فایل‌های رسانه نیاز دارید حداقل یک سرور blossom اضافه کنید.', 'برای آپلود فایل‌های رسانه نیاز دارید حداقل یک سرور blossom اضافه کنید.',
'Recommended blossom servers': 'سرورهای blossom توصیه شده', 'Recommended blossom servers': 'سرورهای blossom توصیه شده',
'Enter Blossom server URL': 'آدرس سرور Blossom را وارد کنید', 'Enter Blossom server URL': 'آدرس سرور Blossom را وارد کنید',
Preferred: 'ترجیحی' Preferred: 'ترجیحی',
'Multiple choice (select one or more)': 'چند گزینه‌ای (یک یا چند انتخاب کنید)',
Vote: 'رای دادن',
'{{number}} votes': '{{number}} رای',
'Total votes': 'کل آرا',
'Poll has ended': 'نظرسنجی پایان یافته',
'Poll ends at {{time}}': 'نظرسنجی در {{time}} پایان می‌یابد',
'Load results': 'بارگیری نتایج',
'This is a poll note.': 'این یک یادداشت نظرسنجی است.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'برخلاف یادداشت‌های معمولی، نظرسنجی‌ها به طور گسترده پشتیبانی نمی‌شوند و ممکن است در کلاینت‌های دیگر نمایش داده نشوند.',
'Option {{number}}': 'گزینه {{number}}',
'Add Option': 'افزودن گزینه',
'Allow multiple choices': 'اجازه انتخاب‌های متعدد',
'End Date (optional)': 'تاریخ پایان (اختیاری)',
'Clear end date': 'پاک کردن تاریخ پایان',
'Relay URLs (optional, comma-separated)': 'آدرس‌های رله (اختیاری، جدا شده با کاما)',
'Remove poll': 'حذف نظرسنجی'
} }
} }

View File

@@ -298,6 +298,24 @@ export default {
'Vous devez ajouter au moins un serveur Blossom pour pouvoir télécharger des fichiers multimédias.', 'Vous devez ajouter au moins un serveur Blossom pour pouvoir télécharger des fichiers multimédias.',
'Recommended blossom servers': 'Serveurs Blossom recommandés', 'Recommended blossom servers': 'Serveurs Blossom recommandés',
'Enter Blossom server URL': 'Entrer lURL du serveur Blossom', 'Enter Blossom server URL': 'Entrer lURL du serveur Blossom',
Preferred: 'Préféré' Preferred: 'Préféré',
'Multiple choice (select one or more)': 'Choix multiple (sélectionnez un ou plusieurs)',
Vote: 'Voter',
'{{number}} votes': '{{number}} votes',
'Total votes': 'Total des votes',
'Poll has ended': 'Le sondage est terminé',
'Poll ends at {{time}}': 'Le sondage se termine le {{time}}',
'Load results': 'Charger les résultats',
'This is a poll note.': 'Ceci est une note de sondage.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
"Contrairement aux notes régulières, les sondages ne sont pas largement pris en charge et peuvent ne pas s'afficher sur d'autres clients.",
'Option {{number}}': 'Option {{number}}',
'Add Option': 'Ajouter une option',
'Allow multiple choices': 'Autoriser les choix multiples',
'End Date (optional)': 'Date de fin (optionnel)',
'Clear end date': 'Effacer la date de fin',
'Relay URLs (optional, comma-separated)':
'URLs de relais (optionnel, séparées par des virgules)',
'Remove poll': 'Supprimer le sondage'
} }
} }

View File

@@ -297,6 +297,23 @@ export default {
'È necessario aggiungere almeno un server Blossom per caricare file multimediali.', 'È necessario aggiungere almeno un server Blossom per caricare file multimediali.',
'Recommended blossom servers': 'Server Blossom consigliati', 'Recommended blossom servers': 'Server Blossom consigliati',
'Enter Blossom server URL': 'Inserisci URL del server Blossom', 'Enter Blossom server URL': 'Inserisci URL del server Blossom',
Preferred: 'Preferito' Preferred: 'Preferito',
'Multiple choice (select one or more)': 'Scelta multipla (seleziona uno o più)',
Vote: 'Vota',
'{{number}} votes': '{{number}} voti',
'Total votes': 'Voti totali',
'Poll has ended': 'Il sondaggio è terminato',
'Poll ends at {{time}}': 'Il sondaggio termina alle {{time}}',
'Load results': 'Carica risultati',
'This is a poll note.': 'Questa è una nota sondaggio.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'A differenza delle note regolari, i sondaggi non sono ampiamente supportati e potrebbero non essere visualizzati su altri client.',
'Option {{number}}': 'Opzione {{number}}',
'Add Option': 'Aggiungi Opzione',
'Allow multiple choices': 'Consenti scelte multiple',
'End Date (optional)': 'Data di fine (opzionale)',
'Clear end date': 'Cancella data di fine',
'Relay URLs (optional, comma-separated)': 'URL relay (opzionale, separati da virgole)',
'Remove poll': 'Rimuovi sondaggio'
} }
} }

View File

@@ -295,6 +295,23 @@ export default {
'メディアファイルをアップロードするには、少なくとも1つのBlossomサーバーを追加する必要があります。', 'メディアファイルをアップロードするには、少なくとも1つのBlossomサーバーを追加する必要があります。',
'Recommended blossom servers': 'おすすめのBlossomサーバー', 'Recommended blossom servers': 'おすすめのBlossomサーバー',
'Enter Blossom server URL': 'BlossomサーバーURLを入力', 'Enter Blossom server URL': 'BlossomサーバーURLを入力',
Preferred: '優先' Preferred: '優先',
'Multiple choice (select one or more)': '複数選択1つ以上選択',
Vote: '投票',
'{{number}} votes': '{{number}} 票',
'Total votes': '総票数',
'Poll has ended': '投票終了',
'Poll ends at {{time}}': '投票終了時刻:{{time}}',
'Load results': '結果を読み込み',
'This is a poll note.': 'これは投票ノートです。',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'通常のノートとは異なり、投票は広くサポートされておらず、他のクライアントでは表示されない場合があります。',
'Option {{number}}': '選択肢 {{number}}',
'Add Option': '選択肢を追加',
'Allow multiple choices': '複数選択を許可',
'End Date (optional)': '終了日(任意)',
'Clear end date': '終了日をクリア',
'Relay URLs (optional, comma-separated)': 'リレーURL任意、カンマ区切り',
'Remove poll': '投票を削除'
} }
} }

View File

@@ -295,6 +295,23 @@ export default {
'미디어 파일을 업로드하려면 최소한 하나의 Blossom 서버를 추가해야 합니다.', '미디어 파일을 업로드하려면 최소한 하나의 Blossom 서버를 추가해야 합니다.',
'Recommended blossom servers': '추천 Blossom 서버', 'Recommended blossom servers': '추천 Blossom 서버',
'Enter Blossom server URL': 'Blossom 서버 URL 입력', 'Enter Blossom server URL': 'Blossom 서버 URL 입력',
Preferred: '선호' Preferred: '선호',
'Multiple choice (select one or more)': '다중 선택 (하나 이상 선택)',
Vote: '투표',
'{{number}} votes': '{{number}} 표',
'Total votes': '총 투표수',
'Poll has ended': '투표 종료',
'Poll ends at {{time}}': '투표 종료 시간: {{time}}',
'Load results': '결과 로드',
'This is a poll note.': '이것은 투표 노트입니다.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'일반 노트와 달리 투표는 널리 지원되지 않으며 다른 클라이언트에서 표시되지 않을 수 있습니다.',
'Option {{number}}': '옵션 {{number}}',
'Add Option': '옵션 추가',
'Allow multiple choices': '다중 선택 허용',
'End Date (optional)': '종료 날짜 (선택사항)',
'Clear end date': '종료 날짜 지우기',
'Relay URLs (optional, comma-separated)': '릴레이 URL (선택사항, 쉼표로 구분)',
'Remove poll': '투표 제거'
} }
} }

View File

@@ -296,6 +296,24 @@ export default {
'Musisz dodać przynajmniej jeden serwer Blossom, aby móc przesyłać pliki multimedialne.', 'Musisz dodać przynajmniej jeden serwer Blossom, aby móc przesyłać pliki multimedialne.',
'Recommended blossom servers': 'Zalecane serwery Blossom', 'Recommended blossom servers': 'Zalecane serwery Blossom',
'Enter Blossom server URL': 'Wprowadź adres URL serwera Blossom', 'Enter Blossom server URL': 'Wprowadź adres URL serwera Blossom',
Preferred: 'Preferowany' Preferred: 'Preferowany',
'Multiple choice (select one or more)': 'Wielokrotny wybór (wybierz jeden lub więcej)',
Vote: 'Głosuj',
'{{number}} votes': '{{number}} głosów',
'Total votes': 'Łączna liczba głosów',
'Poll has ended': 'Ankieta zakończona',
'Poll ends at {{time}}': 'Ankieta kończy się o {{time}}',
'Load results': 'Załaduj wyniki',
'This is a poll note.': 'To jest notatka ankiety.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'W przeciwieństwie do zwykłych notatek, ankiety nie są szeroko obsługiwane i mogą nie wyświetlać się w innych klientach.',
'Option {{number}}': 'Opcja {{number}}',
'Add Option': 'Dodaj opcję',
'Allow multiple choices': 'Zezwól na wielokrotny wybór',
'End Date (optional)': 'Data zakończenia (opcjonalna)',
'Clear end date': 'Wyczyść datę zakończenia',
'Relay URLs (optional, comma-separated)':
'Adresy URL przekaźników (opcjonalne, oddzielone przecinkami)',
'Remove poll': 'Usuń ankietę'
} }
} }

View File

@@ -296,6 +296,23 @@ export default {
'Você precisa adicionar pelo menos um servidor Blossom para poder carregar arquivos de mídia.', 'Você precisa adicionar pelo menos um servidor Blossom para poder carregar arquivos de mídia.',
'Recommended blossom servers': 'Servidores Blossom recomendados', 'Recommended blossom servers': 'Servidores Blossom recomendados',
'Enter Blossom server URL': 'Inserir URL do servidor Blossom', 'Enter Blossom server URL': 'Inserir URL do servidor Blossom',
Preferred: 'Preferido' Preferred: 'Preferido',
'Multiple choice (select one or more)': 'Múltipla escolha (selecione um ou mais)',
Vote: 'Votar',
'{{number}} votes': '{{number}} votos',
'Total votes': 'Total de votos',
'Poll has ended': 'A enquete terminou',
'Poll ends at {{time}}': 'A enquete termina em {{time}}',
'Load results': 'Carregar resultados',
'This is a poll note.': 'Esta é uma nota de enquete.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'Ao contrário das notas regulares, as enquetes não são amplamente suportadas e podem não ser exibidas em outros clientes.',
'Option {{number}}': 'Opção {{number}}',
'Add Option': 'Adicionar Opção',
'Allow multiple choices': 'Permitir múltiplas escolhas',
'End Date (optional)': 'Data de término (opcional)',
'Clear end date': 'Limpar data de término',
'Relay URLs (optional, comma-separated)': 'URLs de relay (opcional, separadas por vírgulas)',
'Remove poll': 'Remover enquete'
} }
} }

View File

@@ -297,6 +297,23 @@ export default {
'Você precisa adicionar pelo menos um servidor Blossom para poder carregar arquivos de mídia.', 'Você precisa adicionar pelo menos um servidor Blossom para poder carregar arquivos de mídia.',
'Recommended blossom servers': 'Servidores Blossom recomendados', 'Recommended blossom servers': 'Servidores Blossom recomendados',
'Enter Blossom server URL': 'Inserir URL do servidor Blossom', 'Enter Blossom server URL': 'Inserir URL do servidor Blossom',
Preferred: 'Preferido' Preferred: 'Preferido',
'Multiple choice (select one or more)': 'Múltipla escolha (selecione um ou mais)',
Vote: 'Votar',
'{{number}} votes': '{{number}} votos',
'Total votes': 'Total de votos',
'Poll has ended': 'A sondagem terminou',
'Poll ends at {{time}}': 'A sondagem termina em {{time}}',
'Load results': 'Carregar resultados',
'This is a poll note.': 'Esta é uma nota de sondagem.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'Ao contrário das notas regulares, as sondagens não são amplamente suportadas e podem não ser exibidas noutros clientes.',
'Option {{number}}': 'Opção {{number}}',
'Add Option': 'Adicionar Opção',
'Allow multiple choices': 'Permitir múltiplas escolhas',
'End Date (optional)': 'Data de fim (opcional)',
'Clear end date': 'Limpar data de fim',
'Relay URLs (optional, comma-separated)': 'URLs de relay (opcional, separadas por vírgulas)',
'Remove poll': 'Remover sondagem'
} }
} }

View File

@@ -298,6 +298,23 @@ export default {
'Вам нужно добавить хотя бы один сервер Blossom, чтобы загружать медиафайлы.', 'Вам нужно добавить хотя бы один сервер Blossom, чтобы загружать медиафайлы.',
'Recommended blossom servers': 'Рекомендуемые серверы Blossom', 'Recommended blossom servers': 'Рекомендуемые серверы Blossom',
'Enter Blossom server URL': 'Введите URL сервера Blossom', 'Enter Blossom server URL': 'Введите URL сервера Blossom',
Preferred: 'Предпочтительный' Preferred: 'Предпочтительный',
'Multiple choice (select one or more)': 'Множественный выбор (выберите один или несколько)',
Vote: 'Голосовать',
'{{number}} votes': '{{number}} голосов',
'Total votes': 'Всего голосов',
'Poll has ended': 'Опрос завершён',
'Poll ends at {{time}}': 'Опрос завершается {{time}}',
'Load results': 'Загрузить результаты',
'This is a poll note.': 'Это заметка с опросом.',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'В отличие от обычных заметок, опросы не получили широкую поддержку и могут не отображаться в других клиентах.',
'Option {{number}}': 'Вариант {{number}}',
'Add Option': 'Добавить вариант',
'Allow multiple choices': 'Разрешить множественный выбор',
'End Date (optional)': 'Дата окончания (необязательно)',
'Clear end date': 'Очистить дату окончания',
'Relay URLs (optional, comma-separated)': 'URL релеев (необязательно, через запятую)',
'Remove poll': 'Удалить опрос'
} }
} }

View File

@@ -292,6 +292,23 @@ export default {
'คุณต้องเพิ่มเซิร์ฟเวอร์ Blossom อย่างน้อยหนึ่งตัวเพื่ออัปโหลดไฟล์สื่อ', 'คุณต้องเพิ่มเซิร์ฟเวอร์ Blossom อย่างน้อยหนึ่งตัวเพื่ออัปโหลดไฟล์สื่อ',
'Recommended blossom servers': 'เซิร์ฟเวอร์ Blossom ที่แนะนำ', 'Recommended blossom servers': 'เซิร์ฟเวอร์ Blossom ที่แนะนำ',
'Enter Blossom server URL': 'ป้อน URL ของเซิร์ฟเวอร์ Blossom', 'Enter Blossom server URL': 'ป้อน URL ของเซิร์ฟเวอร์ Blossom',
Preferred: 'ที่ชื่นชอบ' Preferred: 'ที่ชื่นชอบ',
'Multiple choice (select one or more)': 'ตัวเลือกหลายรายการ (เลือกหนึ่งหรือมากกว่า)',
Vote: 'โหวต',
'{{number}} votes': '{{number}} คะแนน',
'Total votes': 'คะแนนรวม',
'Poll has ended': 'การโพลล์สิ้นสุดแล้ว',
'Poll ends at {{time}}': 'การโพลล์สิ้นสุดเวลา {{time}}',
'Load results': 'โหลดผลลัพธ์',
'This is a poll note.': 'นี่คือโน้ตโพลล์',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'ไม่เหมือนโน้ตธรรมดา โพลล์ไม่ได้รับการสนับสนุนอย่างแพร่หลายและอาจไม่แสดงในไคลเอนต์อื่น',
'Option {{number}}': 'ตัวเลือก {{number}}',
'Add Option': 'เพิ่มตัวเลือก',
'Allow multiple choices': 'อนุญาตให้เลือกหลายรายการ',
'End Date (optional)': 'วันที่สิ้นสุด (ไม่บังคับ)',
'Clear end date': 'ล้างวันที่สิ้นสุด',
'Relay URLs (optional, comma-separated)': 'URL รีเลย์ (ไม่บังคับ, คั่นด้วยจุลภาค)',
'Remove poll': 'ลบโพลล์'
} }
} }

View File

@@ -293,6 +293,23 @@ export default {
'您需要添加至少一个 Blossom 服务器才能上传媒体文件。', '您需要添加至少一个 Blossom 服务器才能上传媒体文件。',
'Recommended blossom servers': '推荐的 Blossom 服务器', 'Recommended blossom servers': '推荐的 Blossom 服务器',
'Enter Blossom server URL': '输入 Blossom 服务器 URL', 'Enter Blossom server URL': '输入 Blossom 服务器 URL',
Preferred: '首选' Preferred: '首选',
'Multiple choice (select one or more)': '多选 (选择一个或多个)',
Vote: '投票',
'{{number}} votes': '{{number}} 次投票',
'Total votes': '总票数',
'Poll has ended': '投票已结束',
'Poll ends at {{time}}': '投票结束时间:{{time}}',
'Load results': '加载结果',
'This is a poll note.': '这是一个投票帖子。',
'Unlike regular notes, polls are not widely supported and may not display on other clients.':
'与普通帖子不同,投票功能暂时没有得到广泛的支持,可能无法在其他客户端中显示。',
'Option {{number}}': '选项 {{number}}',
'Add Option': '添加选项',
'Allow multiple choices': '允许多选',
'End Date (optional)': '结束日期(可选)',
'Clear end date': '清除结束日期',
'Relay URLs (optional, comma-separated)': '中继服务器 URL可选逗号分隔',
'Remove poll': '移除投票'
} }
} }

View File

@@ -124,4 +124,8 @@
--chart-4: 280 65% 60%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%; --chart-5: 340 75% 55%;
} }
.dark input[type='datetime-local']::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.5);
}
} }

View File

@@ -1,7 +1,14 @@
import { ApplicationDataKey, ExtendedKind } from '@/constants' import { ApplicationDataKey, ExtendedKind, POLL_TYPE } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
import { TDraftEvent, TEmoji, TMailboxRelay, TMailboxRelayScope, TRelaySet } from '@/types' import {
TDraftEvent,
TEmoji,
TMailboxRelay,
TMailboxRelayScope,
TPollCreateData,
TRelaySet
} from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
import { import {
@@ -10,6 +17,7 @@ import {
isProtectedEvent, isProtectedEvent,
isReplaceableEvent isReplaceableEvent
} from './event' } from './event'
import { randomString } from './random'
import { generateBech32IdFromETag, tagNameEquals } from './tag' import { generateBech32IdFromETag, tagNameEquals } from './tag'
// https://github.com/nostr-protocol/nips/blob/master/25.md // https://github.com/nostr-protocol/nips/blob/master/25.md
@@ -335,6 +343,93 @@ export function createBlossomServerListDraftEvent(servers: string[]): TDraftEven
} }
} }
const pollDraftEventCache: Map<string, TDraftEvent> = new Map()
export async function createPollDraftEvent(
author: string,
question: string,
mentions: string[],
{ isMultipleChoice, relays, options, endsAt }: TPollCreateData,
{
addClientTag,
isNsfw
}: {
addClientTag?: boolean
isNsfw?: boolean
} = {}
): Promise<TDraftEvent> {
const { quoteEventIds } = await extractRelatedEventIds(question)
const hashtags = extractHashtags(question)
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
// imeta tags
const images = extractImagesFromContent(question)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
// q tags
tags.push(...quoteEventIds.map((eventId) => buildQTag(eventId)))
// p tags
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
const validOptions = options.filter((opt) => opt.trim())
tags.push(...validOptions.map((option) => ['option', randomString(9), option.trim()]))
tags.push(['polltype', isMultipleChoice ? POLL_TYPE.MULTIPLE_CHOICE : POLL_TYPE.SINGLE_CHOICE])
if (endsAt) {
tags.push(['endsAt', endsAt.toString()])
}
if (relays.length) {
relays.forEach((relay) => tags.push(buildRelayTag(relay)))
} else {
const relayList = await client.fetchRelayList(author)
relayList.read.slice(0, 4).forEach((relay) => {
tags.push(buildRelayTag(relay))
})
}
if (addClientTag) {
tags.push(buildClientTag())
}
if (isNsfw) {
tags.push(buildNsfwTag())
}
const baseDraft = {
content: question.trim(),
kind: ExtendedKind.POLL,
tags
}
const cacheKey = JSON.stringify(baseDraft)
const cache = pollDraftEventCache.get(cacheKey)
if (cache) {
return cache
}
const draftEvent = { ...baseDraft, created_at: dayjs().unix() }
pollDraftEventCache.set(cacheKey, draftEvent)
return draftEvent
}
export function createPollResponseDraftEvent(
pollEvent: Event,
selectedOptionIds: string[]
): TDraftEvent {
return {
content: '',
kind: ExtendedKind.POLL_RESPONSE,
tags: [
buildETag(pollEvent.id, pollEvent.pubkey),
...selectedOptionIds.map((optionId) => buildResponseTag(optionId))
],
created_at: dayjs().unix()
}
}
function generateImetaTags(imageUrls: string[]) { function generateImetaTags(imageUrls: string[]) {
return imageUrls return imageUrls
.map((imageUrl) => { .map((imageUrl) => {
@@ -545,6 +640,10 @@ function buildImetaTag(nip94Tags: string[][]) {
return ['imeta', ...nip94Tags.map(([n, v]) => `${n} ${v}`)] return ['imeta', ...nip94Tags.map(([n, v]) => `${n} ${v}`)]
} }
function buildResponseTag(value: string) {
return ['response', value]
}
function buildClientTag() { function buildClientTag() {
return ['client', 'jumble'] return ['client', 'jumble']
} }

View File

@@ -1,5 +1,5 @@
import { BIG_RELAY_URLS } from '@/constants' import { BIG_RELAY_URLS, POLL_TYPE } from '@/constants'
import { TRelayList, TRelaySet } from '@/types' import { TPollType, TRelayList, TRelaySet } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { getReplaceableEventIdentifier } from './event' import { getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
@@ -262,3 +262,76 @@ export function getCommunityDefinitionFromEvent(event: Event) {
return { name, description, image } return { name, description, image }
} }
export function getPollMetadataFromEvent(event: Event) {
const options: { id: string; label: string }[] = []
const relayUrls: string[] = []
let pollType: TPollType = POLL_TYPE.SINGLE_CHOICE
let endsAt: number | undefined
for (const [tagName, ...tagValues] of event.tags) {
if (tagName === 'option' && tagValues.length >= 2) {
const [optionId, label] = tagValues
if (optionId && label) {
options.push({ id: optionId, label })
}
} else if (tagName === 'relay' && tagValues[0]) {
const normalizedUrl = normalizeUrl(tagValues[0])
if (normalizedUrl) relayUrls.push(tagValues[0])
} else if (tagName === 'polltype' && tagValues[0]) {
if (tagValues[0] === POLL_TYPE.MULTIPLE_CHOICE) {
pollType = POLL_TYPE.MULTIPLE_CHOICE
}
} else if (tagName === 'endsAt' && tagValues[0]) {
const timestamp = parseInt(tagValues[0])
if (!isNaN(timestamp)) {
endsAt = timestamp
}
}
}
if (options.length === 0) {
return null
}
return {
options,
pollType,
relayUrls,
endsAt
}
}
export function getPollResponseFromEvent(
event: Event,
optionIds: string[],
isMultipleChoice: boolean
) {
const selectedOptionIds: string[] = []
for (const [tagName, ...tagValues] of event.tags) {
if (tagName === 'response' && tagValues[0]) {
if (optionIds && !optionIds.includes(tagValues[0])) {
continue // Skip if the response is not in the provided optionIds
}
selectedOptionIds.push(tagValues[0])
}
}
// If no valid responses are found, return null
if (selectedOptionIds.length === 0) {
return null
}
// If multiple responses are selected but the poll is not multiple choice, return null
if (selectedOptionIds.length > 1 && !isMultipleChoice) {
return null
}
return {
id: event.id,
pubkey: event.pubkey,
selectedOptionIds,
created_at: event.created_at
}
}

View File

@@ -23,6 +23,11 @@ import { NostrConnectionSigner } from './nostrConnection.signer'
import { NpubSigner } from './npub.signer' import { NpubSigner } from './npub.signer'
import { NsecSigner } from './nsec.signer' import { NsecSigner } from './nsec.signer'
type TPublishOptions = {
specifiedRelayUrls?: string[]
additionalRelayUrls?: string[]
}
type TNostrContext = { type TNostrContext = {
isInitialized: boolean isInitialized: boolean
pubkey: string | null pubkey: string | null
@@ -49,7 +54,7 @@ type TNostrContext = {
/** /**
* Default publish the event to current relays, user's write relays and additional relays * Default publish the event to current relays, user's write relays and additional relays
*/ */
publish: (draftEvent: TDraftEvent, options?: { specifiedRelayUrls?: string[] }) => Promise<Event> publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise<Event>
signHttpAuth: (url: string, method: string) => Promise<string> signHttpAuth: (url: string, method: string) => Promise<string>
signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent> signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string> nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
@@ -528,7 +533,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const publish = async ( const publish = async (
draftEvent: TDraftEvent, draftEvent: TDraftEvent,
{ specifiedRelayUrls }: { specifiedRelayUrls?: string[] } = {} { specifiedRelayUrls, additionalRelayUrls }: TPublishOptions = {}
) => { ) => {
if (!account || !signer || account.signerType === 'npub') { if (!account || !signer || account.signerType === 'npub') {
throw new Error('You need to login first') throw new Error('You need to login first')
@@ -549,7 +554,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
} }
const additionalRelayUrls: string[] = [] const _additionalRelayUrls: string[] = additionalRelayUrls ?? []
if ( if (
!specifiedRelayUrls?.length && !specifiedRelayUrls?.length &&
[ [
@@ -574,7 +579,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (mentions.length > 0) { if (mentions.length > 0) {
const relayLists = await client.fetchRelayLists(mentions) const relayLists = await client.fetchRelayLists(mentions)
relayLists.forEach((relayList) => { relayLists.forEach((relayList) => {
additionalRelayUrls.push(...relayList.read.slice(0, 4)) _additionalRelayUrls.push(...relayList.read.slice(0, 4))
}) })
} }
} }
@@ -586,7 +591,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
ExtendedKind.BLOSSOM_SERVER_LIST ExtendedKind.BLOSSOM_SERVER_LIST
].includes(draftEvent.kind) ].includes(draftEvent.kind)
) { ) {
additionalRelayUrls.push(...BIG_RELAY_URLS) _additionalRelayUrls.push(...BIG_RELAY_URLS)
} }
let relays: string[] let relays: string[]
@@ -595,7 +600,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} else { } else {
const relayList = await client.fetchRelayList(event.pubkey) const relayList = await client.fetchRelayList(event.pubkey)
relays = (relayList?.write.slice(0, 10) ?? []) relays = (relayList?.write.slice(0, 10) ?? [])
.concat(Array.from(new Set(additionalRelayUrls)) ?? []) .concat(Array.from(new Set(_additionalRelayUrls)) ?? [])
.concat(client.getCurrentRelayUrls()) .concat(client.getCurrentRelayUrls())
} }

View File

@@ -0,0 +1,142 @@
import { ExtendedKind } from '@/constants'
import { getPollResponseFromEvent } from '@/lib/event-metadata'
import dayjs from 'dayjs'
import { Filter } from 'nostr-tools'
import client from './client.service'
export type TPollResults = {
totalVotes: number
results: Record<string, Set<string>>
voters: Set<string>
updatedAt: number
}
class PollResultsService {
static instance: PollResultsService
private pollResultsMap: Map<string, TPollResults> = new Map()
private pollResultsSubscribers = new Map<string, Set<() => void>>()
constructor() {
if (!PollResultsService.instance) {
PollResultsService.instance = this
}
return PollResultsService.instance
}
async fetchResults(
pollEventId: string,
relays: string[],
validPollOptionIds: string[],
isMultipleChoice: boolean,
endsAt?: number
) {
const filter: Filter = {
kinds: [ExtendedKind.POLL_RESPONSE],
'#e': [pollEventId],
limit: 1000
}
if (endsAt) {
filter.until = endsAt
}
let results = this.pollResultsMap.get(pollEventId)
if (results) {
if (endsAt && results.updatedAt >= endsAt) {
return results
}
filter.since = results.updatedAt
} else {
results = {
totalVotes: 0,
results: validPollOptionIds.reduce(
(acc, optionId) => {
acc[optionId] = new Set<string>()
return acc
},
{} as Record<string, Set<string>>
),
voters: new Set<string>(),
updatedAt: 0
}
}
const responseEvents = await client.fetchEvents(relays, {
kinds: [ExtendedKind.POLL_RESPONSE],
'#e': [pollEventId],
limit: 1000
})
results.updatedAt = dayjs().unix()
const responses = responseEvents
.map((evt) => getPollResponseFromEvent(evt, validPollOptionIds, isMultipleChoice))
.filter((response): response is NonNullable<typeof response> => response !== null)
responses
.sort((a, b) => b.created_at - a.created_at)
.forEach((response) => {
if (results && results.voters.has(response.pubkey)) return
results.voters.add(response.pubkey)
results.totalVotes += response.selectedOptionIds.length
response.selectedOptionIds.forEach((optionId) => {
if (results.results[optionId]) {
results.results[optionId].add(response.pubkey)
}
})
})
this.pollResultsMap.set(pollEventId, { ...results })
if (responseEvents.length) {
this.notifyPollResults(pollEventId)
}
return results
}
subscribePollResults(pollEventId: string, callback: () => void) {
let set = this.pollResultsSubscribers.get(pollEventId)
if (!set) {
set = new Set()
this.pollResultsSubscribers.set(pollEventId, set)
}
set.add(callback)
return () => {
set?.delete(callback)
if (set?.size === 0) this.pollResultsSubscribers.delete(pollEventId)
}
}
private notifyPollResults(pollEventId: string) {
const set = this.pollResultsSubscribers.get(pollEventId)
if (set) {
set.forEach((cb) => cb())
}
}
getPollResults(id: string): TPollResults | undefined {
return this.pollResultsMap.get(id)
}
addPollResponse(pollEventId: string, pubkey: string, selectedOptionIds: string[]) {
const results = this.pollResultsMap.get(pollEventId)
if (!results) return
if (results.voters.has(pubkey)) return
results.voters.add(pubkey)
results.totalVotes += selectedOptionIds.length
selectedOptionIds.forEach((optionId) => {
if (results.results[optionId]) {
results.results[optionId].add(pubkey)
}
})
this.pollResultsMap.set(pollEventId, { ...results })
this.notifyPollResults(pollEventId)
}
}
const instance = new PollResultsService()
export default instance

View File

@@ -1,48 +0,0 @@
import { Content } from '@tiptap/react'
import { Event } from 'nostr-tools'
class PostContentCacheService {
static instance: PostContentCacheService
private normalPostCache: Map<string, Content> = new Map()
constructor() {
if (!PostContentCacheService.instance) {
PostContentCacheService.instance = this
}
return PostContentCacheService.instance
}
getPostCache({
defaultContent,
parentEvent
}: { defaultContent?: string; parentEvent?: Event } = {}) {
return (
this.normalPostCache.get(this.generateCacheKey(defaultContent, parentEvent)) ?? defaultContent
)
}
setPostCache(
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event },
content: Content
) {
this.normalPostCache.set(this.generateCacheKey(defaultContent, parentEvent), content)
}
clearPostCache({
defaultContent,
parentEvent
}: {
defaultContent?: string
parentEvent?: Event
}) {
this.normalPostCache.delete(this.generateCacheKey(defaultContent, parentEvent))
}
generateCacheKey(defaultContent: string = '', parentEvent?: Event): string {
return parentEvent ? parentEvent.id : defaultContent
}
}
const instance = new PostContentCacheService()
export default instance

View File

@@ -0,0 +1,75 @@
import { TPollCreateData } from '@/types'
import { Content } from '@tiptap/react'
import { Event } from 'nostr-tools'
type TPostSettings = {
isNsfw?: boolean
isPoll?: boolean
pollCreateData?: TPollCreateData
specifiedRelayUrls?: string[]
addClientTag?: boolean
}
class PostEditorCacheService {
static instance: PostEditorCacheService
private postContentCache: Map<string, Content> = new Map()
private postSettingsCache: Map<string, TPostSettings> = new Map()
constructor() {
if (!PostEditorCacheService.instance) {
PostEditorCacheService.instance = this
}
return PostEditorCacheService.instance
}
getPostContentCache({
defaultContent,
parentEvent
}: { defaultContent?: string; parentEvent?: Event } = {}) {
return (
this.postContentCache.get(this.generateCacheKey(defaultContent, parentEvent)) ??
defaultContent
)
}
setPostContentCache(
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event },
content: Content
) {
this.postContentCache.set(this.generateCacheKey(defaultContent, parentEvent), content)
}
getPostSettingsCache({
defaultContent,
parentEvent
}: { defaultContent?: string; parentEvent?: Event } = {}): TPostSettings | undefined {
return this.postSettingsCache.get(this.generateCacheKey(defaultContent, parentEvent))
}
setPostSettingsCache(
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event },
settings: TPostSettings
) {
this.postSettingsCache.set(this.generateCacheKey(defaultContent, parentEvent), settings)
}
clearPostCache({
defaultContent,
parentEvent
}: {
defaultContent?: string
parentEvent?: Event
}) {
const cacheKey = this.generateCacheKey(defaultContent, parentEvent)
this.postContentCache.delete(cacheKey)
this.postSettingsCache.delete(cacheKey)
}
generateCacheKey(defaultContent: string = '', parentEvent?: Event): string {
return parentEvent ? parentEvent.id : defaultContent
}
}
const instance = new PostEditorCacheService()
export default instance

View File

@@ -1,4 +1,5 @@
import { Event, VerifiedEvent } from 'nostr-tools' import { Event, VerifiedEvent } from 'nostr-tools'
import { POLL_TYPE } from './constants'
export type TProfile = { export type TProfile = {
username: string username: string
@@ -151,3 +152,12 @@ export type TMediaUploadServiceConfig =
| { | {
type: 'blossom' type: 'blossom'
} }
export type TPollType = (typeof POLL_TYPE)[keyof typeof POLL_TYPE]
export type TPollCreateData = {
isMultipleChoice: boolean
options: string[]
relays: string[]
endsAt?: number
}

View File

@@ -3,6 +3,7 @@ import { execSync } from 'child_process'
import path from 'path' import path from 'path'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import packageJson from './package.json'
const getGitHash = () => { const getGitHash = () => {
try { try {
@@ -15,7 +16,7 @@ const getGitHash = () => {
const getAppVersion = () => { const getAppVersion = () => {
try { try {
return JSON.stringify(require('./package.json').version) return JSON.stringify(packageJson.version)
} catch (error) { } catch (error) {
console.warn('Failed to retrieve app version:', error) console.warn('Failed to retrieve app version:', error)
return '"unknown"' return '"unknown"'