aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/nip17.ts52
-rw-r--r--src/lib/seed.ts41
-rw-r--r--src/routes/(app)/+layout.svelte3
-rw-r--r--src/routes/(app)/dm/+page.svelte369
-rw-r--r--src/routes/(static)/register/auth/+page.svelte38
5 files changed, 330 insertions, 173 deletions
diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts
index 29a14549..1400b5a5 100644
--- a/src/lib/nip17.ts
+++ b/src/lib/nip17.ts
@@ -302,6 +302,58 @@ const createAndPublishMessageEvent = async (
return publishToPreferred(event, eventFor, expiryDays != null);
};
+// Subscribes to new incoming gift wraps for the user.
+// Decrypts new events and passes the rumour to onNewRumour().
+// Returns a close function to stop the subscription.
+// Uses Relay directly instead of SimplePool.subscribeMany to avoid
+// a filter double-nesting bug in nostr-tools v2.23.0.
+export const subscribeToMessages = async (
+ user: any,
+ onNewRumour: (rumour: any) => void,
+): Promise<() => void> => {
+ const preferredRelays = await getPreferredRelays(user.pubkey);
+ const relayUrls = Array.from(new Set([...preferredRelays, ...DM_RELAYS_LIST]));
+ const since = Math.floor(Date.now() / 1000) - TWO_DAYS;
+ const seen = new Set<string>();
+ let eoseCount = 0;
+
+ const closers: (() => void)[] = [];
+
+ for (const url of relayUrls) {
+ try {
+ const relay = await Relay.connect(url);
+ const sub = relay.subscribe(
+ [{ kinds: [1059], "#p": [user.pubkey], since }],
+ {
+ onevent: async (event) => {
+ if (seen.has(event.id)) return;
+ seen.add(event.id);
+ if (eoseCount < relayUrls.length) return;
+ if (expired(event)) return;
+ try {
+ const rumour: any = await decrypt(event, user);
+ const expires = expiration(event);
+ if (expires) rumour.expiration = expires;
+ onNewRumour(rumour);
+ } catch (e) {
+ console.error("Failed to decrypt subscription event:", event.id, e);
+ }
+ },
+ oneose: () => {
+ eoseCount++;
+ },
+ },
+ );
+ closers.push(() => sub.close());
+ } catch (e) {
+ console.error("Failed to connect to relay for subscription:", url, e);
+ eoseCount++;
+ }
+ }
+
+ return () => closers.forEach((close) => close());
+};
+
// Sends a NIP-17 message from `user` to `recipient`.
// If expiryDays is set, messages will expire after that many days.
export const send = async (message: string, user: any, recipient: any, expiryDays?: number) => {
diff --git a/src/lib/seed.ts b/src/lib/seed.ts
new file mode 100644
index 00000000..3d58bcb5
--- /dev/null
+++ b/src/lib/seed.ts
@@ -0,0 +1,41 @@
+import { getWalletEntropy } from "$lib/walletEntropy";
+import { signer } from "$lib/store";
+
+export async function deriveNostrKey(entropy: ArrayBuffer) {
+ const [{ entropyToMnemonic }, { wordlist }, { privateKeyFromSeedWords }, { getPublicKey }] =
+ await Promise.all([
+ import("@scure/bip39"),
+ import("@scure/bip39/wordlists/english.js"),
+ import("nostr-tools/nip06"),
+ import("nostr-tools"),
+ ]);
+
+ const ent = new Uint8Array(entropy).slice(0, 16);
+ const mnemonic = entropyToMnemonic(ent, wordlist);
+ const sk = privateKeyFromSeedWords(mnemonic);
+ const pubkey = getPublicKey(sk);
+ return { sk, pubkey };
+}
+
+export async function autoUnlockNostr(userPubkey: string) {
+ if (localStorage.getItem("nsec")) return;
+
+ const entropy = await getWalletEntropy();
+ if (!entropy) return;
+
+ try {
+ const { sk, pubkey } = await deriveNostrKey(entropy);
+ if (pubkey !== userPubkey) return;
+
+ const { nip19 } = await import("nostr-tools");
+ const { bytesToHex } = await import("@noble/hashes/utils.js");
+ localStorage.setItem("nsec", nip19.nsecEncode(sk));
+ signer.set({
+ method: "nsec",
+ params: { sk: bytesToHex(sk), pk: pubkey },
+ ready: true,
+ });
+ } catch (e) {
+ console.error("Nostr auto-unlock failed:", e);
+ }
+}
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index e06ec990..40f28444 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -185,6 +185,9 @@
// Auto-unlock ark wallet if silent entropy is available
if (!$arkkey) autoUnlockArk();
+
+ // Auto-unlock nostr key if silent entropy is available
+ import("$lib/seed").then(({ autoUnlockNostr }) => autoUnlockNostr(user.pubkey));
}
// if (window.NDEFReader) {
diff --git a/src/routes/(app)/dm/+page.svelte b/src/routes/(app)/dm/+page.svelte
index b7026f94..1453ccd6 100644
--- a/src/routes/(app)/dm/+page.svelte
+++ b/src/routes/(app)/dm/+page.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
- import { tick, onMount } from "svelte";
+ import { tick, onMount, onDestroy } from "svelte";
import { isValid, queryProfile } from "nostr-tools/nip05";
import * as toolsnip17 from "nostr-tools/nip17";
@@ -9,11 +9,23 @@
import { getMessageRumours } from "$lib/nip17";
import * as libnip17 from "$lib/nip17";
import { npubEncode, decode } from "nostr-tools/nip19";
- import { mute, unmute, mutedAccounts } from "$lib/nip51";
+ // import { mute, unmute, mutedAccounts } from "$lib/nip51";
import { sign, getPrivateKey, pTagKeys } from "$lib/nostr";
import { t } from "$lib/translations";
import { theme } from "$lib/store";
+ const focus = (el: HTMLElement) => el.focus();
+
+ const scrollChat = () => {
+ const doScroll = () => {
+ const el = document.getElementById("messages");
+ if (el) el.scrollTop = el.scrollHeight;
+ document.getElementById("messages-end")?.scrollIntoView({ block: "nearest" });
+ };
+ doScroll();
+ setTimeout(doScroll, 200);
+ };
+
let { data } = $props();
const user = $derived(data.user);
@@ -24,14 +36,13 @@
})();
/// chat selection
- const usernameFromPubkey = async (pubkey: string): Promise<string | null> => {
+ const coinosUserFromPubkey = async (pubkey: string): Promise<any> => {
const response = await fetch(`/api/users/${pubkey}`);
if (response.ok) {
- const userInfo = await response.json();
- return userInfo.anon ? null : userInfo.username;
- } else {
- return null;
+ const info = await response.json();
+ return info.anon ? null : info;
}
+ return null;
};
const userInfo = async (pubkey: string) => {
@@ -41,9 +52,10 @@
const nostrUserInfo = (await getNostrUserInfo(pubkey)) || {};
const valid = "nip05" in nostrUserInfo && (await isValid(pubkey, nostrUserInfo.nip05));
- const username = await usernameFromPubkey(pubkey);
+ const coinosUser = await coinosUserFromPubkey(pubkey);
return {
- coinosUsername: username,
+ coinosUsername: coinosUser?.username || null,
+ picture: coinosUser?.picture || nostrUserInfo?.picture || null,
nostrName: nostrUserInfo?.name,
nip05: nostrUserInfo?.nip05,
nip05Valid: valid,
@@ -55,18 +67,6 @@
return userInfo.coinosUsername || userInfo.nostrName || $t("dm.anonymous");
};
- const id = (userInfo: any): string => {
- if (userInfo.nip05 && userInfo.nip05.slice(0, 2) == "_@") {
- return userInfo.nip05.slice(2);
- }
-
- return userInfo.nip05 || npubEncode(userInfo.pubkey).slice(0, 16) + "...";
- };
-
- const idValid = (userInfo: any): boolean => {
- return !userInfo.nip05 || userInfo.nip05Valid;
- };
-
const rumourInvolves = (rumour: any, pubkey: string) => {
if (rumour.pubkey === pubkey) {
return true;
@@ -135,10 +135,7 @@
let messageRumours: any = $state([]);
let dates: any[] = $state([]);
let relayWarningShown = $state(false);
- let expiryEnabled = $state(false);
- let expiryDays = $state(7);
let canSend = $state(false);
- let canSendExpiring = $state(false);
let nostrUserInfo: any = $state({});
const selectedChatEmpty = () => !messageRumours || messageRumours.size == 0;
@@ -175,7 +172,6 @@
userInfo(selectedChat.pubkey).then((info) => (nostrUserInfo = info));
libnip17.getPreferredRelays(selectedChat.pubkey).then((relays) => {
canSend = relays && relays.length > 0;
- relaysSupporting(relays, [40]).then((supporting) => (canSendExpiring = supporting.length > 0));
});
updateEvents();
};
@@ -248,10 +244,7 @@
dates.sort((d1, d2) => d1 - d2);
await tick();
- const messages = document.getElementById("messages");
- if (messages) {
- messages.scrollTop = messages.scrollHeight;
- }
+ scrollChat();
};
const TIME = new Intl.DateTimeFormat(undefined, {
@@ -279,19 +272,16 @@
}
};
- const expirationClose = (expiration: number) => {
- return expiration <= Date.now() / 1000 + 2 * 86400;
- };
-
let sendError = $state("");
const sendMessage = async (message: string) => {
sendError = "";
try {
- const expiry = expiryEnabled ? expiryDays : undefined;
- await libnip17.send(message, user, selectedChat, expiry);
+ await libnip17.send(message, user, selectedChat);
text = "";
updateEvents();
+ await tick();
+ document.getElementById("message-contents")?.focus();
} catch (e: any) {
console.error("Failed to send message:", e);
sendError = $t("dm.sendFailed");
@@ -306,17 +296,55 @@
relayWarningShown = false;
}))();
- let muted = $state(new Set<string>());
- (() => mutedAccounts(data.user).then((m) => (muted = m)))();
+ let closeSub: (() => void) | undefined;
- const toggleMute = async (pubkey: string) => {
- if (muted && muted.has(pubkey)) {
- await unmute(user, pubkey);
- } else {
- await mute(user, pubkey, true);
- }
- muted = await mutedAccounts(user);
- };
+ onMount(() => {
+ libnip17.subscribeToMessages(user, async (rumour) => {
+ // Add rumour to the full list if not already present
+ if (!allRumours.find((r) => r.id === rumour.id)) {
+ allRumours = [...allRumours, rumour];
+ await updateSendersRecipients(allRumours);
+ }
+
+ // If this rumour belongs to the current chat, add it to the display
+ if (
+ selectedChat &&
+ ((rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(selectedChat.pubkey)) ||
+ (rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey)))
+ ) {
+ const dayKey = Math.floor(rumour.created_at / 86400);
+ if (messageRumours.has(dayKey)) {
+ const dayMsgs = messageRumours.get(dayKey);
+ if (!dayMsgs.find((r: any) => r.id === rumour.id)) {
+ dayMsgs.push(rumour);
+ dayMsgs.sort((a: any, b: any) => a.created_at - b.created_at);
+ }
+ } else {
+ messageRumours.set(dayKey, [rumour]);
+ }
+ dates = Array.from(messageRumours.keys()).sort((a, b) => a - b);
+ // Force reactivity
+ messageRumours = new Map(messageRumours);
+
+ await tick();
+ scrollChat();
+ }
+ }).then((close) => (closeSub = close));
+ });
+
+ onDestroy(() => closeSub?.());
+
+ // let muted = $state(new Set<string>());
+ // (() => mutedAccounts(data.user).then((m) => (muted = m)))();
+
+ // const toggleMute = async (pubkey: string) => {
+ // if (muted && muted.has(pubkey)) {
+ // await unmute(user, pubkey);
+ // } else {
+ // await mute(user, pubkey, true);
+ // }
+ // muted = await mutedAccounts(user);
+ // };
</script>
<div class="super-container">
@@ -346,23 +374,21 @@
{/if}
{#if !creatingNewChat}
{#each chats as c}
- {#if (selectedChat && selectedChat.pubkey === c.pubkey) || !(muted && muted.has(c.pubkey))}
- <button
- class={"chat-btn tall-btn " +
- ($theme === "light" ? "light-chat-btn" : "dark-chat-btn") +
- (selectedChat && selectedChat.pubkey == c.pubkey
- ? $theme === "light"
- ? " light-selected"
- : " dark-selected"
- : "")}
- onclick={() => selectChat(c)}
- >
- <span class={"text-xl" + (muted && muted.has(c.pubkey) ? " secondary" : "")}>
- {name(c)}
- </span>
- <span class={(idValid(c) ? "" : "invalid ") + "secondary text-xs"}>{id(c)}</span>
- </button>
- {/if}
+ <button
+ class={"chat-btn tall-btn " +
+ ($theme === "light" ? "light-chat-btn" : "dark-chat-btn") +
+ (selectedChat && selectedChat.pubkey == c.pubkey
+ ? $theme === "light"
+ ? " light-selected"
+ : " dark-selected"
+ : "")}
+ onclick={() => selectChat(c)}
+ >
+ {#if c.picture}
+ <img src={c.picture} alt={name(c)} class="sidebar-avatar" />
+ {/if}
+ <span class="text-xl">{name(c)}</span>
+ </button>
{/each}
{:else if searchQuery !== ""}
<button
@@ -397,66 +423,65 @@
</h1>
{#if selectedChat}
- <h1>
- {#if selectedChat.coinosUsername}
- <a class="text-3xl" href="/{selectedChat.coinosUsername}">{name(selectedChat)}</a>
- {:else}
- <span class="text-3xl">{name(selectedChat)}</span>
- {/if}
- <span
- class={"secondary timestamp text-small" + (idValid(selectedChat) ? "" : " invalid")}
- title={npubEncode(selectedChat.pubkey)}
- >
- {id(selectedChat)}
- </span>
- <button
- class={"small-btn push-right " +
- ($theme === "light" ? "light-chat-btn" : "dark-chat-btn")}
- onclick={() => toggleMute(selectedChat.pubkey)}
- >
- {$t(muted && muted.has(selectedChat.pubkey) ? "dm.unmute" : "dm.mute")}
- </button>
- </h1>
-
- {#if !(muted && muted.has(selectedChat.pubkey))}
- <div id="messages" style="overflow-y: scroll; max-height: 600px;">
- {#each dates as day}
- <p class="date-header secondary">{formatDate(new Date(day * 86400 * 1000))}</p>
- <ul>
- {#each messageRumours.get(day) as rumour}
- {#if rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(selectedChat.pubkey)}
- <li
- class={(rumour.expiration && expirationClose(rumour.expiration)
- ? "secondary "
- : "") +
- ($theme === "light" ? "light-message " : "dark-message ") +
- "message by-user"}
+ <div id="messages">
+ {#each dates as day}
+ <p class="date-header secondary">{formatDate(new Date(day * 86400 * 1000))}</p>
+ <ul>
+ {#each messageRumours.get(day) as rumour, i}
+ {@const dayMessages = messageRumours.get(day)}
+ {@const next = dayMessages[i + 1]}
+ {@const isLastInRun = !next || next.pubkey !== rumour.pubkey}
+ {#if rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(selectedChat.pubkey)}
+ <li class="message-row by-user">
+ <div
+ class={($theme === "light" ? "light-message " : "dark-message ") + "message"}
>
{rumour.content}
<span class="timestamp secondary text-xs">
{TIME.format(new Date(rumour.created_at * 1000))}
</span>
- </li>
- {:else if rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey)}
- <li
- class={(rumour.expiration && expirationClose(rumour.expiration)
- ? "secondary "
- : "") +
- ($theme === "light" ? "light-message " : "dark-message ") +
- "message by-other"}
+ </div>
+ {#if isLastInRun && user.picture}
+ <img src={user.picture} alt={user.username} class="msg-avatar" />
+ {:else}
+ <div class="msg-avatar-placeholder"></div>
+ {/if}
+ </li>
+ {:else if rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey)}
+ <li class="message-row by-other">
+ {#if isLastInRun && selectedChat.picture}
+ <img src={selectedChat.picture} alt={name(selectedChat)} class="msg-avatar" />
+ {:else}
+ <div class="msg-avatar-placeholder"></div>
+ {/if}
+ <div
+ class={($theme === "light" ? "light-message " : "dark-message ") + "message"}
>
{rumour.content}
<span class="timestamp secondary text-xs">
{TIME.format(new Date(rumour.created_at * 1000))}
</span>
- </li>
- {/if}
- {/each}
- </ul>
- {/each}
- </div>
+ </div>
+ </li>
+ {/if}
+ {/each}
+ </ul>
+ {/each}
+ <div id="messages-end"></div>
+ </div>
- <textarea id="message-contents" bind:value={text}></textarea>
+ <div class="input-bar">
+ <textarea
+ id="message-contents"
+ bind:value={text}
+ use:focus
+ onkeydown={(e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ if (canSend && text.trim()) sendMessage(text);
+ }
+ }}
+ ></textarea>
<input
id="send-message"
type="button"
@@ -470,36 +495,7 @@
{#if sendError}
<p class="warning"><em>{sendError}</em></p>
{/if}
- {/if}
-
- {#if !(muted && muted.has(selectedChat.pubkey))}
- <input
- type="checkbox"
- id="expiryCheckbox"
- class="tiny"
- bind:checked={expiryEnabled}
- disabled={!canSendExpiring}
- />
- <label for="expiryCheckbox">{$t("dm.expiry")}</label>
- :
- <input
- type="number"
- class="short small-width vcenter"
- bind:value={expiryDays}
- disabled={!expiryEnabled}
- min="1"
- step="1"
- max="99999"
- />
- <label for="expiryCheckbox">{$t("dm.days")}</label>
- .
- <p class="secondary">{$t("dm.fadedWarning")}</p>
- {#if canSend && !canSendExpiring}
- <p class="warning">
- <em>{$t("dm.cantSendExpiring").replace("[R]", name(selectedChat))}</em>
- </p>
- {/if}
- {/if}
+ </div>
{:else if chats.length == 0}
<p>{$t("dm.noChats")}</p>
{:else}
@@ -529,27 +525,11 @@
text-decoration-line: underline;
}
- .inline {
- white-space: nowrap;
- }
-
.short {
height: 1.5em;
margin-bottom: 10px;
}
- .small-width {
- width: 6em;
- }
-
- .tiny {
- width: 2em;
- }
-
- .invalid {
- text-decoration: line-through;
- }
-
.super-container {
display: flex;
}
@@ -581,12 +561,17 @@
.tall-btn {
min-height: 3em;
width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
}
- .small-btn {
- padding: 0 15px;
- border-radius: 5px;
- text-align: left;
+ .sidebar-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 9999px;
+ object-fit: cover;
+ flex-shrink: 0;
}
.push-right {
@@ -597,6 +582,35 @@
margin: 1em 15px 0.5em;
}
+ .message-row {
+ display: flex;
+ align-items: flex-end;
+ gap: 0.5rem;
+ margin: 3px 0;
+ }
+
+ .message-row.by-user {
+ justify-content: flex-end;
+ }
+
+ .message-row.by-other {
+ justify-content: flex-start;
+ }
+
+ .msg-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 9999px;
+ object-fit: cover;
+ flex-shrink: 0;
+ }
+
+ .msg-avatar-placeholder {
+ width: 28px;
+ height: 28px;
+ flex-shrink: 0;
+ }
+
.light-chat-btn {
background-color: #eeeeee;
}
@@ -636,6 +650,25 @@
text-align: right;
}
+ #messages {
+ overflow-y: auto;
+ max-height: 50vh;
+ scrollbar-width: thin;
+ }
+
+ #messages::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ #messages::-webkit-scrollbar-thumb {
+ background: rgba(128, 128, 128, 0.3);
+ border-radius: 3px;
+ }
+
+ #messages::-webkit-scrollbar-button {
+ display: none;
+ }
+
.message {
padding: 0.25em 0.75em;
width: fit-content;
@@ -651,15 +684,11 @@
background-color: #222;
}
- .by-user {
- margin: 3px 0px 3px auto;
- }
-
- .by-other {
- margin: 3px auto 3px 0px;
+ .input-bar {
+ padding: 0.5rem 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
}
- .vcenter {
- margin: auto 0px;
- }
</style>
diff --git a/src/routes/(static)/register/auth/+page.svelte b/src/routes/(static)/register/auth/+page.svelte
index 32539079..afb39953 100644
--- a/src/routes/(static)/register/auth/+page.svelte
+++ b/src/routes/(static)/register/auth/+page.svelte
@@ -13,7 +13,7 @@
import PasswordInput from "$comp/PasswordInput.svelte";
import Spinner from "$comp/Spinner.svelte";
- import { fail, focus } from "$lib/utils";
+ import { fail, focus, post } from "$lib/utils";
import { registerPasskey } from "$lib/passkey";
import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
import { avatar, signer, password, pin, loginRedirect } from "$lib/store";
@@ -158,8 +158,21 @@
const { sk } = await deriveAuthKeypair(user.username, user.password);
const canonicalKey = generateCanonicalKey();
const wrapped = await wrapCanonicalKey(sk.buffer as ArrayBuffer, canonicalKey);
- await saveEncryptedKeys({ [passwordMethodId()]: wrapped });
rememberPrfKey(canonicalKey, defaultRememberForMs);
+
+ const encryptedKeys = { [passwordMethodId()]: wrapped };
+ let nostrPubkey: string | undefined;
+ try {
+ const { deriveNostrKey } = await import("$lib/seed");
+ const { nip19 } = await import("nostr-tools");
+ const nostr = await deriveNostrKey(canonicalKey);
+ localStorage.setItem("nsec", nip19.nsecEncode(nostr.sk));
+ nostrPubkey = nostr.pubkey;
+ } catch (e) {
+ console.log("Nostr key derivation failed", e);
+ }
+
+ await post("/post/user", nostrPubkey ? { encryptedKeys, pubkey: nostrPubkey } : { encryptedKeys });
} catch (e) {
console.log("Canonical key generation failed", e);
}
@@ -202,10 +215,25 @@
const { token, sk } = result.data;
const { prfKey, credentialId } = await registerPasskey(token);
+ // Generate canonical key and derive nostr key before activate
+ // so we can pass the pubkey to the server-side activate action
+ const canonicalKey = generateCanonicalKey();
+ let nostrPubkey: string | undefined;
+ let nostrSk: Uint8Array | undefined;
+ try {
+ const { deriveNostrKey } = await import("$lib/seed");
+ const nostr = await deriveNostrKey(canonicalKey);
+ nostrPubkey = nostr.pubkey;
+ nostrSk = nostr.sk;
+ } catch (e) {
+ console.log("Nostr key derivation failed", e);
+ }
+
const activateData = new FormData();
activateData.append("token", token);
activateData.append("username", username);
if (sk) activateData.append("sk", sk);
+ if (nostrPubkey) activateData.append("pubkey", nostrPubkey);
activateData.append("loginRedirect", (redirect ?? "") as string);
const activateResponse = await fetch("/register/auth?/activate", {
@@ -217,10 +245,14 @@
if (activateResult.type === "success" || activateResult.type === "redirect") {
try {
- const canonicalKey = generateCanonicalKey();
const wrapped = await wrapCanonicalKey(prfKey, canonicalKey);
await saveEncryptedKeys({ [passkeyMethodId(credentialId)]: wrapped });
rememberPrfKey(canonicalKey, defaultRememberForMs);
+
+ if (nostrSk) {
+ const { nip19 } = await import("nostr-tools");
+ localStorage.setItem("nsec", nip19.nsecEncode(nostrSk));
+ }
} catch (e) {
console.log("Canonical key generation failed", e);
}