feat: auto-load poll results

This commit is contained in:
codytseng
2025-07-27 22:08:08 +08:00
parent b35e0cf850
commit ea821fd708
18 changed files with 193 additions and 64 deletions

View File

@@ -3,14 +3,14 @@ import { POLL_TYPE } from '@/constants'
import { useFetchPollResults } from '@/hooks/useFetchPollResults' import { useFetchPollResults } from '@/hooks/useFetchPollResults'
import { createPollResponseDraftEvent } from '@/lib/draft-event' import { createPollResponseDraftEvent } from '@/lib/draft-event'
import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { getPollMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils' import { cn, isPartiallyInViewport } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import pollResultsService from '@/services/poll-results.service' import pollResultsService from '@/services/poll-results.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { CheckCircle2, Loader2 } from 'lucide-react' import { CheckCircle2, Loader2 } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -32,6 +32,30 @@ export default function Poll({ event, className }: { event: Event; className?: s
const isExpired = useMemo(() => poll?.endsAt && dayjs().unix() > poll.endsAt, [poll]) const isExpired = useMemo(() => poll?.endsAt && dayjs().unix() > poll.endsAt, [poll])
const isMultipleChoice = useMemo(() => poll?.pollType === POLL_TYPE.MULTIPLE_CHOICE, [poll]) const isMultipleChoice = useMemo(() => poll?.pollType === POLL_TYPE.MULTIPLE_CHOICE, [poll])
const canVote = useMemo(() => !isExpired && !votedOptionIds.length, [isExpired, votedOptionIds]) const canVote = useMemo(() => !isExpired && !votedOptionIds.length, [isExpired, votedOptionIds])
const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null)
useEffect(() => {
if (pollResults || isLoadingResults || !containerElement) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => {
if (isPartiallyInViewport(containerElement)) {
fetchResults()
}
}, 200)
}
},
{ threshold: 0.1 }
)
observer.observe(containerElement)
return () => {
observer.unobserve(containerElement)
}
}, [pollResults, isLoadingResults, containerElement])
if (!poll) { if (!poll) {
return null return null
@@ -102,12 +126,21 @@ export default function Poll({ event, className }: { event: Event; className?: s
} }
return ( return (
<div className={className}> <div className={className} ref={setContainerElement}>
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{poll.pollType === POLL_TYPE.MULTIPLE_CHOICE && ( <p>
<p>{t('Multiple choice (select one or more)')}</p> {poll.pollType === POLL_TYPE.MULTIPLE_CHOICE &&
)} t('Multiple choice (select one or more)')}
</p>
<p>
{!!poll.endsAt &&
(isExpired
? t('Poll has ended')
: t('Poll ends at {{time}}', {
time: new Date(poll.endsAt * 1000).toLocaleString()
}))}
</p>
</div> </div>
{/* Poll Options */} {/* Poll Options */}
@@ -115,7 +148,7 @@ export default function Poll({ event, className }: { event: Event; className?: s
{poll.options.map((option) => { {poll.options.map((option) => {
const votes = pollResults?.results?.[option.id]?.size ?? 0 const votes = pollResults?.results?.[option.id]?.size ?? 0
const totalVotes = pollResults?.totalVotes ?? 0 const totalVotes = pollResults?.totalVotes ?? 0
const percentage = totalVotes > 0 ? (votes / totalVotes) * 100 : 0 const percentage = !canVote && totalVotes > 0 ? (votes / totalVotes) * 100 : 0
const isMax = const isMax =
pollResults && pollResults.totalVotes > 0 pollResults && pollResults.totalVotes > 0
? Object.values(pollResults.results).every((res) => res.size <= votes) ? Object.values(pollResults.results).every((res) => res.size <= votes)
@@ -148,7 +181,7 @@ export default function Poll({ event, className }: { event: Event; className?: s
<CheckCircle2 className="size-4 shrink-0" /> <CheckCircle2 className="size-4 shrink-0" />
)} )}
</div> </div>
{!!pollResults && ( {!canVote && (
<div <div
className={cn( className={cn(
'text-muted-foreground shrink-0 z-10', 'text-muted-foreground shrink-0 z-10',
@@ -173,21 +206,25 @@ export default function Poll({ event, className }: { event: Event; className?: s
</div> </div>
{/* Results Summary */} {/* Results Summary */}
<div className="text-sm text-muted-foreground"> <div className="flex justify-between items-center text-sm text-muted-foreground">
{!!pollResults && t('{{number}} votes', { number: pollResults.totalVotes ?? 0 })} <div>{t('{{number}} votes', { number: pollResults?.totalVotes ?? 0 })}</div>
{!!pollResults && !!poll.endsAt && ' · '}
{!!poll.endsAt && {isLoadingResults && t('Loading...')}
(isExpired {!isLoadingResults && !canVote && (
? t('Poll has ended') <div
: t('Poll ends at {{time}}', { className="hover:underline cursor-pointer"
time: new Date(poll.endsAt * 1000).toLocaleString() onClick={(e) => {
}))} e.stopPropagation()
fetchResults()
}}
>
{!pollResults ? t('Load results') : t('Refresh results')}
</div>
)}
</div> </div>
{(canVote || !pollResults) && (
<div className="flex items-center justify-between gap-2">
{/* Vote Button */} {/* Vote Button */}
{canVote && ( {canVote && !!selectedOptionIds.length && (
<Button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -195,28 +232,12 @@ export default function Poll({ event, className }: { event: Event; className?: s
handleVote() handleVote()
}} }}
disabled={!selectedOptionIds.length || isVoting} disabled={!selectedOptionIds.length || isVoting}
className="flex-1" className="w-full"
> >
{isVoting && <Loader2 className="animate-spin" />} {isVoting && <Loader2 className="animate-spin" />}
{t('Vote')} {t('Vote')}
</Button> </Button>
)} )}
{!pollResults && (
<Button
variant="secondary"
onClick={(e) => {
e.stopPropagation()
fetchResults()
}}
disabled={isLoadingResults}
>
{isLoadingResults && <Loader2 className="animate-spin" />}
{t('Load results')}
</Button>
)}
</div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -29,6 +29,7 @@ export default {
'Unfollow failed': 'فشل إلغاء المتابعة', 'Unfollow failed': 'فشل إلغاء المتابعة',
'show new notes': 'إظهار الملاحظات الجديدة', 'show new notes': 'إظهار الملاحظات الجديدة',
'loading...': 'جار التحميل...', 'loading...': 'جار التحميل...',
'Loading...': 'جار التحميل...',
'no more notes': 'لا توجد ملاحظات إضافية', 'no more notes': 'لا توجد ملاحظات إضافية',
'reply to': 'الرد على', 'reply to': 'الرد على',
reply: 'رد', reply: 'رد',
@@ -310,6 +311,7 @@ export default {
'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': 'إزالة الاستطلاع',
'Refresh results': 'تحديث النتائج'
} }
} }

View File

@@ -29,6 +29,7 @@ export default {
'Unfollow failed': 'Nicht mehr folgen fehlgeschlagen', 'Unfollow failed': 'Nicht mehr folgen fehlgeschlagen',
'show new notes': 'zeige neue Notizen', 'show new notes': 'zeige neue Notizen',
'loading...': 'lädt...', 'loading...': 'lädt...',
'Loading...': 'Lade...',
'no more notes': 'keine weiteren Notizen', 'no more notes': 'keine weiteren Notizen',
'reply to': 'antworten an', 'reply to': 'antworten an',
reply: 'antworten', reply: 'antworten',
@@ -317,6 +318,7 @@ export default {
'End Date (optional)': 'Enddatum (optional)', 'End Date (optional)': 'Enddatum (optional)',
'Clear end date': 'Enddatum löschen', 'Clear end date': 'Enddatum löschen',
'Relay URLs (optional, comma-separated)': 'Relay-URLs (optional, durch Kommas getrennt)', 'Relay URLs (optional, comma-separated)': 'Relay-URLs (optional, durch Kommas getrennt)',
'Remove poll': 'Umfrage entfernen' 'Remove poll': 'Umfrage entfernen',
'Refresh results': 'Ergebnisse aktualisieren'
} }
} }

View File

@@ -28,6 +28,7 @@ export default {
'Unfollow failed': 'Unfollow failed', 'Unfollow failed': 'Unfollow failed',
'show new notes': 'show new notes', 'show new notes': 'show new notes',
'loading...': 'loading...', 'loading...': 'loading...',
'Loading...': 'Loading...',
'no more notes': 'no more notes', 'no more notes': 'no more notes',
'reply to': 'reply to', 'reply to': 'reply to',
reply: 'reply', reply: 'reply',
@@ -310,6 +311,7 @@ export default {
'End Date (optional)': 'End Date (optional)', 'End Date (optional)': 'End Date (optional)',
'Clear end date': 'Clear end date', 'Clear end date': 'Clear end date',
'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)', 'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)',
'Remove poll': 'Remove poll' 'Remove poll': 'Remove poll',
'Refresh results': 'Refresh results'
} }
} }

View File

@@ -29,6 +29,7 @@ export default {
'Unfollow failed': 'Error al dejar de seguir', 'Unfollow failed': 'Error al dejar de seguir',
'show new notes': 'mostrar nuevas notas', 'show new notes': 'mostrar nuevas notas',
'loading...': 'cargando...', 'loading...': 'cargando...',
'Loading...': 'Cargando...',
'no more notes': 'no hay más notas', 'no more notes': 'no hay más notas',
'reply to': 'responder a', 'reply to': 'responder a',
reply: 'responder', reply: 'responder',
@@ -315,6 +316,7 @@ export default {
'End Date (optional)': 'Fecha de finalización (opcional)', 'End Date (optional)': 'Fecha de finalización (opcional)',
'Clear end date': 'Borrar fecha de finalización', 'Clear end date': 'Borrar fecha de finalización',
'Relay URLs (optional, comma-separated)': 'URLs de relé (opcional, separadas por comas)', 'Relay URLs (optional, comma-separated)': 'URLs de relé (opcional, separadas por comas)',
'Remove poll': 'Eliminar encuesta' 'Remove poll': 'Eliminar encuesta',
'Refresh results': 'Actualizar resultados'
} }
} }

View File

@@ -28,6 +28,7 @@ export default {
'Unfollow failed': 'لغو دنبال کردن ناموفق', 'Unfollow failed': 'لغو دنبال کردن ناموفق',
'show new notes': 'نمایش یادداشت‌های جدید', 'show new notes': 'نمایش یادداشت‌های جدید',
'loading...': 'در حال بارگذاری...', 'loading...': 'در حال بارگذاری...',
'Loading...': 'در حال بارگذاری...',
'no more notes': 'یادداشت بیشتری وجود ندارد', 'no more notes': 'یادداشت بیشتری وجود ندارد',
'reply to': 'پاسخ به', 'reply to': 'پاسخ به',
reply: 'پاسخ', reply: 'پاسخ',
@@ -312,6 +313,7 @@ export default {
'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': 'حذف نظرسنجی',
'Refresh results': 'بارگیری مجدد نتایج'
} }
} }

View File

@@ -29,6 +29,7 @@ export default {
'Unfollow failed': "Échec de l'arrêt du suivi", 'Unfollow failed': "Échec de l'arrêt du suivi",
'show new notes': 'afficher les nouvelles notes', 'show new notes': 'afficher les nouvelles notes',
'loading...': 'chargement...', 'loading...': 'chargement...',
'Loading...': 'Chargement...',
'no more notes': 'plus de notes', 'no more notes': 'plus de notes',
'reply to': 'répondre à', 'reply to': 'répondre à',
reply: 'répondre', reply: 'répondre',
@@ -316,6 +317,7 @@ export default {
'Clear end date': 'Effacer la date de fin', 'Clear end date': 'Effacer la date de fin',
'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)':
'URLs de relais (optionnel, séparées par des virgules)', 'URLs de relais (optionnel, séparées par des virgules)',
'Remove poll': 'Supprimer le sondage' 'Remove poll': 'Supprimer le sondage',
'Refresh results': 'Rafraîchir les résultats'
} }
} }

View File

@@ -28,6 +28,7 @@ export default {
'Unfollow failed': 'Disiscrizione non riuscita', 'Unfollow failed': 'Disiscrizione non riuscita',
'show new notes': 'mostra nuove note', 'show new notes': 'mostra nuove note',
'loading...': 'caricando...', 'loading...': 'caricando...',
'Loading...': 'Caricamento in corso...',
'no more notes': 'basta note', 'no more notes': 'basta note',
'reply to': 'replica a', 'reply to': 'replica a',
reply: 'replica', reply: 'replica',
@@ -314,6 +315,7 @@ export default {
'End Date (optional)': 'Data di fine (opzionale)', 'End Date (optional)': 'Data di fine (opzionale)',
'Clear end date': 'Cancella data di fine', 'Clear end date': 'Cancella data di fine',
'Relay URLs (optional, comma-separated)': 'URL relay (opzionale, separati da virgole)', 'Relay URLs (optional, comma-separated)': 'URL relay (opzionale, separati da virgole)',
'Remove poll': 'Rimuovi sondaggio' 'Remove poll': 'Rimuovi sondaggio',
'Refresh results': 'Aggiorna risultati'
} }
} }

View File

@@ -29,6 +29,7 @@ export default {
'Unfollow failed': 'フォロー解除に失敗しました', 'Unfollow failed': 'フォロー解除に失敗しました',
'show new notes': '新しいノートを表示', 'show new notes': '新しいノートを表示',
'loading...': '読み込み中...', 'loading...': '読み込み中...',
'Loading...': '読み込み中...',
'no more notes': 'これ以上ノートはありません', 'no more notes': 'これ以上ノートはありません',
'reply to': '返信先', 'reply to': '返信先',
reply: '返信', reply: '返信',
@@ -312,6 +313,7 @@ export default {
'End Date (optional)': '終了日(任意)', 'End Date (optional)': '終了日(任意)',
'Clear end date': '終了日をクリア', 'Clear end date': '終了日をクリア',
'Relay URLs (optional, comma-separated)': 'リレーURL任意、カンマ区切り', 'Relay URLs (optional, comma-separated)': 'リレーURL任意、カンマ区切り',
'Remove poll': '投票を削除' 'Remove poll': '投票を削除',
'Refresh results': '結果を更新'
} }
} }

View File

@@ -28,6 +28,7 @@ export default {
'Unfollow failed': '언팔로우 실패', 'Unfollow failed': '언팔로우 실패',
'show new notes': '새 노트 보기', 'show new notes': '새 노트 보기',
'loading...': '로딩 중...', 'loading...': '로딩 중...',
'Loading...': '로딩 중...',
'no more notes': '더 이상 노트 없음', 'no more notes': '더 이상 노트 없음',
'reply to': '답글', 'reply to': '답글',
reply: '답글', reply: '답글',
@@ -312,6 +313,7 @@ export default {
'End Date (optional)': '종료 날짜 (선택사항)', 'End Date (optional)': '종료 날짜 (선택사항)',
'Clear end date': '종료 날짜 지우기', 'Clear end date': '종료 날짜 지우기',
'Relay URLs (optional, comma-separated)': '릴레이 URL (선택사항, 쉼표로 구분)', 'Relay URLs (optional, comma-separated)': '릴레이 URL (선택사항, 쉼표로 구분)',
'Remove poll': '투표 제거' 'Remove poll': '투표 제거',
'Refresh results': '결과 새로 고침'
} }
} }

View File

@@ -28,6 +28,7 @@ export default {
'Unfollow failed': 'Porzucenie obserwacji nieudane', 'Unfollow failed': 'Porzucenie obserwacji nieudane',
'show new notes': 'Pokaż nowe wpisy', 'show new notes': 'Pokaż nowe wpisy',
'loading...': 'ładowanie...', 'loading...': 'ładowanie...',
'Loading...': 'Ładowanie...',
'no more notes': 'Koniec wpisów', 'no more notes': 'Koniec wpisów',
'reply to': 'Odpowiedź na', 'reply to': 'Odpowiedź na',
reply: 'odpowiedz', reply: 'odpowiedz',
@@ -314,6 +315,7 @@ export default {
'Clear end date': 'Wyczyść datę zakończenia', 'Clear end date': 'Wyczyść datę zakończenia',
'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)':
'Adresy URL przekaźników (opcjonalne, oddzielone przecinkami)', 'Adresy URL przekaźników (opcjonalne, oddzielone przecinkami)',
'Remove poll': 'Usuń ankietę' 'Remove poll': 'Usuń ankietę',
'Refresh results': 'Odśwież wyniki'
} }
} }

View File

@@ -28,6 +28,7 @@ export default {
'Unfollow failed': 'Falha ao deixar de seguir', 'Unfollow failed': 'Falha ao deixar de seguir',
'show new notes': 'Ver novas notas', 'show new notes': 'Ver novas notas',
'loading...': 'Carregando...', 'loading...': 'Carregando...',
'Loading...': 'Carregando...',
'no more notes': 'Não há mais notas', 'no more notes': 'Não há mais notas',
'reply to': 'Respondendo a', 'reply to': 'Respondendo a',
reply: 'Responder', reply: 'Responder',
@@ -313,6 +314,7 @@ export default {
'End Date (optional)': 'Data de término (opcional)', 'End Date (optional)': 'Data de término (opcional)',
'Clear end date': 'Limpar data de término', 'Clear end date': 'Limpar data de término',
'Relay URLs (optional, comma-separated)': 'URLs de relay (opcional, separadas por vírgulas)', 'Relay URLs (optional, comma-separated)': 'URLs de relay (opcional, separadas por vírgulas)',
'Remove poll': 'Remover enquete' 'Remove poll': 'Remover enquete',
'Refresh results': 'Atualizar resultados'
} }
} }

View File

@@ -29,6 +29,7 @@ export default {
'Unfollow failed': 'Falha ao Deixar de Seguir', 'Unfollow failed': 'Falha ao Deixar de Seguir',
'show new notes': 'mostrar novas notas', 'show new notes': 'mostrar novas notas',
'loading...': 'carregando...', 'loading...': 'carregando...',
'Loading...': 'Carregando...',
'no more notes': 'não há mais notas', 'no more notes': 'não há mais notas',
'reply to': 'responder a', 'reply to': 'responder a',
reply: 'responder', reply: 'responder',
@@ -314,6 +315,7 @@ export default {
'End Date (optional)': 'Data de fim (opcional)', 'End Date (optional)': 'Data de fim (opcional)',
'Clear end date': 'Limpar data de fim', 'Clear end date': 'Limpar data de fim',
'Relay URLs (optional, comma-separated)': 'URLs de relay (opcional, separadas por vírgulas)', 'Relay URLs (optional, comma-separated)': 'URLs de relay (opcional, separadas por vírgulas)',
'Remove poll': 'Remover sondagem' 'Remove poll': 'Remover sondagem',
'Refresh results': 'Atualizar resultados'
} }
} }

View File

@@ -29,6 +29,7 @@ export default {
'Unfollow failed': 'Ошибка отписки', 'Unfollow failed': 'Ошибка отписки',
'show new notes': 'показать новые заметки', 'show new notes': 'показать новые заметки',
'loading...': 'загрузка...', 'loading...': 'загрузка...',
'Loading...': 'Загрузка...',
'no more notes': 'больше нет заметок', 'no more notes': 'больше нет заметок',
'reply to': 'ответить', 'reply to': 'ответить',
reply: 'ответить', reply: 'ответить',
@@ -315,6 +316,7 @@ export default {
'End Date (optional)': 'Дата окончания (необязательно)', 'End Date (optional)': 'Дата окончания (необязательно)',
'Clear end date': 'Очистить дату окончания', 'Clear end date': 'Очистить дату окончания',
'Relay URLs (optional, comma-separated)': 'URL релеев (необязательно, через запятую)', 'Relay URLs (optional, comma-separated)': 'URL релеев (необязательно, через запятую)',
'Remove poll': 'Удалить опрос' 'Remove poll': 'Удалить опрос',
'Refresh results': 'Обновить результаты'
} }
} }

View File

@@ -28,6 +28,7 @@ export default {
'Unfollow failed': 'เลิกติดตามไม่สำเร็จ', 'Unfollow failed': 'เลิกติดตามไม่สำเร็จ',
'show new notes': 'แสดงโน้ตใหม่', 'show new notes': 'แสดงโน้ตใหม่',
'loading...': 'กำลังโหลด...', 'loading...': 'กำลังโหลด...',
'Loading...': 'กำลังโหลด...',
'no more notes': 'ไม่มีโน้ตเพิ่มเติม', 'no more notes': 'ไม่มีโน้ตเพิ่มเติม',
'reply to': 'ตอบกลับถึง', 'reply to': 'ตอบกลับถึง',
reply: 'ตอบกลับ', reply: 'ตอบกลับ',
@@ -309,6 +310,7 @@ export default {
'End Date (optional)': 'วันที่สิ้นสุด (ไม่บังคับ)', 'End Date (optional)': 'วันที่สิ้นสุด (ไม่บังคับ)',
'Clear end date': 'ล้างวันที่สิ้นสุด', 'Clear end date': 'ล้างวันที่สิ้นสุด',
'Relay URLs (optional, comma-separated)': 'URL รีเลย์ (ไม่บังคับ, คั่นด้วยจุลภาค)', 'Relay URLs (optional, comma-separated)': 'URL รีเลย์ (ไม่บังคับ, คั่นด้วยจุลภาค)',
'Remove poll': 'ลบโพลล์' 'Remove poll': 'ลบโพลล์',
'Refresh results': 'รีเฟรชผลลัพธ์'
} }
} }

View File

@@ -28,6 +28,7 @@ export default {
'Unfollow failed': '取消关注失败', 'Unfollow failed': '取消关注失败',
'show new notes': '显示新笔记', 'show new notes': '显示新笔记',
'loading...': '加载中...', 'loading...': '加载中...',
'Loading...': '加载中...',
'no more notes': '到底了', 'no more notes': '到底了',
'reply to': '回复', 'reply to': '回复',
reply: '回复', reply: '回复',
@@ -310,6 +311,7 @@ export default {
'End Date (optional)': '结束日期(可选)', 'End Date (optional)': '结束日期(可选)',
'Clear end date': '清除结束日期', 'Clear end date': '清除结束日期',
'Relay URLs (optional, comma-separated)': '中继服务器 URL可选逗号分隔', 'Relay URLs (optional, comma-separated)': '中继服务器 URL可选逗号分隔',
'Remove poll': '移除投票' 'Remove poll': '移除投票',
'Refresh results': '刷新结果'
} }
} }

View File

@@ -49,6 +49,16 @@ export function isInViewport(el: HTMLElement) {
) )
} }
export function isPartiallyInViewport(el: HTMLElement) {
const rect = el.getBoundingClientRect()
return (
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
rect.bottom > 0 &&
rect.left < (window.innerWidth || document.documentElement.clientWidth) &&
rect.right > 0
)
}
export function isEmail(email: string) { export function isEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
} }

View File

@@ -1,5 +1,6 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { getPollResponseFromEvent } from '@/lib/event-metadata' import { getPollResponseFromEvent } from '@/lib/event-metadata'
import DataLoader from 'dataloader'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Filter } from 'nostr-tools' import { Filter } from 'nostr-tools'
import client from './client.service' import client from './client.service'
@@ -15,6 +16,56 @@ class PollResultsService {
static instance: PollResultsService static instance: PollResultsService
private pollResultsMap: Map<string, TPollResults> = new Map() private pollResultsMap: Map<string, TPollResults> = new Map()
private pollResultsSubscribers = new Map<string, Set<() => void>>() private pollResultsSubscribers = new Map<string, Set<() => void>>()
private loader = new DataLoader<
{
pollEventId: string
relays: string[]
validPollOptionIds: string[]
isMultipleChoice: boolean
endsAt?: number
},
TPollResults | undefined
>(
async (params) => {
const pollMap = new Map<
string,
{
relays: string[]
validPollOptionIds: string[]
isMultipleChoice: boolean
endsAt?: number
}
>()
params.forEach(({ pollEventId, relays, validPollOptionIds, isMultipleChoice, endsAt }) => {
if (!pollMap.has(pollEventId)) {
pollMap.set(pollEventId, { relays, validPollOptionIds, isMultipleChoice, endsAt })
}
})
const pollResults = await Promise.allSettled(
Array.from(pollMap).map(async ([pollEventId, pollParams]) => {
const result = await this._fetchResults(
pollEventId,
pollParams.relays,
pollParams.validPollOptionIds,
pollParams.isMultipleChoice,
pollParams.endsAt
)
return { pollEventId, result }
})
)
const resultMap = new Map<string, TPollResults>()
pollResults.forEach((promiseResult) => {
if (promiseResult.status === 'fulfilled' && promiseResult.value.result) {
resultMap.set(promiseResult.value.pollEventId, promiseResult.value.result)
}
})
return params.map(({ pollEventId }) => resultMap.get(pollEventId))
},
{ cache: false }
)
constructor() { constructor() {
if (!PollResultsService.instance) { if (!PollResultsService.instance) {
@@ -30,6 +81,23 @@ class PollResultsService {
isMultipleChoice: boolean, isMultipleChoice: boolean,
endsAt?: number endsAt?: number
) { ) {
return this.loader.load({
pollEventId,
relays,
validPollOptionIds,
isMultipleChoice,
endsAt
})
}
private async _fetchResults(
pollEventId: string,
relays: string[],
validPollOptionIds: string[],
isMultipleChoice: boolean,
endsAt?: number
) {
console.log('Fetching poll results for:', pollEventId)
const filter: Filter = { const filter: Filter = {
kinds: [ExtendedKind.POLL_RESPONSE], kinds: [ExtendedKind.POLL_RESPONSE],
'#e': [pollEventId], '#e': [pollEventId],