diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx
index b164539..f76f83c 100644
--- a/src/components/ChatViewer.tsx
+++ b/src/components/ChatViewer.tsx
@@ -15,7 +15,6 @@ import {
import { nip19 } from "nostr-tools";
import { getZapRequest } from "applesauce-common/helpers/zap";
import { toast } from "sonner";
-import accountManager from "@/services/accounts";
import eventStore from "@/services/event-store";
import type {
ChatProtocol,
@@ -51,6 +50,7 @@ import {
import { useProfileSearch } from "@/hooks/useProfileSearch";
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import { useCopy } from "@/hooks/useCopy";
+import { useAccount } from "@/hooks/useAccount";
import { Label } from "./ui/label";
import {
Tooltip,
@@ -437,9 +437,8 @@ export function ChatViewer({
}: ChatViewerProps) {
const { addWindow } = useGrimoire();
- // Get active account
- const activeAccount = use$(accountManager.active$);
- const hasActiveAccount = !!activeAccount;
+ // Get active account with signing capability
+ const { pubkey, canSign, signer } = useAccount();
// Profile search for mentions
const { searchProfiles } = useProfileSearch();
@@ -513,14 +512,14 @@ export function ChatViewer({
async (query: string) => {
const availableActions = adapter.getActions({
conversation: conversation || undefined,
- activePubkey: activeAccount?.pubkey,
+ activePubkey: pubkey,
});
const lowerQuery = query.toLowerCase();
return availableActions.filter((action) =>
action.name.toLowerCase().includes(lowerQuery),
);
},
- [adapter, conversation, activeAccount],
+ [adapter, conversation, pubkey],
);
// Cleanup subscriptions when conversation changes or component unmounts
@@ -596,7 +595,7 @@ export function ChatViewer({
emojiTags?: EmojiTag[],
blobAttachments?: BlobAttachment[],
) => {
- if (!conversation || !hasActiveAccount || isSending) return;
+ if (!conversation || !canSign || isSending) return;
// Check if this is a slash command
const slashCmd = parseSlashCommand(content);
@@ -605,8 +604,8 @@ export function ChatViewer({
setIsSending(true);
try {
const result = await adapter.executeAction(slashCmd.command, {
- activePubkey: activeAccount.pubkey,
- activeSigner: activeAccount.signer,
+ activePubkey: pubkey!,
+ activeSigner: signer!,
conversation,
});
@@ -649,13 +648,13 @@ export function ChatViewer({
// Handle command execution from autocomplete
const handleCommandExecute = useCallback(
async (action: ChatAction) => {
- if (!conversation || !hasActiveAccount || isSending) return;
+ if (!conversation || !canSign || isSending) return;
setIsSending(true);
try {
const result = await adapter.executeAction(action.name, {
- activePubkey: activeAccount.pubkey,
- activeSigner: activeAccount.signer,
+ activePubkey: pubkey!,
+ activeSigner: signer!,
conversation,
});
@@ -673,7 +672,7 @@ export function ChatViewer({
setIsSending(false);
}
},
- [conversation, hasActiveAccount, isSending, adapter, activeAccount],
+ [conversation, canSign, isSending, adapter, pubkey, signer],
);
// Handle reply button click
@@ -987,7 +986,7 @@ export function ChatViewer({
adapter={adapter}
conversation={conversation}
onReply={handleReply}
- canReply={hasActiveAccount}
+ canReply={canSign}
onScrollToMessage={handleScrollToMessage}
/>
);
@@ -1001,8 +1000,8 @@ export function ChatViewer({
)}
- {/* Message composer - only show if user has active account */}
- {hasActiveAccount ? (
+ {/* Message composer - only show if user can sign */}
+ {canSign ? (
{replyTo && (
(null);
+ // Read-only login state
+ const [readonlyInput, setReadonlyInput] = useState("");
+
+ // Private key (nsec) login state
+ const [nsecInput, setNsecInput] = useState("");
+
// NIP-46 state
const [bunkerUrl, setBunkerUrl] = useState("");
const [qrDataUrl, setQrDataUrl] = useState(null);
@@ -72,6 +86,8 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
if (!open) {
setLoading(false);
setError(null);
+ setReadonlyInput("");
+ setNsecInput("");
setBunkerUrl("");
setQrDataUrl(null);
setConnectUri(null);
@@ -84,7 +100,13 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
}, [open]);
const handleSuccess = useCallback(
- (account: ExtensionAccount | NostrConnectAccount) => {
+ (
+ account:
+ | ExtensionAccount
+ | NostrConnectAccount
+ | ReadonlyAccount
+ | PrivateKeyAccount,
+ ) => {
accounts.addAccount(account);
accounts.setActive(account);
onOpenChange(false);
@@ -112,6 +134,127 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
}
}
+ // Read-only login
+ async function loginWithReadonly() {
+ if (!readonlyInput.trim()) {
+ setError("Please enter a pubkey, npub, nprofile, or NIP-05 address");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ let pubkey: string;
+
+ // Try npub/nprofile decode
+ if (
+ readonlyInput.startsWith("npub") ||
+ readonlyInput.startsWith("nprofile")
+ ) {
+ try {
+ const decoded = nip19.decode(readonlyInput);
+ if (decoded.type === "npub") {
+ pubkey = decoded.data;
+ } else if (decoded.type === "nprofile") {
+ pubkey = decoded.data.pubkey;
+ } else {
+ throw new Error("Invalid format");
+ }
+ } catch (err) {
+ throw new Error(
+ `Invalid bech32 identifier: ${err instanceof Error ? err.message : "unknown error"}`,
+ );
+ }
+ }
+ // Try hex pubkey
+ else if (isValidHexPubkey(readonlyInput)) {
+ pubkey = normalizeHex(readonlyInput);
+ }
+ // Try NIP-05
+ else if (isNip05(readonlyInput)) {
+ const resolved = await resolveNip05(readonlyInput);
+ if (!resolved) {
+ throw new Error(
+ `Failed to resolve NIP-05 identifier: ${readonlyInput}`,
+ );
+ }
+ pubkey = resolved;
+ } else {
+ throw new Error(
+ "Invalid format. Supported: npub1..., nprofile1..., hex pubkey, or user@domain.com",
+ );
+ }
+
+ const account = ReadonlyAccount.fromPubkey(pubkey);
+ handleSuccess(account);
+ } catch (err) {
+ console.error("Read-only login error:", err);
+ setError(err instanceof Error ? err.message : "Failed to create account");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // Private key (nsec) login
+ async function loginWithNsec() {
+ if (!nsecInput.trim()) {
+ setError("Please enter a private key");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ let account: PrivateKeyAccount;
+
+ // Try nsec decode
+ if (nsecInput.startsWith("nsec")) {
+ try {
+ account = PrivateKeyAccount.fromKey(nsecInput);
+ } catch (err) {
+ throw new Error(
+ `Invalid nsec: ${err instanceof Error ? err.message : "unknown error"}`,
+ );
+ }
+ }
+ // Try hex private key
+ else if (/^[0-9a-f]{64}$/i.test(nsecInput)) {
+ account = PrivateKeyAccount.fromKey(nsecInput);
+ } else {
+ throw new Error(
+ "Invalid format. Supported: nsec1... or 64-character hex private key",
+ );
+ }
+
+ handleSuccess(account);
+ } catch (err) {
+ console.error("Nsec login error:", err);
+ setError(err instanceof Error ? err.message : "Failed to create account");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // Generate new identity
+ async function generateNewIdentity() {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const account = PrivateKeyAccount.generateNew();
+ handleSuccess(account);
+ } catch (err) {
+ console.error("Generate identity error:", err);
+ setError(
+ err instanceof Error ? err.message : "Failed to generate identity",
+ );
+ } finally {
+ setLoading(false);
+ }
+ }
+
// Bunker URL login
async function loginWithBunkerUrl() {
if (!bunkerUrl.trim()) {
@@ -266,15 +409,42 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
+
+ {loading ? (
+ <>
+
+ Generating...
+ >
+ ) : (
+ <>
+
+ Generate Identity
+ >
+ )}
+
+
setTab(v as LoginTab)}>
-
+
- Extension
+ Extension
+
+
+
+ Read-Only
+
+
+
+ Private Key
- Nostr Connect
+ Remote
@@ -317,6 +487,122 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
+
+
+ Browse Nostr in read-only mode. You can view content but cannot
+ sign events or post.
+
+
+ {error && tab === "readonly" && (
+
+ )}
+
+
+
+ Public Key or Identifier
+
+
setReadonlyInput(e.target.value)}
+ disabled={loading}
+ />
+
+ Supports npub, nprofile, hex pubkey, or NIP-05 addresses
+
+
+
+
+ {loading ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ <>
+
+ Continue in Read-Only Mode
+ >
+ )}
+
+
+
+
+
+
+
+
Security Warning
+
+ Entering your private key is not recommended. Your key will be
+ stored in browser localStorage and could be exposed. Consider
+ using an extension or remote signer instead.
+
+
+
+
+
+ Log in by pasting your private key (nsec or hex format). Only use
+ this on trusted devices.
+
+
+ {error && tab === "nsec" && (
+
+ )}
+
+
+
+ Private Key
+
+
setNsecInput(e.target.value)}
+ disabled={loading}
+ />
+
+ Supports nsec or 64-character hex private key
+
+
+
+
+ {loading ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ <>
+
+ Log in with Private Key
+ >
+ )}
+
+
+
Log in using NIP-46 remote signing. Scan the QR code with a signer
diff --git a/src/components/nostr/SpellDialog.tsx b/src/components/nostr/SpellDialog.tsx
index 9a365d4..4c7207d 100644
--- a/src/components/nostr/SpellDialog.tsx
+++ b/src/components/nostr/SpellDialog.tsx
@@ -11,8 +11,6 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
-import { use$ } from "applesauce-react/hooks";
-import accounts from "@/services/accounts";
import { parseReqCommand } from "@/lib/req-parser";
import { reconstructCommand, detectCommandType } from "@/lib/spell-conversion";
import type { ParsedSpell, SpellEvent } from "@/types/spell";
@@ -20,6 +18,7 @@ import { Loader2 } from "lucide-react";
import { saveSpell } from "@/services/spell-storage";
import { LocalSpell } from "@/services/db";
import { PublishSpellAction } from "@/actions/publish-spell";
+import { useAccount } from "@/hooks/useAccount";
/**
* Filter command to show only spell-relevant parts
@@ -79,7 +78,7 @@ export function SpellDialog({
existingSpell,
onSuccess,
}: SpellDialogProps) {
- const activeAccount = use$(accounts.active$);
+ const { canSign } = useAccount();
// Form state
const [alias, setAlias] = useState("");
@@ -186,9 +185,11 @@ export function SpellDialog({
const handlePublish = async () => {
if (!isFormValid) return;
- // Check for active account
- if (!activeAccount) {
- setErrorMessage("No active account. Please sign in first.");
+ // Check for signing capability
+ if (!canSign) {
+ setErrorMessage(
+ "You need a signing account to publish. Read-only accounts cannot publish.",
+ );
setPublishingState("error");
return;
}
@@ -363,10 +364,11 @@ export function SpellDialog({
)}
- {/* No account warning */}
- {!activeAccount && (
+ {/* No signing capability warning */}
+ {!canSign && (
- You need to sign in to publish spells.
+ You need a signing account to publish spells. Read-only accounts
+ cannot publish.
)}
@@ -384,7 +386,7 @@ export function SpellDialog({
{(publishingState === "signing" ||
publishingState === "publishing") && (
diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx
index 111a2ea..4bb555e 100644
--- a/src/components/nostr/user-menu.tsx
+++ b/src/components/nostr/user-menu.tsx
@@ -157,38 +157,11 @@ export default function UserMenu() {
>
)}
-
-
-
-
- Theme
-
-
- {availableThemes.map((theme) => (
- setTheme(theme.id)}
- >
-
- {theme.name}
-
- ))}
-
-
Log out
- >
- ) : (
- <>
+
@@ -213,10 +186,37 @@ export default function UserMenu() {
))}
-
+ >
+ ) : (
+ <>
setShowLogin(true)}>
Log in
+
+
+
+
+ Theme
+
+
+ {availableThemes.map((theme) => (
+ setTheme(theme.id)}
+ >
+
+ {theme.name}
+
+ ))}
+
+
>
)}
diff --git a/src/hooks/useAccount.ts b/src/hooks/useAccount.ts
new file mode 100644
index 0000000..bd30c51
--- /dev/null
+++ b/src/hooks/useAccount.ts
@@ -0,0 +1,69 @@
+import { useMemo } from "react";
+import { use$ } from "applesauce-react/hooks";
+import accounts from "@/services/accounts";
+
+/**
+ * Hook to access the active account with signing capability detection
+ *
+ * @returns {object} Account state
+ * @property {IAccount | undefined} account - The full active account object
+ * @property {string | undefined} pubkey - The account's public key (available for all account types)
+ * @property {boolean} canSign - Whether the account can sign events (false for read-only accounts)
+ * @property {ISigner | undefined} signer - The signer instance (undefined for read-only accounts)
+ * @property {boolean} isLoggedIn - Whether any account is active (including read-only)
+ *
+ * @example
+ * // For read-only operations (viewing profiles, loading data)
+ * const { pubkey, isLoggedIn } = useAccount();
+ * if (pubkey) {
+ * // Load user's relay list, emoji list, etc.
+ * }
+ *
+ * @example
+ * // For signing operations (posting, publishing, uploading)
+ * const { canSign, signer, pubkey } = useAccount();
+ * if (canSign) {
+ * // Can publish events
+ * await adapter.sendMessage({ activePubkey: pubkey, activeSigner: signer, ... });
+ * } else {
+ * // Show "log in to post" message
+ * }
+ */
+export function useAccount() {
+ const account = use$(accounts.active$);
+
+ return useMemo(() => {
+ if (!account) {
+ return {
+ account: undefined,
+ pubkey: undefined,
+ canSign: false,
+ signer: undefined,
+ isLoggedIn: false,
+ };
+ }
+
+ // Check if the account has a functional signer
+ // Read-only accounts have a signer that throws errors on sign operations
+ // We detect this by checking for the ReadonlySigner type or checking signer methods
+ const signer = account.signer;
+ let canSign = false;
+
+ if (signer) {
+ // ReadonlyAccount from applesauce-accounts has a ReadonlySigner
+ // that throws on signEvent, nip04, nip44 operations
+ // We can detect it by checking if it's an instance with the expected methods
+ // but we'll use a safer approach: check the account type name
+ const accountType = account.constructor.name;
+ canSign = accountType !== "ReadonlyAccount";
+ }
+
+ return {
+ account,
+ pubkey: account.pubkey,
+ canSign,
+ signer: canSign ? signer : undefined,
+ isLoggedIn: true,
+ };
+ }, [account]);
+}
diff --git a/src/hooks/useEmojiSearch.ts b/src/hooks/useEmojiSearch.ts
index 86ad12a..5481671 100644
--- a/src/hooks/useEmojiSearch.ts
+++ b/src/hooks/useEmojiSearch.ts
@@ -1,13 +1,12 @@
import { useEffect, useMemo, useRef } from "react";
-import { use$ } from "applesauce-react/hooks";
import {
EmojiSearchService,
type EmojiSearchResult,
} from "@/services/emoji-search";
import { UNICODE_EMOJIS } from "@/lib/unicode-emojis";
import eventStore from "@/services/event-store";
-import accounts from "@/services/accounts";
import type { NostrEvent } from "@/types/nostr";
+import { useAccount } from "./useAccount";
/**
* Hook to provide emoji search functionality with automatic indexing
@@ -15,7 +14,7 @@ import type { NostrEvent } from "@/types/nostr";
*/
export function useEmojiSearch(contextEvent?: NostrEvent) {
const serviceRef = useRef(null);
- const activeAccount = use$(accounts.active$);
+ const { pubkey } = useAccount();
// Create service instance (singleton per component mount)
if (!serviceRef.current) {
@@ -35,12 +34,10 @@ export function useEmojiSearch(contextEvent?: NostrEvent) {
// Subscribe to user's emoji list (kind 10030) and emoji sets (kind 30030)
useEffect(() => {
- if (!activeAccount?.pubkey) {
+ if (!pubkey) {
return;
}
- const pubkey = activeAccount.pubkey;
-
// Subscribe to user's emoji list (kind 10030 - replaceable)
const userEmojiList$ = eventStore.replaceable(10030, pubkey);
const userEmojiSub = userEmojiList$.subscribe({
@@ -99,7 +96,7 @@ export function useEmojiSearch(contextEvent?: NostrEvent) {
// Clear custom emojis but keep unicode
service.clearCustom();
};
- }, [activeAccount?.pubkey, service]);
+ }, [pubkey, service]);
// Memoize search function
const searchEmojis = useMemo(