diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx index dbfeda7f..7733bca0 100644 --- a/src/components/Note/Poll.tsx +++ b/src/components/Note/Poll.tsx @@ -3,14 +3,14 @@ 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 { cn, isPartiallyInViewport } 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 { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' 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 isMultipleChoice = useMemo(() => poll?.pollType === POLL_TYPE.MULTIPLE_CHOICE, [poll]) const canVote = useMemo(() => !isExpired && !votedOptionIds.length, [isExpired, votedOptionIds]) + const [containerElement, setContainerElement] = useState(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) { return null @@ -102,12 +126,21 @@ export default function Poll({ event, className }: { event: Event; className?: s } return ( -
+
- {poll.pollType === POLL_TYPE.MULTIPLE_CHOICE && ( -

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

- )} +

+ {poll.pollType === POLL_TYPE.MULTIPLE_CHOICE && + t('Multiple choice (select one or more)')} +

+

+ {!!poll.endsAt && + (isExpired + ? t('Poll has ended') + : t('Poll ends at {{time}}', { + time: new Date(poll.endsAt * 1000).toLocaleString() + }))} +

{/* Poll Options */} @@ -115,7 +148,7 @@ export default function Poll({ event, className }: { event: Event; className?: s {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 percentage = !canVote && totalVotes > 0 ? (votes / totalVotes) * 100 : 0 const isMax = pollResults && pollResults.totalVotes > 0 ? Object.values(pollResults.results).every((res) => res.size <= votes) @@ -148,7 +181,7 @@ export default function Poll({ event, className }: { event: Event; className?: s )}
- {!!pollResults && ( + {!canVote && (
{/* Results Summary */} -
- {!!pollResults && t('{{number}} votes', { number: pollResults.totalVotes ?? 0 })} - {!!pollResults && !!poll.endsAt && ' · '} - {!!poll.endsAt && - (isExpired - ? t('Poll has ended') - : t('Poll ends at {{time}}', { - time: new Date(poll.endsAt * 1000).toLocaleString() - }))} +
+
{t('{{number}} votes', { number: pollResults?.totalVotes ?? 0 })}
+ + {isLoadingResults && t('Loading...')} + {!isLoadingResults && !canVote && ( +
{ + e.stopPropagation() + fetchResults() + }} + > + {!pollResults ? t('Load results') : t('Refresh results')} +
+ )}
- {(canVote || !pollResults) && ( -
- {/* Vote Button */} - {canVote && ( - - )} - - {!pollResults && ( - - )} -
+ {/* Vote Button */} + {canVote && !!selectedOptionIds.length && ( + )}
diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index d50c3222..dff5f388 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -29,6 +29,7 @@ export default { 'Unfollow failed': 'فشل إلغاء المتابعة', 'show new notes': 'إظهار الملاحظات الجديدة', 'loading...': 'جار التحميل...', + 'Loading...': 'جار التحميل...', 'no more notes': 'لا توجد ملاحظات إضافية', 'reply to': 'الرد على', reply: 'رد', @@ -310,6 +311,7 @@ export default { 'End Date (optional)': 'تاريخ الانتهاء (اختياري)', 'Clear end date': 'مسح تاريخ الانتهاء', 'Relay URLs (optional, comma-separated)': 'عناوين المرحلات (اختياري، مفصولة بفواصل)', - 'Remove poll': 'إزالة الاستطلاع' + 'Remove poll': 'إزالة الاستطلاع', + 'Refresh results': 'تحديث النتائج' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index b3225efd..09b56afd 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -29,6 +29,7 @@ export default { 'Unfollow failed': 'Nicht mehr folgen fehlgeschlagen', 'show new notes': 'zeige neue Notizen', 'loading...': 'lädt...', + 'Loading...': 'Lade...', 'no more notes': 'keine weiteren Notizen', 'reply to': 'antworten an', reply: 'antworten', @@ -317,6 +318,7 @@ export default { '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' + 'Remove poll': 'Umfrage entfernen', + 'Refresh results': 'Ergebnisse aktualisieren' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7fabb270..18d35e33 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -28,6 +28,7 @@ export default { 'Unfollow failed': 'Unfollow failed', 'show new notes': 'show new notes', 'loading...': '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' + 'Remove poll': 'Remove poll', + 'Refresh results': 'Refresh results' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index cce99493..a59a8d4b 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -29,6 +29,7 @@ export default { 'Unfollow failed': 'Error al dejar de seguir', 'show new notes': 'mostrar nuevas notas', 'loading...': 'cargando...', + 'Loading...': 'Cargando...', 'no more notes': 'no hay más notas', 'reply to': 'responder a', reply: 'responder', @@ -315,6 +316,7 @@ export default { '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' + 'Remove poll': 'Eliminar encuesta', + 'Refresh results': 'Actualizar resultados' } } diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index c0c5d68d..2b31c6d3 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -28,6 +28,7 @@ export default { 'Unfollow failed': 'لغو دنبال کردن ناموفق', 'show new notes': 'نمایش یادداشت‌های جدید', 'loading...': 'در حال بارگذاری...', + 'Loading...': 'در حال بارگذاری...', 'no more notes': 'یادداشت بیشتری وجود ندارد', 'reply to': 'پاسخ به', reply: 'پاسخ', @@ -312,6 +313,7 @@ export default { 'End Date (optional)': 'تاریخ پایان (اختیاری)', 'Clear end date': 'پاک کردن تاریخ پایان', 'Relay URLs (optional, comma-separated)': 'آدرس‌های رله (اختیاری، جدا شده با کاما)', - 'Remove poll': 'حذف نظرسنجی' + 'Remove poll': 'حذف نظرسنجی', + 'Refresh results': 'بارگیری مجدد نتایج' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 608fc8a1..f2a70b57 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -29,6 +29,7 @@ export default { 'Unfollow failed': "Échec de l'arrêt du suivi", 'show new notes': 'afficher les nouvelles notes', 'loading...': 'chargement...', + 'Loading...': 'Chargement...', 'no more notes': 'plus de notes', 'reply to': 'répondre à', reply: 'répondre', @@ -316,6 +317,7 @@ export default { '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' + 'Remove poll': 'Supprimer le sondage', + 'Refresh results': 'Rafraîchir les résultats' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 3bb460b3..e2b7a19d 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -28,6 +28,7 @@ export default { 'Unfollow failed': 'Disiscrizione non riuscita', 'show new notes': 'mostra nuove note', 'loading...': 'caricando...', + 'Loading...': 'Caricamento in corso...', 'no more notes': 'basta note', 'reply to': 'replica a', reply: 'replica', @@ -314,6 +315,7 @@ export default { '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' + 'Remove poll': 'Rimuovi sondaggio', + 'Refresh results': 'Aggiorna risultati' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 00f678c8..c58f7f86 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -29,6 +29,7 @@ export default { 'Unfollow failed': 'フォロー解除に失敗しました', 'show new notes': '新しいノートを表示', 'loading...': '読み込み中...', + 'Loading...': '読み込み中...', 'no more notes': 'これ以上ノートはありません', 'reply to': '返信先', reply: '返信', @@ -312,6 +313,7 @@ export default { 'End Date (optional)': '終了日(任意)', 'Clear end date': '終了日をクリア', 'Relay URLs (optional, comma-separated)': 'リレーURL(任意、カンマ区切り)', - 'Remove poll': '投票を削除' + 'Remove poll': '投票を削除', + 'Refresh results': '結果を更新' } } diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 653d4a1a..fe2b3de0 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -28,6 +28,7 @@ export default { 'Unfollow failed': '언팔로우 실패', 'show new notes': '새 노트 보기', 'loading...': '로딩 중...', + 'Loading...': '로딩 중...', 'no more notes': '더 이상 노트 없음', 'reply to': '답글', reply: '답글', @@ -312,6 +313,7 @@ export default { 'End Date (optional)': '종료 날짜 (선택사항)', 'Clear end date': '종료 날짜 지우기', 'Relay URLs (optional, comma-separated)': '릴레이 URL (선택사항, 쉼표로 구분)', - 'Remove poll': '투표 제거' + 'Remove poll': '투표 제거', + 'Refresh results': '결과 새로 고침' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 2acc3f6e..ea58fc3c 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -28,6 +28,7 @@ export default { 'Unfollow failed': 'Porzucenie obserwacji nieudane', 'show new notes': 'Pokaż nowe wpisy', 'loading...': 'ładowanie...', + 'Loading...': 'Ładowanie...', 'no more notes': 'Koniec wpisów', 'reply to': 'Odpowiedź na', reply: 'odpowiedz', @@ -314,6 +315,7 @@ export default { 'Clear end date': 'Wyczyść datę zakończenia', 'Relay URLs (optional, comma-separated)': 'Adresy URL przekaźników (opcjonalne, oddzielone przecinkami)', - 'Remove poll': 'Usuń ankietę' + 'Remove poll': 'Usuń ankietę', + 'Refresh results': 'Odśwież wyniki' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 02bcf56d..760ba78e 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -28,6 +28,7 @@ export default { 'Unfollow failed': 'Falha ao deixar de seguir', 'show new notes': 'Ver novas notas', 'loading...': 'Carregando...', + 'Loading...': 'Carregando...', 'no more notes': 'Não há mais notas', 'reply to': 'Respondendo a', reply: 'Responder', @@ -313,6 +314,7 @@ export default { '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' + 'Remove poll': 'Remover enquete', + 'Refresh results': 'Atualizar resultados' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 22cadb76..49e1632a 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -29,6 +29,7 @@ export default { 'Unfollow failed': 'Falha ao Deixar de Seguir', 'show new notes': 'mostrar novas notas', 'loading...': 'carregando...', + 'Loading...': 'Carregando...', 'no more notes': 'não há mais notas', 'reply to': 'responder a', reply: 'responder', @@ -314,6 +315,7 @@ export default { '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' + 'Remove poll': 'Remover sondagem', + 'Refresh results': 'Atualizar resultados' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 06da4231..eaf2cb85 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -29,6 +29,7 @@ export default { 'Unfollow failed': 'Ошибка отписки', 'show new notes': 'показать новые заметки', 'loading...': 'загрузка...', + 'Loading...': 'Загрузка...', 'no more notes': 'больше нет заметок', 'reply to': 'ответить', reply: 'ответить', @@ -315,6 +316,7 @@ export default { 'End Date (optional)': 'Дата окончания (необязательно)', 'Clear end date': 'Очистить дату окончания', 'Relay URLs (optional, comma-separated)': 'URL релеев (необязательно, через запятую)', - 'Remove poll': 'Удалить опрос' + 'Remove poll': 'Удалить опрос', + 'Refresh results': 'Обновить результаты' } } diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index afef032d..20304b4c 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -28,6 +28,7 @@ export default { 'Unfollow failed': 'เลิกติดตามไม่สำเร็จ', 'show new notes': 'แสดงโน้ตใหม่', 'loading...': 'กำลังโหลด...', + 'Loading...': 'กำลังโหลด...', 'no more notes': 'ไม่มีโน้ตเพิ่มเติม', 'reply to': 'ตอบกลับถึง', reply: 'ตอบกลับ', @@ -309,6 +310,7 @@ export default { 'End Date (optional)': 'วันที่สิ้นสุด (ไม่บังคับ)', 'Clear end date': 'ล้างวันที่สิ้นสุด', 'Relay URLs (optional, comma-separated)': 'URL รีเลย์ (ไม่บังคับ, คั่นด้วยจุลภาค)', - 'Remove poll': 'ลบโพลล์' + 'Remove poll': 'ลบโพลล์', + 'Refresh results': 'รีเฟรชผลลัพธ์' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index a78bae3d..b7786616 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -28,6 +28,7 @@ export default { 'Unfollow failed': '取消关注失败', 'show new notes': '显示新笔记', 'loading...': '加载中...', + 'Loading...': '加载中...', 'no more notes': '到底了', 'reply to': '回复', reply: '回复', @@ -310,6 +311,7 @@ export default { 'End Date (optional)': '结束日期(可选)', 'Clear end date': '清除结束日期', 'Relay URLs (optional, comma-separated)': '中继服务器 URL(可选,逗号分隔)', - 'Remove poll': '移除投票' + 'Remove poll': '移除投票', + 'Refresh results': '刷新结果' } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 28e832c8..7918cb45 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) } diff --git a/src/services/poll-results.service.ts b/src/services/poll-results.service.ts index b6c9dff8..b15ebbde 100644 --- a/src/services/poll-results.service.ts +++ b/src/services/poll-results.service.ts @@ -1,5 +1,6 @@ import { ExtendedKind } from '@/constants' import { getPollResponseFromEvent } from '@/lib/event-metadata' +import DataLoader from 'dataloader' import dayjs from 'dayjs' import { Filter } from 'nostr-tools' import client from './client.service' @@ -15,6 +16,56 @@ class PollResultsService { static instance: PollResultsService private pollResultsMap: Map = new Map() private pollResultsSubscribers = new Map 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() + 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() { if (!PollResultsService.instance) { @@ -30,6 +81,23 @@ class PollResultsService { isMultipleChoice: boolean, 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 = { kinds: [ExtendedKind.POLL_RESPONSE], '#e': [pollEventId],