diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 66a80154..ca616f76 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -26,6 +26,10 @@ const NormalFeed = forwardRef void @@ -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( const feedTimelineScopePrevRef = useRef(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( 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( 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( 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) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 781c801b..f77db4b7 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -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({ 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 }) diff --git a/src/components/ReadOnlySessionIndicator.tsx b/src/components/ReadOnlySessionIndicator.tsx new file mode 100644 index 00000000..a2e6eae8 --- /dev/null +++ b/src/components/ReadOnlySessionIndicator.tsx @@ -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 ( +
+ {t('readOnlySession.label')} + + {t('readOnlySession.labelShort')} + +
+ ) + } + + return ( + + {t('readOnlySession.label')} + + ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 8f390b11..2473ee8e 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -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() { +
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c46dc431..7c9b4f60 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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 { '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', diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index 80c3ea6f..ad5596a5 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -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({ }) { return ( - {children} +
+ +
{children}
+
) } diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index aedd386a..dabc764b 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -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({ }): JSX.Element { if (titlebar) { return ( - - {titlebar} + + +
{titlebar}
) } return ( - {hideBackButton ? ( -
- {title} -
- ) : ( -
- {title} -
- )} -
{controls}
+ +
+ {hideBackButton ? ( +
+ {title} +
+ ) : ( +
+ {title} +
+ )} +
{controls}
+
) } diff --git a/src/lib/following-feed-delta.ts b/src/lib/following-feed-delta.ts new file mode 100644 index 00000000..621434c1 --- /dev/null +++ b/src/lib/following-feed-delta.ts @@ -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 { + const s = new Set() + 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() + + 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 +} diff --git a/src/lib/nostr-errors.ts b/src/lib/nostr-errors.ts new file mode 100644 index 00000000..56c6ba3e --- /dev/null +++ b/src/lib/nostr-errors.ts @@ -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' + } +} diff --git a/src/pages/primary/NoteListPage/FollowingFeed.tsx b/src/pages/primary/NoteListPage/FollowingFeed.tsx index 19741ed8..a724c3e5 100644 --- a/src/pages/primary/NoteListPage/FollowingFeed.tsx +++ b/src/pages/primary/NoteListPage/FollowingFeed.tsx @@ -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< const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { feedInfo } = useFeed() const [subRequests, setSubRequests] = useState([]) + const [deltaSubRequests, setDeltaSubRequests] = useState([]) const favoriteRelaysKey = useMemo( () => @@ -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< 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< } 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< ( @@ -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(null) // Cleanup on page unload to prevent extension UI issues @@ -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 }) { }) 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 }) { } 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 }) { 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 }) { 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 }) { } } } + 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 }) { { 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 }) { } 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 }) { } } - const checkLogin = async (cb?: () => T): Promise => { - if (signer) { - return cb && cb() + const checkLogin = async (cb?: () => T | Promise): Promise => { + if (!signer || account?.signerType === 'npub') { + setOpenLoginDialog(true) + return + } + if (cb) { + return await cb() } - return setOpenLoginDialog(true) } const updateRelayListEvent = async (relayListEvent: Event) => { diff --git a/src/providers/nostr-context.tsx b/src/providers/nostr-context.tsx index 5b6b8179..de1b7d2c 100644 --- a/src/providers/nostr-context.tsx +++ b/src/providers/nostr-context.tsx @@ -51,7 +51,7 @@ export type TNostrContext = { nip04Encrypt: (pubkey: string, plainText: string) => Promise nip04Decrypt: (pubkey: string, cipherText: string) => Promise startLogin: () => void - checkLogin: (cb?: () => T) => Promise + checkLogin: (cb?: () => T | Promise) => Promise updateRelayListEvent: (relayListEvent: Event) => Promise updateCacheRelayListEvent: (cacheRelayListEvent: Event) => Promise updateHttpRelayListEvent: (httpRelayListEvent: Event) => Promise diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 11d88ef1..de2aa3dc 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1815,6 +1815,30 @@ class ClientService extends EventTarget { } } + /** + * Append another {@link subscribeTimeline} composite’s 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) */