From 79a05f1f8735342acdc69853c8007cace85bf0ba Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 26 Mar 2026 13:03:57 +0100 Subject: [PATCH] bug-fixes --- src/components/KindFilter/index.tsx | 8 +-- src/components/NormalFeed/index.tsx | 12 ++-- src/components/Note/Poll.tsx | 70 ++++++++++++++------ src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 1 + src/lib/relay-list-builder.ts | 93 +++++++++++++++++++++++++++ src/providers/KindFilterProvider.tsx | 32 +++++++-- src/services/local-storage.service.ts | 7 +- src/services/poll-results.service.ts | 27 +++++--- 9 files changed, 206 insertions(+), 45 deletions(-) diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 7738d6d5..cd52d4d3 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -64,7 +64,7 @@ export default function KindFilter({ const [temporaryShowKind1OPs, setTemporaryShowKind1OPs] = useState(savedShowKind1OPs) const [temporaryShowKind1Replies, setTemporaryShowKind1Replies] = useState(savedShowKind1Replies) const [temporaryShowKind1111, setTemporaryShowKind1111] = useState(savedShowKind1111) - const [isPersistent, setIsPersistent] = useState(false) + const [isPersistent, setIsPersistent] = useState(true) const isDifferentFromSaved = useMemo( () => !isSameKindFilter(showKinds, savedShowKinds), [showKinds, savedShowKinds] @@ -93,7 +93,7 @@ export default function KindFilter({ setTemporaryShowKind1OPs(savedShowKind1OPs) setTemporaryShowKind1Replies(savedShowKind1Replies) setTemporaryShowKind1111(savedShowKind1111) - setIsPersistent(false) + setIsPersistent(true) } }, [open, showKinds, savedShowKind1OPs, savedShowKind1Replies, savedShowKind1111]) @@ -119,9 +119,9 @@ export default function KindFilter({ updateShowKinds(newShowKinds, { showKind1OPs: temporaryShowKind1OPs, showKind1Replies: temporaryShowKind1Replies, - showKind1111: temporaryShowKind1111 + showKind1111: temporaryShowKind1111, + persist: isPersistent }) - setIsPersistent(false) setOpen(false) } diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 1094c6c8..2d27aef3 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -39,7 +39,6 @@ const NormalFeed = forwardRef(() => { const storedMode = storage.getNoteListMode() if (isMainFeed) { @@ -73,13 +72,14 @@ const NormalFeed = forwardRef { - setTemporaryShowKinds(newShowKinds) + const handleShowKindsChange = (_newShowKinds: number[]) => { if (noteListRef && typeof noteListRef !== 'function') { noteListRef.current?.scrollToTop() } } + const showKindsKey = useMemo(() => JSON.stringify(showKinds), [showKinds]) + const tabsElement = ( {onSubHeaderRefresh != null && } - + } /> @@ -98,7 +98,7 @@ const NormalFeed = forwardRef setSubHeader(null) - }, [isMainFeed, setSubHeader, listMode, temporaryShowKinds, onSubHeaderRefresh]) + }, [isMainFeed, setSubHeader, listMode, showKindsKey, onSubHeaderRefresh]) const renderTabsInFeed = !(isMainFeed && setSubHeader) @@ -108,7 +108,7 @@ const NormalFeed = forwardRef () + export default function Poll({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() const nostr = useNostrOptional() const pubkey = nostr?.pubkey ?? null + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const publish = nostr?.publish ?? (async () => { throw new Error('Not logged in') }) const startLogin = nostr?.startLogin ?? (() => {}) const [isVoting, setIsVoting] = useState(false) const [selectedOptionIds, setSelectedOptionIds] = useState([]) /** User chose to view vote breakdown without voting first (card UX). */ - const [resultsRevealed, setResultsRevealed] = useState(false) + const [resultsRevealed, setResultsRevealed] = useState( + () => pollSessionRevealResultIds.has(event.id) + ) + + useEffect(() => { + setResultsRevealed(pollSessionRevealResultIds.has(event.id)) + }, [event.id]) const pollResults = useFetchPollResults(event.id) const [isLoadingResults, setIsLoadingResults] = useState(false) const poll = useMemo(() => getPollMetadataFromEvent(event), [event]) @@ -54,7 +69,13 @@ export default function Poll({ event, className }: { event: Event; className?: s if (!meta) return undefined setIsLoadingResults(true) try { - const relays = await ensurePollRelays(event.pubkey, meta) + const relays = await buildPollResultsReadRelayUrls({ + pollEvent: event, + pollRelayUrls: meta.relayUrls, + viewerPubkey: pubkey, + viewerFavoriteRelayUrls: favoriteRelays, + blockedRelays + }) const optionIds = meta.options.map((o) => o.id) const multi = meta.pollType === POLL_TYPE.MULTIPLE_CHOICE return await pollResultsService.fetchResults( @@ -71,7 +92,7 @@ export default function Poll({ event, className }: { event: Event; className?: s pollResultsViewportFetchDoneRef.current = true setIsLoadingResults(false) } - }, [event]) + }, [event, pubkey, favoriteRelays, blockedRelays]) useEffect(() => { if ( @@ -106,9 +127,10 @@ export default function Poll({ event, className }: { event: Event; className?: s useEffect(() => { if (!poll || !isExpired) return + pollSessionRevealResultIds.add(event.id) setResultsRevealed(true) void fetchResults() - }, [poll, isExpired, fetchResults]) + }, [poll, isExpired, fetchResults, event.id]) if (!poll) { return null @@ -226,11 +248,18 @@ export default function Poll({ event, className }: { event: Event; className?: s {showResults && (
- {totalVotes > 0 ? `${percentage.toFixed(1)}%` : '0%'} + {isExpired + ? t('{{votes}} · {{pct}}%', { + votes, + pct: totalVotes > 0 ? percentage.toFixed(1) : '0' + }) + : totalVotes > 0 + ? `${percentage.toFixed(1)}%` + : '0%'}
)} {showResults && ( @@ -267,19 +296,22 @@ export default function Poll({ event, className }: { event: Event; className?: s {canVote && !resultsRevealed && ( - +
+ +
)} {/* Results Summary */} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 30684dc1..a7fd9c2f 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -638,6 +638,7 @@ export default { 'Relay URLs (optional, comma-separated)': 'Relay-URLs (optional, durch Kommas getrennt)', 'Remove poll': 'Umfrage entfernen', 'Refresh results': 'Ergebnisse aktualisieren', + '{{votes}} · {{pct}}%': '{{votes}} · {{pct}}%', Poll: 'Umfrage', Media: 'Medien', Interests: 'Interessen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7e29edc5..eb202d3c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -642,6 +642,7 @@ export default { 'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)', 'Remove poll': 'Remove poll', 'Refresh results': 'Refresh results', + '{{votes}} · {{pct}}%': '{{votes}} · {{pct}}%', 'See results': 'See results', 'Zap poll (paid votes)': 'Zap poll (paid votes)', 'Invalid zap poll': 'Invalid zap poll', diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 3cd4d467..c362c7f0 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -14,6 +14,7 @@ import { normalizeUrl } from '@/lib/url' import { getCacheRelayUrls } from './private-relays' import client from '@/services/client.service' import logger from '@/lib/logger' +import type { Event } from 'nostr-tools' function dedupeNormalizedRelayUrls(urls: string[]): string[] { const seen = new Set() @@ -291,6 +292,98 @@ export function relayHintsFromEventTags(event: { tags: string[][] }): string[] { return [...out] } +const POLL_RESULTS_RELAY_TIMEOUT_MS = 2000 +const POLL_RESULTS_MAX_RELAYS = 40 +const POLL_RESULTS_NIP65_READ_SLICE = 16 + +/** + * Relays to REQ poll responses (kind 1068 replies), in priority order: + * seen relays, NIP-10 `e`/`E` hints, poll `relay` tags, viewer NIP-65 **read** (inbox), + * favorite relays (kind 10012 from props), viewer cache relays (10432), {@link FAST_READ_RELAY_URLS}, + * poll author NIP-65 **read** (inbox). + */ +export async function buildPollResultsReadRelayUrls(options: { + pollEvent: Event + pollRelayUrls: string[] + viewerPubkey: string | null | undefined + /** From {@link useFavoriteRelays} — avoids a second kind 10012 fetch. */ + viewerFavoriteRelayUrls?: string[] + blockedRelays?: string[] +}): Promise { + const { + pollEvent, + pollRelayUrls, + viewerPubkey, + viewerFavoriteRelayUrls = [], + blockedRelays = [] + } = options + + const normalizedBlocked = new Set( + blockedRelays + .map((url) => (normalizeUrl(url) || url).toLowerCase()) + .filter(Boolean) + ) + + const ordered: string[] = [] + const seenNorm = new Set() + + const pushLayer = (urls: string[]) => { + for (const raw of urls) { + const normalized = normalizeUrl(raw) || raw?.trim() + if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) continue + if (seenNorm.has(normalized)) continue + seenNorm.add(normalized) + ordered.push(normalized) + } + } + + pushLayer(client.getSeenEventRelayUrls(pollEvent.id)) + pushLayer(relayHintsFromEventTags(pollEvent)) + pushLayer(pollRelayUrls) + + const raceRelayList = (pubkey: string) => { + const p = client.fetchRelayList(pubkey) + const t = new Promise((resolve) => + setTimeout(() => resolve(null), POLL_RESULTS_RELAY_TIMEOUT_MS) + ) + return Promise.race([p, t]) + } + + let authorReadSlice: string[] = [] + let viewerReadSlice: string[] = [] + try { + const [authorRl, viewerRl] = await Promise.all([ + pollEvent.pubkey ? raceRelayList(pollEvent.pubkey) : Promise.resolve(null), + viewerPubkey ? raceRelayList(viewerPubkey) : Promise.resolve(null) + ]) + if (authorRl?.read?.length) { + authorReadSlice = authorRl.read.slice(0, POLL_RESULTS_NIP65_READ_SLICE) + } + if (viewerRl?.read?.length) { + viewerReadSlice = viewerRl.read.slice(0, POLL_RESULTS_NIP65_READ_SLICE) + } + } catch { + logger.debug('[RelayListBuilder] poll results: NIP-65 relay list race failed') + } + + pushLayer(viewerReadSlice) + + if (viewerPubkey) { + pushLayer(viewerFavoriteRelayUrls) + try { + const localRelays = await getCacheRelayUrls(viewerPubkey) + pushLayer(localRelays) + } catch { + logger.debug('[RelayListBuilder] poll results: cache relays failed') + } + } + + pushLayer([...FAST_READ_RELAY_URLS]) + pushLayer(authorReadSlice) + + return ordered.slice(0, POLL_RESULTS_MAX_RELAYS) +} + /** * Build relay list for reading replies/comments * READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes diff --git a/src/providers/KindFilterProvider.tsx b/src/providers/KindFilterProvider.tsx index 776e0ba2..36b912eb 100644 --- a/src/providers/KindFilterProvider.tsx +++ b/src/providers/KindFilterProvider.tsx @@ -25,7 +25,16 @@ type TKindFilterContext = { showKind1OPs: boolean showKind1Replies: boolean showKind1111: boolean - updateShowKinds: (kinds: number[], options?: { showKind1OPs?: boolean; showKind1Replies?: boolean; showKind1111?: boolean }) => void + updateShowKinds: ( + kinds: number[], + options?: { + showKind1OPs?: boolean + showKind1Replies?: boolean + showKind1111?: boolean + /** When false, update the live feed only; do not write settings (IndexedDB). Default true. */ + persist?: boolean + } + ) => void updateShowKind1OPs: (value: boolean) => void updateShowKind1Replies: (value: boolean) => void updateShowKind1111: (value: boolean) => void @@ -56,17 +65,28 @@ export function KindFilterProvider({ children }: { children: React.ReactNode }) const [showKind1111, setShowKind1111State] = useState(storedShowKind1111) const updateShowKinds = useCallback( - (newKinds: number[], options?: { showKind1OPs?: boolean; showKind1Replies?: boolean; showKind1111?: boolean }) => { + ( + newKinds: number[], + options?: { + showKind1OPs?: boolean + showKind1Replies?: boolean + showKind1111?: boolean + persist?: boolean + } + ) => { const op = options?.showKind1OPs ?? newKinds.includes(KIND_1) const kind1Replies = options?.showKind1Replies ?? newKinds.includes(KIND_1) const kind1111 = options?.showKind1111 ?? newKinds.includes(KIND_1111) - storage.setShowKind1OPs(op) - storage.setShowKind1Replies(kind1Replies) - storage.setShowKind1111(kind1111) + const persist = options?.persist !== false + if (persist) { + storage.setShowKind1OPs(op) + storage.setShowKind1Replies(kind1Replies) + storage.setShowKind1111(kind1111) + storage.setShowKinds(newKinds) + } setShowKind1OPsState(op) setShowKind1RepliesState(kind1Replies) setShowKind1111State(kind1111) - storage.setShowKinds(newKinds) setShowKindsState(newKinds) }, [] diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 59be3afd..f94211f4 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -300,9 +300,12 @@ class LocalStorageService { } // v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent). this.showKinds = showKinds + // Only persist when we read from localStorage. If SHOW_KINDS is missing here (migrated to IDB and + // keys cleared), persisting would write DEFAULT_FEED_SHOW_KINDS to IndexedDB and wipe the user's + // saved filter before initAsync/applySettings runs. + this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) + this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '10') } - this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) - this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '10') // Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set) const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs) diff --git a/src/services/poll-results.service.ts b/src/services/poll-results.service.ts index c4194bbe..59a73054 100644 --- a/src/services/poll-results.service.ts +++ b/src/services/poll-results.service.ts @@ -9,7 +9,10 @@ export type TPollResults = { totalVotes: number results: Record> voters: Set + /** Wall-clock time of last successful merge (legacy / diagnostics). */ updatedAt: number + /** Latest `created_at` among merged poll responses; used for open-poll incremental `since`. */ + maxResponseCreatedAt?: number } type TFetchPollResultsParams = { @@ -88,6 +91,9 @@ class PollResultsService { isMultipleChoice: boolean, endsAt?: number ) { + const nowSec = dayjs().unix() + const pollIsClosed = endsAt != null && nowSec > endsAt + const filter: Filter = { kinds: [ExtendedKind.POLL_RESPONSE], '#e': [pollEventId], @@ -99,12 +105,7 @@ class PollResultsService { } let results = this.pollResultsMap.get(pollEventId) - if (results) { - if (endsAt && results.updatedAt >= endsAt) { - return results - } - filter.since = results.updatedAt - } else { + if (!results) { results = { totalVotes: 0, results: validPollOptionIds.reduce( @@ -117,16 +118,22 @@ class PollResultsService { voters: new Set(), updatedAt: 0 } + } else if (!pollIsClosed && (results.maxResponseCreatedAt ?? 0) > 0) { + // Open poll: incremental fetch only by latest merged vote timestamp (not wall clock). + filter.since = results.maxResponseCreatedAt } + // Closed poll: never set `since` so we always re-query the full [0, endsAt] window. + // (Using `updatedAt` as `since` or short-circuiting on `updatedAt >= endsAt` was wrong: + // `updatedAt` was wall time, which hid all historical votes and froze empty caches.) const responseEvents = await queryService.fetchEvents(relays, filter) - results.updatedAt = dayjs().unix() - const responses = responseEvents .map((evt) => getPollResponseFromEvent(evt, validPollOptionIds, isMultipleChoice)) .filter((response): response is NonNullable => response !== null) + let maxSeen = results.maxResponseCreatedAt ?? 0 + responses .sort((a, b) => b.created_at - a.created_at) .forEach((response) => { @@ -139,8 +146,12 @@ class PollResultsService { results.results[optionId].add(response.pubkey) } }) + maxSeen = Math.max(maxSeen, response.created_at) }) + results.updatedAt = nowSec + results.maxResponseCreatedAt = maxSeen + this.pollResultsMap.set(pollEventId, { ...results }) this.notifyPollResults(pollEventId) return results