35 changed files with 1240 additions and 130 deletions
@ -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 |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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) |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
@ -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 |
|
||||||
@ -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 |
||||||
Loading…
Reference in new issue