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,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