Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
a16b09b41d
  1. 8
      src/components/NormalFeed/index.tsx
  2. 310
      src/components/NoteList/index.tsx
  3. 4
      src/components/PostEditor/PostContent.tsx
  4. 41
      src/components/ReadOnlySessionIndicator.tsx
  5. 2
      src/components/Sidebar/index.tsx
  6. 8
      src/i18n/locales/en.ts
  7. 6
      src/layouts/PrimaryPageLayout/index.tsx
  8. 18
      src/layouts/SecondaryPageLayout/index.tsx
  9. 65
      src/lib/following-feed-delta.ts
  10. 7
      src/lib/nostr-errors.ts
  11. 46
      src/pages/primary/NoteListPage/FollowingFeed.tsx
  12. 54
      src/providers/NostrProvider/index.tsx
  13. 2
      src/providers/nostr-context.tsx
  14. 24
      src/services/client.service.ts

8
src/components/NormalFeed/index.tsx

@ -26,6 +26,10 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -26,6 +26,10 @@ const NormalFeed = forwardRef<TNoteListRef, {
*/
preserveTimelineOnSubRequestsChange?: boolean
mergeTimelineWhenSubRequestFiltersMatch?: boolean
/** Home following: second subscribe wave (delta relays / new authors); see {@link NoteList}. */
followingFeedDeltaSubRequests?: TFeedSubRequest[]
/** Stable subscription identity; see {@link NoteList} `feedSubscriptionKey`. */
feedSubscriptionKey?: string
/** Home favorite-relays chip scope; see {@link NoteList} `feedTimelineScopeKey`. */
feedTimelineScopeKey?: string
/** Single-relay Explore / chip: kindless REQ (see `SINGLE_RELAY_KINDLESS_REQ_LIMIT` in constants). */
@ -62,6 +66,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -62,6 +66,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
onSubHeaderRefresh,
preserveTimelineOnSubRequestsChange = false,
mergeTimelineWhenSubRequestFiltersMatch = false,
followingFeedDeltaSubRequests,
feedSubscriptionKey,
feedTimelineScopeKey,
useFilterAsIs = false,
clientSideKindFilter = false,
@ -230,8 +236,10 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -230,8 +236,10 @@ const NormalFeed = forwardRef<TNoteListRef, {
hideUntrustedNotes={hideUntrustedNotes}
areAlgoRelays={areAlgoRelays}
relayCapabilityReady={relayCapabilityReady}
feedSubscriptionKey={feedSubscriptionKey}
preserveTimelineOnSubRequestsChange={preserveTimelineOnSubRequestsChange}
mergeTimelineWhenSubRequestFiltersMatch={mergeTimelineWhenSubRequestFiltersMatch}
followingFeedDeltaSubRequests={followingFeedDeltaSubRequests}
feedTimelineScopeKey={feedTimelineScopeKey}
useFilterAsIs={useFilterAsIs}
clientSideKindFilter={clientSideKindFilter}

310
src/components/NoteList/index.tsx

@ -289,6 +289,8 @@ const NoteList = forwardRef( @@ -289,6 +289,8 @@ const NoteList = forwardRef(
* avoid a loading reset.
*/
mergeTimelineWhenSubRequestFiltersMatch = false,
/** Home following: second {@link client.subscribeTimeline} merged into the primary composite key (delta relays / new authors). */
followingFeedDeltaSubRequests,
/**
* When set with {@link preserveTimelineOnSubRequestsChange}: home relay chip / feed mode identity.
* If this string changes (e.g. single relay all favorites), the timeline is cleared even when the new
@ -376,6 +378,7 @@ const NoteList = forwardRef( @@ -376,6 +378,7 @@ const NoteList = forwardRef(
feedSubscriptionKey?: string
preserveTimelineOnSubRequestsChange?: boolean
mergeTimelineWhenSubRequestFiltersMatch?: boolean
followingFeedDeltaSubRequests?: TFeedSubRequest[]
feedTimelineScopeKey?: string
spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
@ -507,6 +510,77 @@ const NoteList = forwardRef( @@ -507,6 +510,77 @@ const NoteList = forwardRef(
)
}, [subRequests])
const followingFeedDeltaSubRequestsKey = useMemo(
() =>
JSON.stringify(
(followingFeedDeltaSubRequests ?? []).map((req) => ({
urls: [...req.urls].map((u) => normalizeUrl(u) || u).filter(Boolean).sort(),
filter: stableSpellFeedFilterKey(req.filter)
}))
),
[followingFeedDeltaSubRequests]
)
const mapLiveSubRequestsForTimeline = useCallback(
(requests: TFeedSubRequest[]) => {
const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]
const seeAllNoSpell = seeAllFeedEvents && !useFilterAsIs
return requests.map(({ urls, filter }) => {
const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT)
if (useFilterAsIs) {
const hasKindsInRequest = Array.isArray(filter.kinds) && filter.kinds.length > 0
if (allowKindlessRelayExplore && urls.length === 1 && !hasKindsInRequest) {
const finalFilter: Filter = {
...filter,
limit: filter.limit ?? RELAY_EXPLORE_LIMIT
}
delete finalFilter.kinds
return { urls, filter: finalFilter }
}
const finalFilter: Filter = { ...filter, limit: baseLimit }
if (clientSideKindFilter) {
if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
delete finalFilter.kinds
}
} else if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
finalFilter.kinds = defaultKinds
}
return { urls, filter: finalFilter }
}
if (seeAllNoSpell) {
const { kinds: _omitKinds, ...rest } = filter
return {
urls,
filter: {
...rest,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}
}
return {
urls,
filter: {
...filter,
kinds: defaultKinds,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}
})
},
[
allowKindlessRelayExplore,
areAlgoRelays,
clientSideKindFilter,
seeAllFeedEvents,
showKinds,
useFilterAsIs
]
)
/** Feed identity for scoping client filter state (timeline key minus unrelated churn where possible). */
const feedClientFilterScopeKey = useMemo(
() => feedTimelineScopeKey ?? feedSubscriptionKey ?? subRequestsKey,
@ -559,6 +633,7 @@ const NoteList = forwardRef( @@ -559,6 +633,7 @@ const NoteList = forwardRef(
const feedTimelineScopePrevRef = useRef<string | undefined>(undefined)
/** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */
const timelineEffectLastRefreshCountRef = useRef(refreshCount)
const followingFeedDeltaCloserRef = useRef<(() => void) | null>(null)
useLayoutEffect(() => {
setFeedTimelineEmptyUiReady(false)
@ -1315,55 +1390,9 @@ const NoteList = forwardRef( @@ -1315,55 +1390,9 @@ const NoteList = forwardRef(
setHasMore(true)
consecutiveEmptyRef.current = 0 // Reset counter on refresh
const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]
const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current
const mappedSubRequests = subRequestsRef.current.map(({ urls, filter }) => {
const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT)
if (useFilterAsIs) {
const hasKindsInRequest = Array.isArray(filter.kinds) && filter.kinds.length > 0
if (allowKindlessRelayExplore && urls.length === 1 && !hasKindsInRequest) {
const finalFilter: Filter = {
...filter,
limit: filter.limit ?? RELAY_EXPLORE_LIMIT
}
delete finalFilter.kinds
return { urls, filter: finalFilter }
}
const finalFilter: Filter = { ...filter, limit: baseLimit }
if (clientSideKindFilter) {
if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
delete finalFilter.kinds
}
} else if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
finalFilter.kinds = defaultKinds
}
return { urls, filter: finalFilter }
}
if (seeAllNoSpell) {
const { kinds: _omitKinds, ...rest } = filter
return {
urls,
filter: {
...rest,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}
}
return {
urls,
filter: {
...filter,
kinds: defaultKinds,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}
})
const mappedSubRequests = mapLiveSubRequestsForTimeline(subRequestsRef.current)
const filterMissingKinds = (f: Filter) => !f.kinds || f.kinds.length === 0
const invalidFilters = mappedSubRequests.filter(({ urls, filter: f }) => {
@ -1755,6 +1784,8 @@ const NoteList = forwardRef( @@ -1755,6 +1784,8 @@ const NoteList = forwardRef(
const snapshotKeyForCleanup = sessionSnapshotIdentityKey
return () => {
effectActive = false
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current)
if (timelinePrefetchDebounceRef.current) {
clearTimeout(timelinePrefetchDebounceRef.current)
@ -1793,7 +1824,190 @@ const NoteList = forwardRef( @@ -1793,7 +1824,190 @@ const NoteList = forwardRef(
allowKindlessRelayExplore,
showAllKinds,
withKindFilter,
onSingleRelayKindlessEmpty
onSingleRelayKindlessEmpty,
mapLiveSubRequestsForTimeline
])
useEffect(() => {
if (oneShotFetch) return
const deltas = followingFeedDeltaSubRequests ?? []
if (deltas.length === 0) {
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
return
}
const tk = timelineKey
if (!tk) return
let deltaActive = true
const mappedDelta = mapLiveSubRequestsForTimeline(deltas)
const seeAllNoSpellDelta = seeAllFeedEventsRef.current && !useFilterAsIsRef.current
const filterMissingKindsDelta = (f: Filter) => !f.kinds || f.kinds.length === 0
const invalidDelta = mappedDelta.filter(({ urls, filter: f }) => {
if (seeAllNoSpellDelta) return false
if (!filterMissingKindsDelta(f)) return false
if (useFilterAsIs && clientSideKindFilter && timelineFilterHasNonKindScope(f)) return false
if (useFilterAsIs && allowKindlessRelayExplore && urls.length === 1) return false
return true
})
if (invalidDelta.length > 0) {
logger.warn('[NoteList] following feed delta: invalid filters, skipping wave', {
invalidCount: invalidDelta.length
})
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
return
}
const eventCapDelta = allowKindlessRelayExplore
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
const narrowDeltaBatch = (evs: Event[]) => {
if (seeAllFeedEventsRef.current) return evs
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs
return evs.filter((e) => showKindsRef.current.includes(e.kind))
}
void (async () => {
try {
const { closer, timelineKey: deltaTk } = await client.subscribeTimeline(
mappedDelta as Array<{ urls: string[]; filter: TSubRequestFilter }>,
{
onEvents: (batch: Event[], eosed: boolean) => {
if (!deltaActive) return
if (batch.length > 0) {
feedRelayReturnedAnyEventRef.current = true
}
const narrowed = narrowDeltaBatch(batch)
const paintDoneBefore = feedPaintLiveRelayDoneRef.current
if (!feedPaintLiveRelayDoneRef.current) {
if (narrowed.length > 0) {
feedPaintLiveRelayDoneRef.current = true
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'live_subscription',
mode: 'rows',
narrowedInBatch: narrowed.length,
batchIncoming: batch.length,
eosed
}
} else if (eosed) {
feedPaintLiveRelayDoneRef.current = true
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'live_subscription',
mode: 'eose_no_visible_rows',
batchIncoming: batch.length,
eosed
}
}
}
if (!paintDoneBefore && feedPaintLiveRelayDoneRef.current) {
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
if (batch.length > 0) {
if (narrowed.length > 0) {
setEvents((prev) => {
const next = mergeEventBatchesById(prev, narrowed, eventCapDelta)
lastEventsForTimelinePrefetchRef.current = next
return next
})
setLoading(false)
} else if (eosed) {
setLoading(false)
}
} else if (eosed) {
setLoading(false)
}
if (!areAlgoRelays && eosed) {
setHasMore(true)
}
},
onNew: (event: Event) => {
if (!deltaActive) return
feedRelayReturnedAnyEventRef.current = true
if (!seeAllFeedEventsRef.current && withKindFilterRef.current) {
const kindlessFirehose =
allowKindlessRelayExploreRef.current && showAllKindsRef.current
if (!kindlessFirehose) {
if (!useFilterAsIsRef.current && !showKinds.includes(event.kind)) return
if (
clientSideKindFilterRef.current &&
useFilterAsIsRef.current &&
!showKinds.includes(event.kind)
)
return
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return
if (!isReply && !showKind1OPs) return
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return
}
}
if (shouldHideEventRef.current(event)) return
if (pubkey && event.pubkey === pubkey) {
setEvents((oldEvents) =>
oldEvents.some((e) => e.id === event.id) ? oldEvents : [event, ...oldEvents]
)
} else {
setNewEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
}
}
},
{
startLogin,
needSort: !areAlgoRelays,
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS
}
)
if (!deltaActive) {
closer()
return
}
const addedLeaves = client.appendTimelinesToComposite(tk, deltaTk)
const innerClose = closer
const tkForLeafRemoval = tk
followingFeedDeltaCloserRef.current = () => {
innerClose()
if (tkForLeafRemoval && addedLeaves.length > 0) {
client.removeTimelineLeavesFromComposite(tkForLeafRemoval, addedLeaves)
}
}
} catch (e) {
logger.warn('[NoteList] following feed delta subscribe failed', { error: e })
}
})()
return () => {
deltaActive = false
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
}
}, [
followingFeedDeltaSubRequestsKey,
timelineKey,
oneShotFetch,
mapLiveSubRequestsForTimeline,
areAlgoRelays,
allowKindlessRelayExplore,
useFilterAsIs,
clientSideKindFilter,
startLogin,
pubkey,
showKinds,
showKind1OPs,
showKind1Replies,
showKind1111
])
const oneShotDebugPrevLoadingRef = useRef(false)

4
src/components/PostEditor/PostContent.tsx

@ -40,6 +40,7 @@ import { useReply } from '@/providers/ReplyProvider' @@ -40,6 +40,7 @@ import { useReply } from '@/providers/ReplyProvider'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { normalizeUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import logger from '@/lib/logger'
import { LoginRequiredError } from '@/lib/nostr-errors'
import postEditorCache from '@/services/post-editor-cache.service'
import storage from '@/services/local-storage.service'
import { TPollCreateData } from '@/types'
@ -1160,6 +1161,9 @@ export default function PostContent({ @@ -1160,6 +1161,9 @@ export default function PostContent({
onPublishSuccess?.()
close()
} catch (error) {
if (error instanceof LoginRequiredError) {
return
}
// AggregateError = "Failed to publish to any relay" is already logged in NostrProvider with relayStatuses; avoid duplicate noise
if (!(error instanceof AggregateError && error.message === 'Failed to publish to any relay')) {
logger.error('Publishing error', { error })

41
src/components/ReadOnlySessionIndicator.tsx

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useTranslation } from 'react-i18next'
type TVariant = 'sidebar' | 'titlebar'
export function ReadOnlySessionIndicator({ variant }: { variant: TVariant }) {
const { t } = useTranslation()
const { account } = useNostr()
if (account?.signerType !== 'npub') return null
const hint = t('readOnlySession.hint')
if (variant === 'sidebar') {
return (
<div
role="status"
title={hint}
className={cn(
'mb-1 w-full px-0.5 text-center text-[10px] font-medium uppercase tracking-wide',
'text-muted-foreground/85'
)}
>
<span className="max-xl:hidden">{t('readOnlySession.label')}</span>
<span className="xl:hidden" aria-label={t('readOnlySession.label')}>
{t('readOnlySession.labelShort')}
</span>
</div>
)
}
return (
<span
role="status"
title={hint}
className="shrink-0 rounded border border-border/40 bg-muted/20 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90"
>
{t('readOnlySession.label')}
</span>
)
}

2
src/components/Sidebar/index.tsx

@ -16,6 +16,7 @@ import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysAct @@ -16,6 +16,7 @@ import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysAct
import PaneModeToggle from './PaneModeToggle'
import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton'
import LiveActivitiesStrip from '@/components/LiveActivitiesStrip'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
export default function PrimaryPageSidebar() {
const { isSmallScreen } = useScreenSize()
@ -33,6 +34,7 @@ export default function PrimaryPageSidebar() { @@ -33,6 +34,7 @@ export default function PrimaryPageSidebar() {
</div>
</div>
</div>
<ReadOnlySessionIndicator variant="sidebar" />
<div className="max-xl:hidden w-full min-w-0 px-1">
<LiveActivitiesStrip placement="sidebar" />
</div>

8
src/i18n/locales/en.ts

@ -310,6 +310,12 @@ export default { @@ -310,6 +310,12 @@ export default {
'Login with Bunker': 'Login with Bunker',
'Login with Private Key': 'Login with Private Key',
'Login with npub (read-only)': 'Login with npub (read-only)',
readOnlySession: {
label: 'Read-only',
labelShort: 'R/O',
hint:
'Browsing without a signing key. Sign in with an extension, nsec, or another method to post, react, and edit.'
},
'reload notes': 'reload notes',
'Logged in Accounts': 'Logged in Accounts',
'Add an Account': 'Add an Account',
@ -1442,6 +1448,8 @@ export default { @@ -1442,6 +1448,8 @@ export default {
'Log in to run this spell (it uses $me or $contacts).':
'Log in to run this spell (it uses $me or $contacts).',
'Login failed': 'Login failed',
'nip07.extensionKeyMismatch':
'Your browser wallet is using a different key than this saved account. Select the matching key in the extension, or log in to add this wallet as an account. Retrying will not help until the keys match.',
'Login to configure RSS feeds': 'Login to configure RSS feeds',
'Long-form Article': 'Long-form Article',
'Mailbox relays saved': 'Mailbox relays saved',

6
src/layouts/PrimaryPageLayout/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import ScrollToTopButton from '@/components/ScrollToTopButton'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
import { Titlebar } from '@/components/Titlebar'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import type { TPrimaryPageName } from '@/PageManager'
@ -159,7 +160,10 @@ function PrimaryPageTitlebar({ @@ -159,7 +160,10 @@ function PrimaryPageTitlebar({
}) {
return (
<Titlebar className="p-1" hideBottomBorder={hideBottomBorder}>
{children}
<div className="flex h-full w-full min-w-0 items-center gap-2">
<ReadOnlySessionIndicator variant="titlebar" />
<div className="relative min-h-0 min-w-0 flex-1 h-full">{children}</div>
</div>
</Titlebar>
)
}

18
src/layouts/SecondaryPageLayout/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import ScrollToTopButton from '@/components/ScrollToTopButton'
import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator'
import { Titlebar } from '@/components/Titlebar'
import { Button } from '@/components/ui/button'
import {
@ -162,26 +163,33 @@ export function SecondaryPageTitlebar({ @@ -162,26 +163,33 @@ export function SecondaryPageTitlebar({
}): JSX.Element {
if (titlebar) {
return (
<Titlebar className="p-1" hideBottomBorder={hideBottomBorder}>
{titlebar}
<Titlebar
className="flex min-w-0 items-center gap-2 p-1"
hideBottomBorder={hideBottomBorder}
>
<ReadOnlySessionIndicator variant="titlebar" />
<div className="min-h-0 min-w-0 flex-1 h-full">{titlebar}</div>
</Titlebar>
)
}
return (
<Titlebar
className="flex gap-1 p-1 items-center justify-between font-semibold"
className="flex min-w-0 gap-1 p-1 items-center font-semibold"
hideBottomBorder={hideBottomBorder}
>
<ReadOnlySessionIndicator variant="titlebar" />
<div className="flex min-w-0 flex-1 items-center justify-between gap-1">
{hideBackButton ? (
<div className="flex gap-2 items-center pl-3 w-fit truncate text-lg font-semibold">
<div className="flex gap-2 items-center pl-2 w-fit truncate text-lg font-semibold">
{title}
</div>
) : (
<div className="flex items-center flex-1 w-0">
<div className="flex min-w-0 flex-1 items-center">
<BackButton>{title}</BackButton>
</div>
)}
<div className="flex-shrink-0">{controls}</div>
</div>
</Titlebar>
)
}

65
src/lib/following-feed-delta.ts

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity'
import type { TFeedSubRequest } from '@/types'
import { normalizeUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import type { Filter } from 'nostr-tools'
function normalizedRelayUrlSet(requests: TFeedSubRequest[]): Set<string> {
const s = new Set<string>()
for (const r of requests) {
for (const u of r.urls) {
const n = normalizeUrl(u) || u.trim()
if (n) s.add(n)
}
}
return s
}
function dedupeShardKey(urls: string[], filter: Filter): string {
const nu = [...urls].map((u) => normalizeUrl(u) || u).filter(Boolean).sort()
return `${nu.join('\0')}|${stableSpellFeedFilterKey(filter)}`
}
/**
* Second-wave REQ shards for the home following feed: relays and/or author groups not covered by the
* provisional (kind-3 tags) subscription. Keeps the first subscription open and avoids "closed by caller" churn.
*/
export function buildFollowingFeedDeltaSubRequests(
fullAugmented: TFeedSubRequest[],
provisionalAugmented: TFeedSubRequest[],
provisionalAuthorHexes: string[]
): TFeedSubRequest[] {
if (fullAugmented.length === 0) return []
const rProv = normalizedRelayUrlSet(provisionalAugmented)
const rProvList = [...rProv]
const aProv = new Set(provisionalAuthorHexes.map((p) => p.toLowerCase()))
const out: TFeedSubRequest[] = []
const seen = new Set<string>()
for (const req of fullAugmented) {
const filter = req.filter as Filter
const authorsRaw = Array.isArray(filter.authors) ? filter.authors : []
const authors = authorsRaw.map((x) => (typeof x === 'string' ? x.toLowerCase() : x)) as string[]
const uDelta = subtractNormalizedRelayUrls(req.urls, rProvList)
const authorsNew = authors.filter((a) => typeof a === 'string' && a.length === 64 && !aProv.has(a))
const pushIfNew = (urls: string[], f: Filter) => {
if (urls.length === 0) return
const k = dedupeShardKey(urls, f)
if (seen.has(k)) return
seen.add(k)
out.push({ ...req, urls, filter: f })
}
if (uDelta.length > 0) {
pushIfNew(uDelta, { ...filter, authors } as Filter)
}
if (authorsNew.length > 0) {
pushIfNew(req.urls, { ...filter, authors: authorsNew } as Filter)
}
}
return out
}

7
src/lib/nostr-errors.ts

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
/** Login dialog is already opened; callers should not treat this as an application failure. */
export class LoginRequiredError extends Error {
constructor(message = 'LOGIN_REQUIRED') {
super(message)
this.name = 'LoginRequiredError'
}
}

46
src/pages/primary/NoteListPage/FollowingFeed.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList'
import { augmentSubRequestsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays'
import { buildFollowingFeedDeltaSubRequests } from '@/lib/following-feed-delta'
import { getPubkeysFromPTags } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url'
import logger from '@/lib/logger'
@ -23,6 +24,7 @@ const FollowingFeed = forwardRef< @@ -23,6 +24,7 @@ const FollowingFeed = forwardRef<
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { feedInfo } = useFeed()
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [deltaSubRequests, setDeltaSubRequests] = useState<TFeedSubRequest[]>([])
const favoriteRelaysKey = useMemo(
() =>
@ -61,14 +63,22 @@ const FollowingFeed = forwardRef< @@ -61,14 +63,22 @@ const FollowingFeed = forwardRef<
[relayList?.write]
)
const followingFeedSubscriptionKey = useMemo(
() => (pubkey ? `home-following:${pubkey.toLowerCase()}` : undefined),
[pubkey]
)
useEffect(() => {
let cancelled = false
async function init() {
if (feedInfo.feedType !== 'following' || !pubkey) {
setSubRequests([])
setDeltaSubRequests([])
return
}
setDeltaSubRequests([])
const augment = (raw: TFeedSubRequest[]) =>
augmentSubRequestsWithFavoritesFastReadAndInbox(
raw,
@ -80,13 +90,16 @@ const FollowingFeed = forwardRef< @@ -80,13 +90,16 @@ const FollowingFeed = forwardRef<
const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
const provisionalAuthors = [...new Set([pubkey, ...fromTags])]
const provisionalAuthorLower = provisionalAuthors.map((p) => p.toLowerCase())
let rawProv: TFeedSubRequest[] = []
try {
const rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey)
if (!cancelled) setSubRequests(augment(rawProv))
rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey)
} catch (error) {
logger.warn('[FollowingFeed] provisional generateSubRequestsForPubkeys failed', { error })
}
const provAug = augment(rawProv)
if (!cancelled) setSubRequests(provAug)
let followings: string[] = fromTags
try {
@ -100,19 +113,25 @@ const FollowingFeed = forwardRef< @@ -100,19 +113,25 @@ const FollowingFeed = forwardRef<
}
const fullAuthors = [...new Set([pubkey, ...followings])]
const sameSize = fullAuthors.length === provisionalAuthors.length
const sameSet =
sameSize && fullAuthors.every((p) => provisionalAuthors.includes(p)) && provisionalAuthors.every((p) => fullAuthors.includes(p))
if (sameSet) {
return
}
try {
const raw = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey)
if (!cancelled) setSubRequests(augment(raw))
const rawFull = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey)
if (cancelled) return
const fullAug = augment(rawFull)
const delta = buildFollowingFeedDeltaSubRequests(fullAug, provAug, provisionalAuthorLower)
if (!cancelled) {
setDeltaSubRequests(delta)
if (delta.length > 0) {
logger.info('[FollowingFeed] delta wave subRequests', {
deltaShardCount: delta.length,
provisionalShardCount: provAug.length,
fullShardCount: fullAug.length
})
}
}
} catch (error) {
logger.error('[FollowingFeed] generateSubRequestsForPubkeys failed', error)
if (!cancelled) setSubRequests([])
logger.error('[FollowingFeed] full generateSubRequestsForPubkeys failed', error)
if (!cancelled) setDeltaSubRequests([])
}
}
@ -134,8 +153,9 @@ const FollowingFeed = forwardRef< @@ -134,8 +153,9 @@ const FollowingFeed = forwardRef<
<NormalFeed
ref={ref}
subRequests={subRequests}
followingFeedDeltaSubRequests={deltaSubRequests}
feedSubscriptionKey={followingFeedSubscriptionKey}
preserveTimelineOnSubRequestsChange
mergeTimelineWhenSubRequestFiltersMatch
isMainFeed
setSubHeader={setSubHeader}
onSubHeaderRefresh={onSubHeaderRefresh}

54
src/providers/NostrProvider/index.tsx

@ -21,6 +21,7 @@ import { @@ -21,6 +21,7 @@ import {
import { getLatestEvent, minePow } from '@/lib/event'
import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { LoginRequiredError } from '@/lib/nostr-errors'
import { normalizeHttpRelayUrl, normalizeUrl } from '@/lib/url'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
@ -86,6 +87,12 @@ function blockedRelayUrlsFromEvent(blockedRelaysEvent: Event | null): string[] { @@ -86,6 +87,12 @@ function blockedRelayUrlsFromEvent(blockedRelaysEvent: Event | null): string[] {
return out
}
const NIP07_SIGNER_PUBKEY_MISMATCH_MSG = 'Signer pubkey does not match current account'
function isNip07SignerPubkeyMismatchError(e: unknown): boolean {
return e instanceof Error && e.message === NIP07_SIGNER_PUBKEY_MISMATCH_MSG
}
export function NostrProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const [accounts, setAccounts] = useState<TAccountPointer[]>(
@ -98,6 +105,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -98,6 +105,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [openLoginDialog, setOpenLoginDialog] = useState(false)
const [ncryptsecPasswordOpen, setNcryptsecPasswordOpen] = useState(false)
const ncryptsecPasswordResolveRef = useRef<((value: string | null) => void) | null>(null)
/** One toast per mismatch episode; cleared after a successful NIP-07 login. */
const nip07KeyMismatchToastShownRef = useRef(false)
const [profile, setProfile] = useState<TProfile | null>(null)
// Cleanup on page unload to prevent extension UI issues
@ -831,7 +840,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -831,7 +840,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
await nip07Signer.init()
const pubkey = await nip07Signer.getPublicKey()
if (pubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) {
throw new Error('Signer pubkey does not match current account')
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
}
login(nip07Signer, preferred)
logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', {
@ -840,6 +849,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -840,6 +849,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
})
return
} catch (error) {
if (isNip07SignerPubkeyMismatchError(error)) {
if (!nip07KeyMismatchToastShownRef.current) {
nip07KeyMismatchToastShownRef.current = true
toast.error(t('nip07.extensionKeyMismatch'), { duration: 12_000 })
}
attempts = maxAttempts
return
}
logger.info('[NostrProvider] NIP-07 recovery retry failed', {
pubkeySlice: preferred.pubkey.slice(0, 12),
attempts,
@ -875,6 +892,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -875,6 +892,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const login = (signer: ISigner, act: TAccount) => {
if (act.signerType === 'nip-07') {
nip07KeyMismatchToastShownRef.current = false
}
const newAccounts = storage.addAccount(act)
setAccounts(newAccounts)
storage.switchAccount(act)
@ -1071,10 +1091,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1071,10 +1091,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
await nip07Signer.init()
const pubkey = await nip07Signer.getPublicKey()
if (pubkey.toLowerCase() !== storedAccount.pubkey.toLowerCase()) {
throw new Error('Signer pubkey does not match current account')
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
}
return login(nip07Signer, storedAccount)
} catch (err) {
let lastNip07Err: unknown = err
// One short retry avoids transient extension injection races on reload.
try {
await new Promise((resolve) => setTimeout(resolve, 1200))
@ -1082,10 +1103,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1082,10 +1103,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
await retrySigner.init()
const retryPubkey = await retrySigner.getPublicKey()
if (retryPubkey.toLowerCase() !== storedAccount.pubkey.toLowerCase()) {
throw new Error('Signer pubkey does not match current account')
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
}
return login(retrySigner, storedAccount)
} catch (retryErr) {
lastNip07Err = retryErr
// If this tab already has a working nip-07 signer for the same account, keep it.
if (
currentAccountState?.pubkey === storedAccount.pubkey &&
@ -1105,6 +1127,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1105,6 +1127,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
}
}
if (
(isNip07SignerPubkeyMismatchError(err) || isNip07SignerPubkeyMismatchError(lastNip07Err)) &&
!nip07KeyMismatchToastShownRef.current
) {
nip07KeyMismatchToastShownRef.current = true
toast.error(t('nip07.extensionKeyMismatch'), { duration: 12_000 })
}
return fallbackToReadOnlyNpub(storedAccount.pubkey, err)
}
} else if (storedAccount.signerType === 'bunker') {
@ -1224,7 +1253,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1224,7 +1253,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
{ minPow = 0, ...options }: TPublishOptions = {}
) => {
if (!account || !signer || account.signerType === 'npub') {
throw new Error('You need to login first')
setOpenLoginDialog(true)
throw new LoginRequiredError()
}
// Validate account state before publishing
@ -1355,8 +1385,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1355,8 +1385,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const attemptDelete = async (targetEvent: Event) => {
if (!signer) {
throw new Error(t('You need to login first'))
if (!signer || account?.signerType === 'npub') {
setOpenLoginDialog(true)
return
}
if (account?.pubkey !== targetEvent.pubkey) {
throw new Error(t('You can only delete your own notes'))
@ -1413,11 +1444,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1413,11 +1444,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
}
const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
if (signer) {
return cb && cb()
const checkLogin = async <T,>(cb?: () => T | Promise<T>): Promise<T | void> => {
if (!signer || account?.signerType === 'npub') {
setOpenLoginDialog(true)
return
}
if (cb) {
return await cb()
}
return setOpenLoginDialog(true)
}
const updateRelayListEvent = async (relayListEvent: Event) => {

2
src/providers/nostr-context.tsx

@ -51,7 +51,7 @@ export type TNostrContext = { @@ -51,7 +51,7 @@ export type TNostrContext = {
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
startLogin: () => void
checkLogin: <T>(cb?: () => T) => Promise<T | void>
checkLogin: <T>(cb?: () => T | Promise<T>) => Promise<T | void>
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
updateCacheRelayListEvent: (cacheRelayListEvent: Event) => Promise<void>
updateHttpRelayListEvent: (httpRelayListEvent: Event) => Promise<void>

24
src/services/client.service.ts

@ -1815,6 +1815,30 @@ class ClientService extends EventTarget { @@ -1815,6 +1815,30 @@ class ClientService extends EventTarget {
}
}
/**
* Append another {@link subscribeTimeline} composites leaf keys onto `primaryCompositeKey` so
* {@link loadMoreTimeline} fans out to both waves. Removes `secondaryCompositeKey` from the map after merge.
* Returns the leaf keys that were appended (for removal when the delta wave closes).
*/
appendTimelinesToComposite(primaryCompositeKey: string, secondaryCompositeKey: string): string[] {
const primary = this.timelines[primaryCompositeKey]
const secondary = this.timelines[secondaryCompositeKey]
if (!Array.isArray(primary) || !Array.isArray(secondary)) return []
const added = secondary.slice()
this.timelines[primaryCompositeKey] = [...primary, ...added]
delete this.timelines[secondaryCompositeKey]
return added
}
/** Undo part of {@link appendTimelinesToComposite} when a delta subscription is torn down. */
removeTimelineLeavesFromComposite(compositeKey: string, leafKeys: string[]): void {
if (leafKeys.length === 0) return
const t = this.timelines[compositeKey]
if (!Array.isArray(t)) return
const drop = new Set(leafKeys)
this.timelines[compositeKey] = t.filter((k) => !drop.has(k))
}
/**
* Check if a timeline has more events available (either cached or from network)
*/

Loading…
Cancel
Save