diff --git a/src/components/BottomNavigationBar/WriteButton.tsx b/src/components/BottomNavigationBar/WriteButton.tsx index 404265e1..9d54bb5b 100644 --- a/src/components/BottomNavigationBar/WriteButton.tsx +++ b/src/components/BottomNavigationBar/WriteButton.tsx @@ -18,20 +18,20 @@ export default function WriteButton() { return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest) }, [canSignEvents, checkLogin]) - if (!canSignEvents) return null - return ( <> - { - e.stopPropagation() - checkLogin(() => { - setOpen(true) - }) - }} - > - - + {canSignEvents ? ( + { + e.stopPropagation() + checkLogin(() => { + setOpen(true) + }) + }} + > + + + ) : null} ) diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index 3f02bd5e..58ff1f0e 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -29,8 +29,6 @@ import { useFetchProfile } from '@/hooks/useFetchProfile' import { useNostr } from '@/providers/NostrProvider' import { AccountQuickSwitchMenuItems } from '@/components/AccountQuickSwitchMenuItems' import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' -import { ActiveRelaysDropdownSection } from '@/components/ConnectedRelays/ActiveRelaysDropdownSection' -import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows' import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' @@ -84,7 +82,6 @@ function AccountDropdownItems({ {t('Browse Cache')} - @@ -239,31 +236,11 @@ function TitlebarAccountMenu({ function LoggedOutTitlebarMenu({ onLogin }: { onLogin: () => void }) { const { t } = useTranslation() - const { rows } = useRelayConnectionRows() - - if (rows.length === 0) { - return ( - - ) - } return ( - - - - - - - - {t('Login')} - - - - + ) } diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 9457a18f..0a1232de 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -46,7 +46,7 @@ import { } from '@/constants' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' -import { useReply } from '@/providers/ReplyProvider' +import { useReplyIngress } from '@/hooks/useReplyIngress' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' import { cleanUrl, isBlossomBudBlobUrl, rewritePlainTextHttpUrls } from '@/lib/url' import logger from '@/lib/logger' @@ -199,7 +199,7 @@ export default function PostContent({ }) { const { t, i18n } = useTranslation() const { pubkey, publish, checkLogin, canSignEvents } = useNostr() - const { addReplies } = useReply() + const { addReplies } = useReplyIngress() const mergePublishedReplyIntoThread = useCallback( (reply: Event, relayStatuses?: TRelayPublishStatus[]) => { @@ -742,6 +742,7 @@ export default function PostContent({ }, [getDeterminedKind, defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag]) const prevComposerShellOpenRef = useRef(open) + const prevComposerPubkeyRef = useRef(pubkey) useEffect(() => { const wasOpen = prevComposerShellOpenRef.current prevComposerShellOpenRef.current = open @@ -750,6 +751,18 @@ export default function PostContent({ } }, [open, getDeterminedKind, defaultContent, parentEvent]) + useEffect(() => { + if (!open) { + prevComposerPubkeyRef.current = pubkey + return + } + const prevPk = prevComposerPubkeyRef.current + prevComposerPubkeyRef.current = pubkey + if (prevPk && pubkey && prevPk !== pubkey && !advancedLabOpenRef.current) { + textareaRef.current?.syncFromPostCache() + } + }, [open, pubkey]) + const rssReplyExtraPreviewTags = useMemo((): string[][] | undefined => { if (!parentEvent || parentEvent.kind !== ExtendedKind.RSS_THREAD_ROOT) return undefined const raw = @@ -1245,6 +1258,10 @@ export default function PostContent({ const post = async (e?: React.MouseEvent) => { e?.stopPropagation() checkLogin(async () => { + if (!canSignEvents) { + toast.error(t('readOnlySession.cannotPublish')) + return + } if (!canPost) { logger.warn('Attempted to post while canPost is false') return @@ -1387,6 +1404,16 @@ export default function PostContent({ close() } catch (error) { if (error instanceof LoginRequiredError) { + toast.error(t('readOnlySession.cannotPublish')) + return + } + const message = error instanceof Error ? error.message : String(error) + if ( + message === t('Cancelled') || + message.includes('Signer pubkey does not match') || + message.includes(t('nip07.publishExtensionMismatch')) + ) { + toast.error(t('nip07.publishExtensionMismatch')) return } // AggregateError = "Failed to publish to any relay" is already logged in NostrProvider with relayStatuses; avoid duplicate noise @@ -3524,7 +3551,13 @@ export default function PostContent({ {open ? ( - + ) : null} {/* Media Kind Selection Dialog */} diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index 46b1e966..6033a60e 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -17,7 +17,9 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import { pubkeyToNpub } from '@/lib/pubkey' import postEditor from '@/services/post-editor.service' import { Event } from 'nostr-tools' -import { Dispatch, useMemo } from 'react' +import postEditorService from '@/services/post-editor.service' +import { Dispatch, useEffect, useMemo } from 'react' +import { useNostr } from '@/providers/NostrProvider' import type { TDiscussionDynamicTopics } from '@/lib/discussion-thread-composer' import PostContent from './PostContent' @@ -46,6 +48,16 @@ export default function PostEditor({ discussionDynamicTopics?: TDiscussionDynamicTopics | null }) { const { isSmallScreen } = useScreenSize() + const { isAccountSessionHydrating, isNip07LoginInFlight } = useNostr() + + useEffect(() => { + if (!open) return + postEditorService.setComposerShellOpen(true) + return () => postEditorService.setComposerShellOpen(false) + }, [open]) + + const blockDismissForAccountSwitch = + isAccountSessionHydrating || isNip07LoginInFlight const effectiveDefaultContent = useMemo(() => { if (initialPublicMessageTo) { @@ -89,6 +101,12 @@ export default function PostEditor({ className="h-full w-full max-w-full p-0 border-none overflow-hidden" side="bottom" hideClose + onInteractOutside={(e) => { + if (blockDismissForAccountSwitch) e.preventDefault() + }} + onPointerDownOutside={(e) => { + if (blockDismissForAccountSwitch) e.preventDefault() + }} onEscapeKeyDown={(e) => { if (postEditor.isSuggestionPopupOpen) { e.preventDefault() @@ -115,6 +133,12 @@ export default function PostEditor({ { + if (blockDismissForAccountSwitch) e.preventDefault() + }} + onPointerDownOutside={(e) => { + if (blockDismissForAccountSwitch) e.preventDefault() + }} onEscapeKeyDown={(e) => { if (postEditor.isSuggestionPopupOpen) { e.preventDefault() diff --git a/src/components/RefreshButton/index.tsx b/src/components/RefreshButton/index.tsx index e29454c0..a6118475 100644 --- a/src/components/RefreshButton/index.tsx +++ b/src/components/RefreshButton/index.tsx @@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { useLongPressAction } from '@/hooks/use-long-press-action' import { hardReloadPreservingFeedSnapshots } from '@/services/session-feed-snapshot.service' -import { RefreshCcw } from 'lucide-react' +import { RefreshCw } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -46,12 +46,13 @@ export function RefreshButton({ onClick() setTimeout(() => setRefreshing(false), 500) }} - className="h-8 shrink-0 px-1.5 text-muted-foreground focus:text-foreground [&_svg]:size-3" + className="shrink-0 text-muted-foreground focus:text-foreground" + aria-label={t('Refresh')} > {refreshing ? ( - + ) : ( - + )} ) diff --git a/src/components/Sidebar/PostButton.tsx b/src/components/Sidebar/PostButton.tsx index 7091501c..229fa1d9 100644 --- a/src/components/Sidebar/PostButton.tsx +++ b/src/components/Sidebar/PostButton.tsx @@ -18,25 +18,27 @@ export default function PostButton() { return () => postEditorService.removeEventListener('requestOpenNewPost', onRequest) }, [canSignEvents, checkLogin]) - if (!canSignEvents) return null - return ( -
- { - e.stopPropagation() - checkLogin(() => { - setOpen(true) - }) - }} - variant="default" - className="bg-primary-active hover:bg-primary-hover active:bg-primary-active xl:justify-center gap-2" - > - - + <> + {canSignEvents ? ( +
+ { + e.stopPropagation() + checkLogin(() => { + setOpen(true) + }) + }} + variant="default" + className="bg-primary-active hover:bg-primary-hover active:bg-primary-active xl:justify-center gap-2" + > + + +
+ ) : null} -
+ ) } diff --git a/src/components/StoredAccountSwitchSelect.tsx b/src/components/StoredAccountSwitchSelect.tsx index aced6bb6..ed6acc07 100644 --- a/src/components/StoredAccountSwitchSelect.tsx +++ b/src/components/StoredAccountSwitchSelect.tsx @@ -5,6 +5,7 @@ import { accountPubkeyToHex, formatPubkey, hexPubkeysEqual, normalizeHexPubkey } import { cn } from '@/lib/utils' import { Nip07Signer } from '@/providers/NostrProvider/nip-07.signer' import { useNostr } from '@/providers/NostrProvider' +import storage from '@/services/local-storage.service' import type { TAccountPointer } from '@/types' import { Loader2, X } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -22,6 +23,8 @@ type Props = { withTopBorder?: boolean /** Align chips to the end (e.g. beside the publish button). */ alignEnd?: boolean + /** Post composer: keep clicks from bubbling to the dialog/sheet dismiss layer. */ + inComposer?: boolean } const EXTENSION_SYNC_HINT_DISMISSED_PREFIX = 'extensionSyncHintDismissed:' @@ -44,7 +47,8 @@ export default function StoredAccountSwitchSelect({ showLabelAlways = false, withBottomBorder = false, withTopBorder = false, - alignEnd = false + alignEnd = false, + inComposer = false }: Props) { const { t } = useTranslation() const { @@ -158,11 +162,17 @@ export default function StoredAccountSwitchSelect({ toast.error(t('notificationsSwitchAccountFailed')) return } + if (inComposer && nextAccount.signerType === 'nip-07') { + const current = storage.getCurrentAccount() + if (current?.signerType === 'npub' && hexPubkeysEqual(current.pubkey, switched)) { + toast.error(t('accountSwitch.composerExtensionMismatch')) + } + } } finally { setSwitchingKey(null) } }, - [account, switchAccount, retryNip07SignerForPreferredAccount, t] + [account, switchAccount, retryNip07SignerForPreferredAccount, t, inComposer] ) const handleRetryExtension = useCallback(async () => { @@ -257,7 +267,10 @@ export default function StoredAccountSwitchSelect({ : 'ring-transparent hover:ring-muted-foreground/35', busy && !isSwitching && 'opacity-50' )} - onClick={() => void handlePick(act)} + onClick={(e) => { + if (inComposer) e.stopPropagation() + void handlePick(act) + }} > {isSwitching ? ( diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index f060abbb..e17c68d6 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -466,7 +466,9 @@ export default { 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.' + hint: 'Nur-Lesen ohne Signierschlüssel. Mit passender Erweiterung, nsec o. Ä. anmelden, um zu posten, reagieren und bearbeiten.', + cannotPublish: + 'Dieses Konto ist nur lesbar. Verbinde die passende Erweiterung oder wähle ein Konto, das signieren kann.' }, 'reload notes': 'Notizen neu laden', 'Logged in Accounts': 'Angemeldete Konten', @@ -1000,6 +1002,8 @@ export default { 'Nur-Lesen-Ansicht. „Erneut“ verbindet, wenn die Erweiterung zu diesem Schlüssel passt — ein anderer Erweiterungsschlüssel ist in Ordnung, wenn du nur stöbern willst.', 'accountSwitch.extensionRetry': 'Erweiterung erneut', 'accountSwitch.extensionConnected': 'Erweiterung für dieses Konto verbunden.', + 'accountSwitch.composerExtensionMismatch': + 'Anmeldung für dieses Konto fehlgeschlagen — die Erweiterung nutzt einen anderen Schlüssel. Schlüssel in der Erweiterung wechseln oder „Erweiterung erneut“ unten tippen.', 'accountSwitch.extensionRetryFailed': 'Erweiterungsschlüssel passt noch nicht. Schlüssel in der Erweiterung wechseln und erneut versuchen.', 'accountSwitch.extensionUnavailable': @@ -2086,6 +2090,8 @@ export default { 'nip07.useExtensionIdentity': 'Erweiterungs-Identität verwenden', 'nip07.switchedToExtensionIdentity': 'Auf die aktuelle Identität Ihrer Erweiterung umgestellt.', 'nip07.adoptExtensionFailed': 'Wechsel zur Erweiterungs-Identität fehlgeschlagen', + 'nip07.publishExtensionMismatch': + 'Die Erweiterung hat mit einem anderen Schlüssel signiert als das gewählte Konto. Schlüssel in der Erweiterung wechseln oder „Erweiterung erneut“ im Editor nutzen.', '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/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0e55b966..b3420880 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -467,7 +467,9 @@ export default { 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.' + hint: 'Browsing without a signing key. Sign in with an extension, nsec, or another method to post, react, and edit.', + cannotPublish: + 'This account is read-only. Connect the matching browser extension key or pick an account that can sign.' }, 'reload notes': 'reload notes', 'Logged in Accounts': 'Logged in Accounts', @@ -1019,6 +1021,8 @@ export default { 'accountSwitch.extensionConnected': 'Extension connected for this account.', 'accountSwitch.extensionRetryFailed': 'Extension key still does not match. Switch the key in your extension, then try again.', + 'accountSwitch.composerExtensionMismatch': + 'Could not sign in as this account — your extension is using a different key. Switch the key in the extension or tap “Retry extension” below.', 'accountSwitch.extensionUnavailable': 'Could not reach the browser extension. Unlock nos2x/Alby, allow this site, then click the account again.', 'Show untrusted {type}': 'Show untrusted {{type}}', @@ -2085,6 +2089,8 @@ export default { 'nip07.useExtensionIdentity': 'Use extension identity', 'nip07.switchedToExtensionIdentity': "Switched to your extension's current identity.", 'nip07.adoptExtensionFailed': 'Could not switch to extension identity', + 'nip07.publishExtensionMismatch': + 'Your extension signed with a different key than the selected account. Switch the key in the extension or use “Retry extension” in the composer.', '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/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 3ad00dcc..f6739256 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -63,6 +63,7 @@ import { queryService, replaceableEventService } from '@/services/client.service import customEmojiService from '@/services/custom-emoji.service' import indexedDb from '@/services/indexed-db.service' import postEditorCache from '@/services/post-editor-cache.service' +import postEditorService from '@/services/post-editor.service' import noteStatsService from '@/services/note-stats.service' import { ISigner, @@ -938,6 +939,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const prev = prevAccountPubkeyRef.current const curr = account?.pubkey ?? null prevAccountPubkeyRef.current = curr + if (postEditorService.isComposerShellOpen) { + return + } if (prev != null && curr != null && prev !== curr) { postEditorCache.clearOnAccountChange() } else if (prev != null && curr === null) { @@ -1893,6 +1897,30 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { return event as VerifiedEvent } + const publishExtensionMismatchError = () => + new Error( + t('nip07.publishExtensionMismatch', { + defaultValue: + 'Your extension signed with a different key than the selected account. Switch the key in the extension or use “Retry extension” in the composer.' + }) + ) + + const assertSignerMatchesAccountForPublish = async () => { + if (!account || !signer || account.signerType === 'npub') return + const accountPk = accountPubkeyToHex(account.pubkey) + if (!accountPk) throw new LoginRequiredError() + if (account.signerType !== 'nip-07') return + let signerPk: string | null = null + try { + signerPk = pubkeyFromNip07Extension(await signer.getPublicKey()) + } catch { + return + } + if (signerPk && !hexPubkeysEqual(signerPk, accountPk)) { + throw publishExtensionMismatchError() + } + } + const publish = async ( draftEvent: TDraftEvent, { minPow = 0, ...options }: TPublishOptions = {} @@ -1901,6 +1929,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { throw new LoginRequiredError() } + const accountPk = accountPubkeyToHex(account.pubkey) + await assertSignerMatchesAccountForPublish() + const normalizeOpts = { addClientTag: options.addClientTag } const draft = normalizeDraftEventTags(draftEvent, normalizeOpts) let event: Event @@ -1932,18 +1963,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { event = await signEvent(draft, normalizeOpts) } - if (event.kind !== kinds.Application && event.pubkey !== account.pubkey) { - const profileEvent = await replaceableEventService.fetchReplaceableEvent(event.pubkey, kinds.Metadata) - const eventAuthor = profileEvent ? getProfileFromEvent(profileEvent) : undefined - const result = confirm( - t( - 'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?', - { eventAuthorName: eventAuthor?.username, currentUsername: profile?.username } - ) - ) - if (!result) { - throw new Error(t('Cancelled')) - } + if ( + event.kind !== kinds.Application && + accountPk && + !hexPubkeysEqual(event.pubkey, accountPk) + ) { + throw publishExtensionMismatchError() } client.interruptBackgroundQueries() @@ -2142,6 +2167,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const checkLogin = async (cb?: () => T | Promise): Promise => { if (account?.signerType === 'npub') { + if (cb) { + toast.error(t('readOnlySession.cannotPublish')) + } return } if (!signer) { diff --git a/src/services/post-editor.service.ts b/src/services/post-editor.service.ts index 0793fb7d..765ef75c 100644 --- a/src/services/post-editor.service.ts +++ b/src/services/post-editor.service.ts @@ -2,6 +2,20 @@ class PostEditorService extends EventTarget { static instance: PostEditorService isSuggestionPopupOpen = false + /** Ref-count of open PostEditor / Sheet shells (reply, new post, etc.). */ + private composerShellOpenCount = 0 + + get isComposerShellOpen(): boolean { + return this.composerShellOpenCount > 0 + } + + setComposerShellOpen(open: boolean) { + if (open) { + this.composerShellOpenCount += 1 + } else { + this.composerShellOpenCount = Math.max(0, this.composerShellOpenCount - 1) + } + } constructor() { super()