Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
79a05f1f87
  1. 8
      src/components/KindFilter/index.tsx
  2. 12
      src/components/NormalFeed/index.tsx
  3. 70
      src/components/Note/Poll.tsx
  4. 1
      src/i18n/locales/de.ts
  5. 1
      src/i18n/locales/en.ts
  6. 93
      src/lib/relay-list-builder.ts
  7. 32
      src/providers/KindFilterProvider.tsx
  8. 7
      src/services/local-storage.service.ts
  9. 27
      src/services/poll-results.service.ts

8
src/components/KindFilter/index.tsx

@ -64,7 +64,7 @@ export default function KindFilter({ @@ -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({ @@ -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({ @@ -119,9 +119,9 @@ export default function KindFilter({
updateShowKinds(newShowKinds, {
showKind1OPs: temporaryShowKind1OPs,
showKind1Replies: temporaryShowKind1Replies,
showKind1111: temporaryShowKind1111
showKind1111: temporaryShowKind1111,
persist: isPersistent
})
setIsPersistent(false)
setOpen(false)
}

12
src/components/NormalFeed/index.tsx

@ -39,7 +39,6 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -39,7 +39,6 @@ const NormalFeed = forwardRef<TNoteListRef, {
) {
const { hideUntrustedNotes } = useUserTrust()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [listMode, setListMode] = useState<TNoteListMode>(() => {
const storedMode = storage.getNoteListMode()
if (isMainFeed) {
@ -73,13 +72,14 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -73,13 +72,14 @@ const NormalFeed = forwardRef<TNoteListRef, {
}
}
const handleShowKindsChange = (newShowKinds: number[]) => {
setTemporaryShowKinds(newShowKinds)
const handleShowKindsChange = (_newShowKinds: number[]) => {
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop()
}
}
const showKindsKey = useMemo(() => JSON.stringify(showKinds), [showKinds])
const tabsElement = (
<Tabs
value={listMode}
@ -88,7 +88,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -88,7 +88,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
options={
<div className="flex items-center gap-1">
{onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />}
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
<KindFilter showKinds={showKinds} onShowKindsChange={handleShowKindsChange} />
</div>
}
/>
@ -98,7 +98,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -98,7 +98,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
if (!isMainFeed || !setSubHeader) return
setSubHeader(tabsElement)
return () => setSubHeader(null)
}, [isMainFeed, setSubHeader, listMode, temporaryShowKinds, onSubHeaderRefresh])
}, [isMainFeed, setSubHeader, listMode, showKindsKey, onSubHeaderRefresh])
const renderTabsInFeed = !(isMainFeed && setSubHeader)
@ -108,7 +108,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -108,7 +108,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
<div className="min-w-0 pt-2">
<NoteList
ref={noteListRef}
showKinds={temporaryShowKinds}
showKinds={showKinds}
showKind1OPs={showKind1OPs}
showKind1Replies={showKind1Replies}
showKind1111={showKind1111}

70
src/components/Note/Poll.tsx

@ -3,7 +3,9 @@ import { FAST_READ_RELAY_URLS, POLL_TYPE } from '@/constants' @@ -3,7 +3,9 @@ import { FAST_READ_RELAY_URLS, POLL_TYPE } from '@/constants'
import { useFetchPollResults } from '@/hooks/useFetchPollResults'
import { createPollResponseDraftEvent } from '@/lib/draft-event'
import { getPollMetadataFromEvent } from '@/lib/event-metadata'
import { buildPollResultsReadRelayUrls } from '@/lib/relay-list-builder'
import { cn, isPartiallyInViewport } from '@/lib/utils'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context'
import pollResultsService from '@/services/poll-results.service'
import dayjs from 'dayjs'
@ -16,16 +18,29 @@ import { toast } from 'sonner' @@ -16,16 +18,29 @@ import { toast } from 'sonner'
import logger from '@/lib/logger'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
/**
* Persists "See results" across remounts (React Strict Mode dev double-mount, list recycle).
* Scoped to this tab session only.
*/
const pollSessionRevealResultIds = new Set<string>()
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<string[]>([])
/** 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 @@ -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 @@ -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 @@ -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 @@ -226,11 +248,18 @@ export default function Poll({ event, className }: { event: Event; className?: s
{showResults && (
<div
className={cn(
'text-muted-foreground shrink-0 z-10 tabular-nums',
'text-muted-foreground shrink-0 z-10 tabular-nums text-right',
isMax ? 'font-semibold text-foreground' : ''
)}
>
{totalVotes > 0 ? `${percentage.toFixed(1)}%` : '0%'}
{isExpired
? t('{{votes}} · {{pct}}%', {
votes,
pct: totalVotes > 0 ? percentage.toFixed(1) : '0'
})
: totalVotes > 0
? `${percentage.toFixed(1)}%`
: '0%'}
</div>
)}
{showResults && (
@ -267,19 +296,22 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -267,19 +296,22 @@ export default function Poll({ event, className }: { event: Event; className?: s
</div>
{canVote && !resultsRevealed && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation()
setResultsRevealed(true)
void fetchResults()
}}
>
{t('See results')}
</Button>
<div className="flex justify-start pt-1">
<Button
type="button"
variant="link"
size="sm"
className="h-auto min-h-0 w-fit max-w-full px-0 py-1 text-xs font-normal text-muted-foreground no-underline hover:text-foreground hover:underline"
onClick={(e) => {
e.stopPropagation()
pollSessionRevealResultIds.add(event.id)
setResultsRevealed(true)
void fetchResults()
}}
>
{t('See results')}
</Button>
</div>
)}
{/* Results Summary */}

1
src/i18n/locales/de.ts

@ -638,6 +638,7 @@ export default { @@ -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',

1
src/i18n/locales/en.ts

@ -642,6 +642,7 @@ export default { @@ -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',

93
src/lib/relay-list-builder.ts

@ -14,6 +14,7 @@ import { normalizeUrl } from '@/lib/url' @@ -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<string>()
@ -291,6 +292,98 @@ export function relayHintsFromEventTags(event: { tags: string[][] }): string[] { @@ -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<string[]> {
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<string>()
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<null>((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

32
src/providers/KindFilterProvider.tsx

@ -25,7 +25,16 @@ type TKindFilterContext = { @@ -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 }) @@ -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)
},
[]

7
src/services/local-storage.service.ts

@ -300,9 +300,12 @@ class LocalStorageService { @@ -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)

27
src/services/poll-results.service.ts

@ -9,7 +9,10 @@ export type TPollResults = { @@ -9,7 +9,10 @@ export type TPollResults = {
totalVotes: number
results: Record<string, Set<string>>
voters: Set<string>
/** 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 { @@ -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 { @@ -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 { @@ -117,16 +118,22 @@ class PollResultsService {
voters: new Set<string>(),
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<typeof response> => 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 { @@ -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

Loading…
Cancel
Save