NIP-46 and better DMs

This commit is contained in:
hzrd149 2023-12-07 09:10:29 -06:00
parent 907e6df271
commit 53b2c9e399
30 changed files with 775 additions and 250 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add reactions and zaps to DMs

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Make DMs view more readable

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for NIP-46 signer

View File

@ -74,6 +74,7 @@ import UserDMsTab from "./views/user/dms";
import DMFeedView from "./views/tools/dm-feed";
import ContentDiscoveryView from "./views/tools/content-discovery";
import ContentDiscoveryDVMView from "./views/tools/content-discovery/dvm";
import LoginNostrConnectView from "./views/signin/nostr-connect";
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const ToolsHomeView = lazy(() => import("./views/tools"));
@ -146,6 +147,7 @@ const router = createHashRouter([
{ path: "npub", element: <LoginNpubView /> },
{ path: "nip05", element: <LoginNip05View /> },
{ path: "nsec", element: <LoginNsecView /> },
{ path: "nostr-connect", element: <LoginNostrConnectView /> },
],
},
{

View File

@ -112,6 +112,12 @@ export default class NostrMultiSubscription {
this.relayQueries.delete(relay);
}
sendAll(event: NostrEvent) {
for (const relay of this.relays) {
relay.send(["EVENT", event]);
}
}
open() {
if (this.state === NostrMultiSubscription.OPEN) return this;

View File

@ -2,21 +2,21 @@ import { Badge, BadgeProps } from "@chakra-ui/react";
import { Account } from "../services/account";
export default function AccountInfoBadge({ account, ...props }: BadgeProps & { account: Account }) {
if (account.connectionType === "extension") {
if (account.type === "extension") {
return (
<Badge {...props} variant="solid" colorScheme="green">
extension
</Badge>
);
}
if (account.connectionType === "serial") {
if (account.type === "serial") {
return (
<Badge {...props} variant="solid" colorScheme="teal">
serial
</Badge>
);
}
if (account.secKey) {
if (account.type === "local") {
return (
<Badge {...props} variant="solid" colorScheme="red">
nsec

View File

@ -22,12 +22,10 @@ function AccountItem({ account, onClick }: { account: Account; onClick?: () => v
return (
<Box display="flex" gap="2" alignItems="center" cursor="pointer">
<Flex as="button" onClick={handleClick} flex={1} gap="2">
<Flex as="button" onClick={handleClick} flex={1} gap="2" overflow="hidden" alignItems="center">
<UserAvatar pubkey={pubkey} size="md" />
<Flex overflow="hidden" direction="column" alignItems="flex-start">
<Text isTruncated>{getUserDisplayName(metadata, pubkey)}</Text>
<AccountInfoBadge fontSize="0.7em" account={account} />
</Flex>
<Text isTruncated>{getUserDisplayName(metadata, pubkey)}</Text>
<AccountInfoBadge fontSize="0.7em" account={account} />
</Flex>
<IconButton
icon={<CloseIcon />}

View File

@ -22,7 +22,7 @@ import { draftEventReaction } from "../../../helpers/nostr/reactions";
import { getEventUID } from "../../../helpers/nostr/events";
import { useState } from "react";
export default function ReactionButton({ event, ...props }: { event: NostrEvent } & Omit<ButtonProps, "children">) {
export default function AddReactionButton({ event, ...props }: { event: NostrEvent } & Omit<ButtonProps, "children">) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const reactions = useEventReactions(getEventUID(event)) ?? [];

View File

@ -1,7 +1,7 @@
import { ButtonGroup, ButtonGroupProps, Divider } from "@chakra-ui/react";
import { NostrEvent } from "../../../types/nostr-event";
import ReactionButton from "./reaction-button";
import AddReactionButton from "./add-reaction-button";
import EventReactionButtons from "../../event-reactions/event-reactions";
import useEventReactions from "../../../hooks/use-event-reactions";
import { useBreakpointValue } from "../../../providers/breakpoint-provider";
@ -12,7 +12,7 @@ export default function NoteReactions({ event, ...props }: Omit<ButtonGroupProps
return (
<ButtonGroup spacing="1" {...props}>
<ReactionButton event={event} />
<AddReactionButton event={event} />
{reactions.length > 0 && (
<>
<Divider orientation="vertical" h="1.5rem" />

View File

@ -40,13 +40,13 @@ import {
import { UserAvatarStack } from "../compact-user-stack";
import MagicTextArea, { RefType } from "../magic-textarea";
import { useContextEmojis } from "../../providers/emoji-provider";
import { nostrBuildUploadImage as nostrBuildUpload } from "../../helpers/nostr-build";
import CommunitySelect from "./community-select";
import ZapSplitCreator, { fillRemainingPercent } from "./zap-split-creator";
import { EventSplit } from "../../helpers/nostr/zaps";
import useCurrentAccount from "../../hooks/use-current-account";
import useCacheForm from "../../hooks/use-cache-form";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useTextAreaUploadFileWithForm } from "../../hooks/use-textarea-upload-file";
type FormValues = {
subject: string;
@ -100,34 +100,10 @@ export default function PostModal({
// cache form to localStorage
useCacheForm<FormValues>(cacheFormKey, getValues, setValue, formState);
const textAreaRef = useRef<RefType | null>(null);
const imageUploadRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
try {
if (!(file.type.includes("image") || file.type.includes("video") || file.type.includes("audio")))
throw new Error("Unsupported file type");
setUploading(true);
const response = await nostrBuildUpload(file, requestSignature);
const imageUrl = response.url;
const content = getValues().content;
const position = textAreaRef.current?.getCaretPosition();
if (position !== undefined) {
setValue("content", content.slice(0, position) + imageUrl + " " + content.slice(position), {
shouldDirty: true,
});
} else setValue("content", content + imageUrl + " ", { shouldDirty: true });
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setUploading(false);
},
[setValue, getValues],
);
const textAreaRef = useRef<RefType | null>(null);
const { onPaste, onFileInputChange, uploading } = useTextAreaUploadFileWithForm(textAreaRef, getValues, setValue);
const getDraft = useCallback(() => {
const { content, nsfw, nsfwReason, community, split, subject } = getValues();
@ -139,8 +115,6 @@ export default function PostModal({
created_at: dayjs().unix(),
});
updatedDraft.content = correctContentMentions(updatedDraft.content);
if (nsfw) {
updatedDraft.tags.push(nsfwReason ? ["content-warning", nsfwReason] : ["content-warning"]);
}
@ -191,14 +165,11 @@ export default function PostModal({
autoFocus
mb="2"
value={getValues().content}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true, shouldTouch: true })}
rows={5}
isRequired
instanceRef={(inst) => (textAreaRef.current = inst)}
onPaste={(e) => {
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
if (imageFile) uploadFile(imageFile);
}}
onPaste={onPaste}
/>
{getValues().content.length > 0 && (
<Box>
@ -216,10 +187,7 @@ export default function PostModal({
type="file"
accept="image/*,audio/*,video/*"
ref={imageUploadRef}
onChange={(e) => {
const img = e.target.files?.[0];
if (img) uploadFile(img);
}}
onChange={onFileInputChange}
/>
<IconButton
icon={<UploadImageIcon />}

View File

@ -0,0 +1,72 @@
import { ChangeEventHandler, ClipboardEventHandler, MutableRefObject, useCallback, useState } from "react";
import { useToast } from "@chakra-ui/react";
import { nostrBuildUploadImage } from "../helpers/nostr-build";
import { RefType } from "../components/magic-textarea";
import { useSigningContext } from "../providers/signing-provider";
import { UseFormGetValues, UseFormSetValue } from "react-hook-form";
export function useTextAreaUploadFileWithForm(
ref: MutableRefObject<RefType | null>,
getValues: UseFormGetValues<any>,
setValue: UseFormSetValue<any>,
) {
const getText = useCallback(() => getValues().content, [getValues]);
const setText = useCallback(
(text: string) => setValue("content", text, { shouldDirty: true, shouldTouch: true }),
[setValue],
);
return useTextAreaUploadFile(ref, getText, setText);
}
export default function useTextAreaUploadFile(
ref: MutableRefObject<RefType | null>,
getText: () => string,
setText: (text: string) => void,
) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
try {
if (!(file.type.includes("image") || file.type.includes("video") || file.type.includes("audio")))
throw new Error("Unsupported file type");
setUploading(true);
const response = await nostrBuildUploadImage(file, requestSignature);
const imageUrl = response.url;
const content = getText();
const position = ref.current?.getCaretPosition();
if (position !== undefined) {
setText(content.slice(0, position) + imageUrl + " " + content.slice(position));
} else setText(content + imageUrl + " ");
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setUploading(false);
},
[setText, getText, toast, setUploading],
);
const onFileInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => {
const img = e.target.files?.[0];
if (img) uploadFile(img);
},
[uploadFile],
);
const onPaste = useCallback<ClipboardEventHandler<HTMLTextAreaElement>>(
(e) => {
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
if (imageFile) uploadFile(imageFile);
},
[uploadFile],
);
return { uploadFile, uploading, onPaste, onFileInputChange };
}

View File

@ -2,7 +2,6 @@ import "./polyfill";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import { GlobalProviders } from "./providers";
import "./services/local-cache-relay";
// setup dayjs
import dayjs from "dayjs";

View File

@ -2,15 +2,47 @@ import { PersistentSubject } from "../classes/subject";
import db from "./db";
import { AppSettings } from "./settings/migrations";
export type Account = {
type CommonAccount = {
pubkey: string;
readonly: boolean;
relays?: string[];
secKey?: ArrayBuffer;
iv?: Uint8Array;
connectionType?: "extension" | "serial" | "amber";
localSettings?: AppSettings;
};
export type LocalAccount = CommonAccount & {
type: "local";
readonly: false;
secKey: ArrayBuffer;
iv: Uint8Array;
};
export type PubkeyAccount = CommonAccount & {
type: "pubkey";
readonly: true;
};
export type ExtensionAccount = CommonAccount & {
type: "extension";
readonly: false;
};
export type SerialAccount = CommonAccount & {
type: "serial";
readonly: false;
};
export type AmberAccount = CommonAccount & {
type: "amber";
readonly: false;
};
export type NostrConnectAccount = CommonAccount & {
type: "nostr-connect";
clientSecretKey: string;
signerRelays: string[];
readonly: false;
};
export type Account =
| ExtensionAccount
| LocalAccount
| NostrConnectAccount
| SerialAccount
| AmberAccount
| PubkeyAccount;
class AccountService {
loading = new PersistentSubject(true);
@ -33,6 +65,7 @@ class AccountService {
startGhost(pubkey: string) {
const ghostAccount: Account = {
type: "pubkey",
pubkey,
readonly: true,
};

View File

@ -1,11 +1,11 @@
import { openDB, deleteDB, IDBPDatabase } from "idb";
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6 } from "./schema";
import { openDB, deleteDB, IDBPDatabase, IDBPTransaction } from "idb";
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6, SchemaV7 } from "./schema";
import { logger } from "../../helpers/debug";
const log = logger.extend("Database");
const dbName = "storage";
const version = 6;
const version = 7;
const db = await openDB<SchemaV6>(dbName, version, {
upgrade(db, oldVersion, newVersion, transaction, event) {
if (oldVersion < 1) {
@ -44,11 +44,11 @@ const db = await openDB<SchemaV6>(dbName, version, {
}
if (oldVersion < 2) {
const v1 = db as unknown as IDBPDatabase<SchemaV1>;
const trans = transaction as unknown as IDBPTransaction<SchemaV1, string[], "versionchange">;
const v2 = db as unknown as IDBPDatabase<SchemaV2>;
// rename the old settings object store to misc
const oldSettings = transaction.objectStore("settings");
const oldSettings = trans.objectStore("settings");
oldSettings.name = "misc";
// create new settings object store
@ -63,10 +63,10 @@ const db = await openDB<SchemaV6>(dbName, version, {
const v3 = db as unknown as IDBPDatabase<SchemaV3>;
// rename the old event caches
v3.deleteObjectStore("userMetadata");
v3.deleteObjectStore("userContacts");
v3.deleteObjectStore("userRelays");
v3.deleteObjectStore("settings");
v2.deleteObjectStore("userMetadata");
v2.deleteObjectStore("userContacts");
v2.deleteObjectStore("userRelays");
v2.deleteObjectStore("settings");
// create new replaceable event object store
const settings = v3.createObjectStore("replaceableEvents", {
@ -89,15 +89,14 @@ const db = await openDB<SchemaV6>(dbName, version, {
}
if (oldVersion < 5) {
const v4 = db as unknown as IDBPDatabase<SchemaV4>;
const v5 = db as unknown as IDBPDatabase<SchemaV5>;
const trans = transaction as unknown as IDBPTransaction<SchemaV5, string[], "versionchange">;
// migrate accounts table
const objectStore = transaction.objectStore("accounts");
const objectStore = trans.objectStore("accounts");
objectStore.getAll().then((accounts: SchemaV4["accounts"]["value"][]) => {
for (const account of accounts) {
const newAccount: SchemaV5["accounts"] = {
const newAccount: SchemaV5["accounts"]["value"] = {
...account,
connectionType: account.useExtension ? "extension" : undefined,
};
@ -118,6 +117,52 @@ const db = await openDB<SchemaV6>(dbName, version, {
});
channelMetadata.createIndex("created", "created");
}
if (oldVersion < 7) {
const transV6 = transaction as unknown as IDBPTransaction<SchemaV6, string[], "versionchange">;
const transV7 = transaction as unknown as IDBPTransaction<SchemaV7, string[], "versionchange">;
const accounts = transV7.objectStore("accounts");
transV6
.objectStore("accounts")
.getAll()
.then((oldAccounts: SchemaV6["accounts"]["value"][]) => {
for (const account of oldAccounts) {
if (account.secKey && account.iv) {
// migrate local accounts
accounts.put({
type: "local",
pubkey: account.pubkey,
secKey: account.secKey,
iv: account.iv,
readonly: false,
relays: account.relays,
} satisfies SchemaV7["accounts"]["value"]);
} else if (account.readonly) {
// migrate readonly accounts
accounts.put({
type: "pubkey",
pubkey: account.pubkey,
readonly: true,
relays: account.relays,
} satisfies SchemaV7["accounts"]["value"]);
} else if (
account.connectionType === "serial" ||
account.connectionType === "amber" ||
account.connectionType === "extension"
) {
// migrate extension, serial, amber accounts
accounts.put({
type: account.connectionType,
pubkey: account.pubkey,
readonly: false,
relays: account.relays,
} satisfies SchemaV7["accounts"]["value"]);
}
}
});
}
},
});

View File

@ -1,9 +1,11 @@
import { DBSchema } from "idb";
import { NostrEvent } from "../../types/nostr-event";
import { RelayInformationDocument } from "../relay-info";
import { AppSettings } from "../settings/migrations";
import { Account } from "../account";
export interface SchemaV1 extends DBSchema {
export interface SchemaV1 {
userMetadata: {
key: string;
value: NostrEvent;
@ -58,8 +60,7 @@ export interface SchemaV1 extends DBSchema {
};
}
export interface SchemaV2 extends SchemaV1 {
accounts: SchemaV1["accounts"];
export interface SchemaV2 extends Omit<SchemaV1, "settings"> {
settings: {
key: string;
value: NostrEvent;
@ -71,8 +72,7 @@ export interface SchemaV2 extends SchemaV1 {
};
}
export interface SchemaV3 {
accounts: SchemaV2["accounts"];
export interface SchemaV3 extends Omit<SchemaV2, "settings" | "userMetadata" | "userContacts" | "userRelays"> {
replaceableEvents: {
key: string;
value: {
@ -80,20 +80,11 @@ export interface SchemaV3 {
created: number;
event: NostrEvent;
};
indexes: { created: number };
};
userFollows: SchemaV2["userFollows"];
dnsIdentifiers: SchemaV2["dnsIdentifiers"];
relayInfo: SchemaV2["relayInfo"];
relayScoreboardStats: SchemaV2["relayScoreboardStats"];
misc: SchemaV2["misc"];
}
export interface SchemaV4 {
accounts: SchemaV3["accounts"];
replaceableEvents: SchemaV3["replaceableEvents"];
dnsIdentifiers: SchemaV3["dnsIdentifiers"];
relayInfo: SchemaV3["relayInfo"];
relayScoreboardStats: SchemaV3["relayScoreboardStats"];
export interface SchemaV4 extends Omit<SchemaV3, "userFollows"> {
userSearch: {
key: string;
value: {
@ -101,30 +92,24 @@ export interface SchemaV4 {
names: string[];
};
};
misc: SchemaV3["misc"];
}
export interface SchemaV5 {
export interface SchemaV5 extends Omit<SchemaV4, "accounts"> {
accounts: {
pubkey: string;
readonly: boolean;
relays?: string[];
secKey?: ArrayBuffer;
iv?: Uint8Array;
connectionType?: "extension" | "serial";
localSettings?: AppSettings;
key: string;
value: {
pubkey: string;
readonly: boolean;
relays?: string[];
secKey?: ArrayBuffer;
iv?: Uint8Array;
connectionType?: "extension" | "serial" | "amber";
localSettings?: AppSettings;
};
};
replaceableEvents: SchemaV4["replaceableEvents"];
dnsIdentifiers: SchemaV4["dnsIdentifiers"];
relayInfo: SchemaV4["relayInfo"];
relayScoreboardStats: SchemaV4["relayScoreboardStats"];
userSearch: SchemaV4["userSearch"];
misc: SchemaV4["misc"];
}
export interface SchemaV6 {
accounts: SchemaV5["accounts"];
replaceableEvents: SchemaV5["replaceableEvents"];
export interface SchemaV6 extends SchemaV5 {
channelMetadata: {
key: string;
value: {
@ -133,9 +118,11 @@ export interface SchemaV6 {
event: NostrEvent;
};
};
dnsIdentifiers: SchemaV5["dnsIdentifiers"];
relayInfo: SchemaV5["relayInfo"];
relayScoreboardStats: SchemaV5["relayScoreboardStats"];
userSearch: SchemaV5["userSearch"];
misc: SchemaV5["misc"];
}
export interface SchemaV7 extends Omit<SchemaV6, "account"> {
accounts: {
key: string;
value: Account;
};
}

View File

@ -0,0 +1,224 @@
import { finishEvent, generatePrivateKey, getPublicKey, nip04, nip19 } from "nostr-tools";
import dayjs from "dayjs";
import { nanoid } from "nanoid";
import NostrMultiSubscription from "../classes/nostr-multi-subscription";
import { getPubkeyFromDecodeResult, isHexKey } from "../helpers/nip19";
import { createSimpleQueryMap } from "../helpers/nostr/filter";
import { logger } from "../helpers/debug";
import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event";
import createDefer, { Deferred } from "../classes/deferred";
import { truncatedId } from "../helpers/nostr/events";
import { NostrConnectAccount } from "./account";
export enum NostrConnectMethod {
Connect = "connect",
Disconnect = "disconnect",
GetPublicKey = "get_pubic_key",
SignEvent = "sign_event",
Nip04Encrypt = "nip04_encrypt",
Nip04Decrypt = "nip04_decrypt",
}
type RequestParams = {
[NostrConnectMethod.Connect]: [string] | [string, string];
[NostrConnectMethod.Disconnect]: [];
[NostrConnectMethod.GetPublicKey]: [];
[NostrConnectMethod.SignEvent]: [string];
[NostrConnectMethod.Nip04Encrypt]: [string, string];
[NostrConnectMethod.Nip04Decrypt]: [string, string];
};
type ResponseResults = {
[NostrConnectMethod.Connect]: "ack";
[NostrConnectMethod.Disconnect]: "ack";
[NostrConnectMethod.GetPublicKey]: string;
[NostrConnectMethod.SignEvent]: string;
[NostrConnectMethod.Nip04Encrypt]: string;
[NostrConnectMethod.Nip04Decrypt]: string;
};
export type NostrConnectRequest<N extends NostrConnectMethod> = { id: string; method: N; params: RequestParams[N] };
export type NostrConnectResponse<N extends NostrConnectMethod> = {
id: string;
result: ResponseResults[N];
error?: string;
};
export class NostrConnectClient {
sub: NostrMultiSubscription;
log = logger.extend("NostrConnectClient");
isConnected = false;
pubkey: string;
relays: string[];
secretKey: string;
publicKey: string;
supportedMethods: NostrConnectMethod[] | undefined;
constructor(pubkey: string, relays: string[], secretKey?: string) {
this.sub = new NostrMultiSubscription(`${truncatedId(pubkey)}-nostr-connect`);
this.pubkey = pubkey;
this.relays = relays;
this.secretKey = secretKey || generatePrivateKey();
this.publicKey = getPublicKey(this.secretKey);
this.sub.onEvent.subscribe(this.handleEvent, this);
this.sub.setQueryMap(createSimpleQueryMap(this.relays, { kinds: [24133], "#p": [this.publicKey] }));
}
open() {
this.sub.open();
}
close() {
this.sub.close();
}
private requests = new Map<string, Deferred<any>>();
async handleEvent(event: NostrEvent) {
if (event.kind !== 24133) return;
const to = event.tags.find(isPTag)?.[1];
if (!to) return;
try {
const responseStr = await nip04.decrypt(this.secretKey, this.pubkey, event.content);
const response = JSON.parse(responseStr);
if (response.id) {
const p = this.requests.get(response.id);
if (!p) return;
if (response.error) {
this.log(`ERROR: Got error for ${response.id}`, response);
p.reject(new Error(response.error));
} else if (response.result) {
this.log(response.id, response);
p.resolve(response.result);
}
}
} catch (e) {}
}
private createEvent(content: string) {
return finishEvent(
{
kind: 24133,
created_at: dayjs().unix(),
tags: [["p", this.pubkey]],
content,
},
this.secretKey,
);
}
private async makeRequest<T extends NostrConnectMethod>(
method: T,
params: RequestParams[T],
): Promise<ResponseResults[T]> {
const id = nanoid();
const request: NostrConnectRequest<T> = { method, id, params };
const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request));
this.log(`Sending request ${id} (${method}) ${JSON.stringify(params)}`);
this.sub.sendAll(this.createEvent(encrypted));
const p = createDefer<ResponseResults[T]>();
this.requests.set(id, p);
return p;
}
connect(token?: string) {
this.open();
try {
const result = this.makeRequest(NostrConnectMethod.Connect, token ? [this.publicKey, token] : [this.publicKey]);
this.isConnected = true;
return result;
} catch (e) {
this.isConnected = false;
this.close();
throw e;
}
}
ensureConnected() {
if (!this.isConnected) return this.connect();
}
disconnect() {
return this.makeRequest(NostrConnectMethod.Disconnect, []);
}
getPublicKey() {
return this.makeRequest(NostrConnectMethod.GetPublicKey, []);
}
async signEvent(draft: DraftNostrEvent) {
const eventString = await this.makeRequest(NostrConnectMethod.SignEvent, [JSON.stringify(draft)]);
return JSON.parse(eventString) as NostrEvent;
}
nip04Encrypt(pubkey: string, plaintext: string) {
return this.makeRequest(NostrConnectMethod.Nip04Encrypt, [pubkey, plaintext]);
}
async nip04Decrypt(pubkey: string, data: string) {
const plaintext = await this.makeRequest(NostrConnectMethod.Nip04Decrypt, [pubkey, data]);
if (plaintext.startsWith('["') && plaintext.endsWith('"]')) return JSON.parse(plaintext)[0] as string;
else return plaintext;
}
}
class NostrConnectService {
log = logger.extend("NostrConnect");
clients: NostrConnectClient[] = [];
getClient(pubkey: string) {
return this.clients.find((client) => client.pubkey === pubkey);
}
saveClient(client: NostrConnectClient) {
if (!this.clients.includes(client)) this.clients.push(client);
}
createClient(pubkey: string, relays: string[], secretKey?: string) {
if (this.getClient(pubkey)) throw new Error("A client for that pubkey already exists");
const client = new NostrConnectClient(pubkey, relays, secretKey);
client.log = this.log.extend(pubkey);
this.log(`Created client for ${pubkey} using ${relays.join(", ")}`);
return client;
}
fromBunkerURI(uri: string) {
const url = new URL(uri);
const pubkey = url.pathname.replace(/^\/\//, "");
if (!isHexKey(pubkey)) throw new Error("Invalid connection URI");
const relays = url.searchParams.getAll("relay");
if (relays.length === 0) throw new Error("Missing relays");
return this.getClient(pubkey) || this.createClient(pubkey, relays);
}
fromNsecBunkerToken(token: string) {
const [npub, hexToken] = token.split("#");
const decoded = nip19.decode(npub);
const pubkey = getPubkeyFromDecodeResult(decoded);
if (!pubkey) throw new Error("Cant find pubkey");
const relays = ["wss://relay.nsecbunker.com", "wss://nos.lol"];
if (relays.length === 0) throw new Error("Missing relays");
const client = this.getClient(pubkey) || this.createClient(pubkey, relays);
return client;
}
fromAccount(account: NostrConnectAccount) {
const existingClient = this.getClient(account.pubkey);
if (existingClient) return existingClient;
const client = this.createClient(account.pubkey, account.signerRelays, account.clientSecretKey);
// presume the client has already connected
this.saveClient(client);
return client;
}
}
const nostrConnectService = new NostrConnectService();
if (import.meta.env.DEV) {
// @ts-ignore
window.nostrConnectService = nostrConnectService;
}
export default nostrConnectService;

View File

@ -5,6 +5,7 @@ import { Account } from "./account";
import db from "./db";
import serialPortService from "./serial-port";
import amberSignerService from "./amber-signer";
import nostrConnectService from "./nostr-connect";
const decryptedKeys = new Map<string, string>();
@ -62,7 +63,7 @@ class SigningService {
}
async decryptSecKey(account: Account) {
if (!account.secKey) throw new Error("Account dose not have a secret key");
if (account.type !== "local") throw new Error("Account dose not have a secret key");
const cache = decryptedKeys.get(account.pubkey);
if (cache) return cache;
@ -86,90 +87,101 @@ class SigningService {
};
if (account.readonly) throw new Error("Cant sign in readonly mode");
if (account.connectionType) {
switch (account.connectionType) {
case "extension":
if (window.nostr) {
const signed = await window.nostr.signEvent(draft);
checkSig(signed);
return signed;
} else throw new Error("Missing nostr extension");
case "serial":
if (serialPortService.supported) {
const signed = await serialPortService.signEvent(draft);
checkSig(signed);
return signed;
} else throw new Error("Serial devices are not supported");
case "amber":
if (amberSignerService.supported) {
const signed = await amberSignerService.signEvent({ ...draft, pubkey: account.pubkey });
checkSig(signed);
return signed;
} else throw new Error("Cant use Amber on non-Android device");
default:
throw new Error("Unknown connection type " + account.connectionType);
}
} else if (account?.secKey) {
const secKey = await this.decryptSecKey(account);
const tmpDraft = { ...draft, pubkey: getPublicKey(secKey) };
const event = finishEvent(tmpDraft, secKey) as NostrEvent;
return event;
} else throw new Error("No signing method");
switch (account.type) {
case "local": {
const secKey = await this.decryptSecKey(account);
const tmpDraft = { ...draft, pubkey: getPublicKey(secKey) };
const event = finishEvent(tmpDraft, secKey) as NostrEvent;
return event;
}
case "extension":
if (window.nostr) {
const signed = await window.nostr.signEvent(draft);
checkSig(signed);
return signed;
} else throw new Error("Missing nostr extension");
case "serial":
if (serialPortService.supported) {
const signed = await serialPortService.signEvent(draft);
checkSig(signed);
return signed;
} else throw new Error("Serial devices are not supported");
case "amber":
if (amberSignerService.supported) {
const signed = await amberSignerService.signEvent({ ...draft, pubkey: account.pubkey });
checkSig(signed);
return signed;
} else throw new Error("Cant use Amber on non-Android device");
case "nostr-connect":
const client = nostrConnectService.fromAccount(account);
await client.ensureConnected();
const signed = await client.signEvent({ ...draft, pubkey: account.pubkey });
checkSig(signed);
return signed;
default:
throw new Error("Unknown account type");
}
}
async requestDecrypt(data: string, pubkey: string, account: Account) {
if (account.readonly) throw new Error("Cant decrypt in readonly mode");
if (account.connectionType) {
switch (account.connectionType) {
case "extension":
if (window.nostr) {
if (window.nostr.nip04) {
return await window.nostr.nip04.decrypt(pubkey, data);
} else throw new Error("Extension dose not support decryption");
} else throw new Error("Missing nostr extension");
case "serial":
if (serialPortService.supported) {
return await serialPortService.nip04Decrypt(pubkey, data);
} else throw new Error("Serial devices are not supported");
case "amber":
if (amberSignerService.supported) {
return await amberSignerService.nip04Decrypt(pubkey, data);
} else throw new Error("Cant use Amber on non-Android device");
default:
throw new Error("Unknown connection type " + account.connectionType);
}
} else if (account?.secKey) {
const secKey = await this.decryptSecKey(account);
return await nip04.decrypt(secKey, pubkey, data);
} else throw new Error("No decryption method");
switch (account.type) {
case "local":
const secKey = await this.decryptSecKey(account);
return await nip04.decrypt(secKey, pubkey, data);
case "extension":
if (window.nostr) {
if (window.nostr.nip04) {
return await window.nostr.nip04.decrypt(pubkey, data);
} else throw new Error("Extension dose not support decryption");
} else throw new Error("Missing nostr extension");
case "serial":
if (serialPortService.supported) {
return await serialPortService.nip04Decrypt(pubkey, data);
} else throw new Error("Serial devices are not supported");
case "amber":
if (amberSignerService.supported) {
return await amberSignerService.nip04Decrypt(pubkey, data);
} else throw new Error("Cant use Amber on non-Android device");
case "nostr-connect":
const client = nostrConnectService.fromAccount(account);
await client.ensureConnected();
return await client.nip04Decrypt(pubkey, data);
default:
throw new Error("Unknown account type");
}
}
async requestEncrypt(text: string, pubkey: string, account: Account) {
if (account.readonly) throw new Error("Cant encrypt in readonly mode");
if (account.connectionType) {
switch (account.connectionType) {
case "extension":
if (window.nostr) {
if (window.nostr.nip04) {
return await window.nostr.nip04.encrypt(pubkey, text);
} else throw new Error("Extension dose not support encryption");
} else throw new Error("Missing nostr extension");
case "serial":
if (serialPortService.supported) {
return await serialPortService.nip04Encrypt(pubkey, text);
} else throw new Error("Serial devices are not supported");
case "amber":
if (amberSignerService.supported) {
return await amberSignerService.nip04Encrypt(pubkey, text);
} else throw new Error("Cant use Amber on non-Android device");
default:
throw new Error("Unknown connection type " + account.connectionType);
}
} else if (account?.secKey) {
const secKey = await this.decryptSecKey(account);
return await nip04.encrypt(secKey, pubkey, text);
} else throw new Error("No encryption method");
switch (account.type) {
case "local":
const secKey = await this.decryptSecKey(account);
return await nip04.encrypt(secKey, pubkey, text);
case "extension":
if (window.nostr) {
if (window.nostr.nip04) {
return await window.nostr.nip04.encrypt(pubkey, text);
} else throw new Error("Extension dose not support encryption");
} else throw new Error("Missing nostr extension");
case "serial":
if (serialPortService.supported) {
return await serialPortService.nip04Encrypt(pubkey, text);
} else throw new Error("Serial devices are not supported");
case "amber":
if (amberSignerService.supported) {
return await amberSignerService.nip04Encrypt(pubkey, text);
} else throw new Error("Cant use Amber on non-Android device");
case "nostr-connect":
const client = nostrConnectService.fromAccount(account);
await client.ensureConnected();
return await client.nip04Encrypt(pubkey, text);
default:
throw new Error("Unknown connection type");
}
}
}

View File

@ -1,6 +1,5 @@
import { useState } from "react";
import { Button, Card, Flex, IconButton, Textarea, useToast } from "@chakra-ui/react";
import dayjs from "dayjs";
import { Button, Card, Flex, IconButton } from "@chakra-ui/react";
import { Kind, nip19 } from "nostr-tools";
import { useNavigate, useParams } from "react-router-dom";
@ -9,9 +8,6 @@ import UserAvatar from "../../components/user-avatar";
import UserLink from "../../components/user-link";
import { isHexKey } from "../../helpers/nip19";
import useSubject from "../../hooks/use-subject";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import { DraftNostrEvent } from "../../types/nostr-event";
import RequireCurrentAccount from "../../providers/require-current-account";
import Message from "./message";
import useTimelineLoader from "../../hooks/use-timeline-loader";
@ -20,26 +16,17 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { LightboxProvider } from "../../components/lightbox-provider";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import { useDecryptionContext } from "../../providers/dycryption-provider";
import { useUserRelays } from "../../hooks/use-user-relays";
import { RelayMode } from "../../classes/relay";
import { unique } from "../../helpers/array";
import SendMessageForm from "./send-message-form";
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const toast = useToast();
const navigate = useNavigate();
const account = useCurrentAccount()!;
const { getOrCreateContainer, addToQueue, startQueue } = useDecryptionContext();
const { requestEncrypt, requestSignature } = useSigningContext();
const [content, setContent] = useState<string>("");
const myInbox = useReadRelayUrls();
const usersInbox = useUserRelays(pubkey)
.filter((r) => r.mode & RelayMode.READ)
.map((r) => r.url);
const timeline = useTimelineLoader(`${pubkey}-${account.pubkey}-messages`, myInbox, [
{
@ -56,26 +43,6 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const messages = useSubject(timeline.timeline);
const sendMessage = async () => {
try {
if (!content) return;
const encrypted = await requestEncrypt(content, pubkey);
const event: DraftNostrEvent = {
kind: Kind.EncryptedDirectMessage,
content: encrypted,
tags: [["p", pubkey]],
created_at: dayjs().unix(),
};
const signed = await requestSignature(event);
const writeRelays = clientRelaysService.getWriteUrls();
const relays = unique([...writeRelays, ...usersInbox]);
new NostrPublishAction("Send DM", relays, signed);
setContent("");
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
};
const [loading, setLoading] = useState(false);
const decryptAll = async () => {
const promises = messages
@ -119,12 +86,7 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
<Flex shrink={0}>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
<Button isDisabled={!content} onClick={sendMessage}>
Send
</Button>
</Flex>
<SendMessageForm flexShrink={0} pubkey={pubkey} />
</IntersectionObserverProvider>
</LightboxProvider>
);

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { Alert, AlertDescription, AlertIcon, Button } from "@chakra-ui/react";
import { Alert, AlertDescription, AlertIcon, Button, ButtonProps } from "@chakra-ui/react";
import { UnlockIcon } from "../../components/icons";
import { useDecryptionContainer } from "../../providers/dycryption-provider";
@ -8,11 +8,12 @@ export default function DecryptPlaceholder({
children,
data,
pubkey,
...props
}: {
children: (decrypted: string) => JSX.Element;
data: string;
pubkey: string;
}): JSX.Element {
} & Omit<ButtonProps, "children">): JSX.Element {
const [loading, setLoading] = useState(false);
const { requestDecrypt, plaintext, error } = useDecryptionContainer(pubkey, data);
@ -39,7 +40,7 @@ export default function DecryptPlaceholder({
);
}
return (
<Button onClick={decrypt} isLoading={loading} leftIcon={<UnlockIcon />} width="full">
<Button onClick={decrypt} isLoading={loading} leftIcon={<UnlockIcon />} width="full" {...props}>
Decrypt
</Button>
);

View File

@ -82,7 +82,7 @@ function DirectMessagesPage() {
const isChatOpen = !!params.pubkey;
return (
<Flex gap="4" maxH={{ base: "calc(100vh - 3.5rem)", md: "100vh" }}>
<Flex gap="4" h={{ base: "calc(100vh - 3.5rem)", md: "100vh" }}>
<Flex
gap="2"
direction="column"

View File

@ -1,5 +1,5 @@
import { useRef } from "react";
import { Box, CardProps, Flex } from "@chakra-ui/react";
import { Box, ButtonGroup, Card, CardBody, CardFooter, CardHeader, CardProps, Flex } from "@chakra-ui/react";
import useCurrentAccount from "../../hooks/use-current-account";
import { getMessageRecipient } from "../../services/direct-messages";
@ -15,9 +15,15 @@ import {
} from "../../components/embed-types";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import UserAvatar from "../../components/user-avatar";
import UserLink from "../../components/user-link";
import { getEventUID } from "../../helpers/nostr/events";
import Timestamp from "../../components/timestamp";
import NoteZapButton from "../../components/note/note-zap-button";
import UserLink from "../../components/user-link";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import EventReactionButtons from "../../components/event-reactions/event-reactions";
import useEventReactions from "../../hooks/use-event-reactions";
import AddReactionButton from "../../components/note/components/add-reaction-button";
import { TrustProvider } from "../../providers/trust";
export function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
let content: EmbedableContent = [text];
@ -28,26 +34,58 @@ export function MessageContent({ event, text }: { event: NostrEvent; text: strin
// cashu
content = embedCashuTokens(content);
return <Box whiteSpace="pre-wrap">{content}</Box>;
return (
<TrustProvider event={event}>
<Box whiteSpace="pre-wrap" display="inline">
{content}
</Box>
</TrustProvider>
);
}
export default function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
const account = useCurrentAccount()!;
const isOwnMessage = account.pubkey === event.pubkey;
const isOwn = account.pubkey === event.pubkey;
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));
const avatar = <UserAvatar pubkey={event.pubkey} size="sm" my="1" />;
const reactions = useEventReactions(event.id) ?? [];
return (
<Flex direction="column" gap="2" ref={ref}>
<Flex gap="2" mr="2">
<UserAvatar pubkey={event.pubkey} size="xs" />
<UserLink pubkey={event.pubkey} fontWeight="bold" />
<Timestamp ml="auto" timestamp={event.created_at} />
</Flex>
<DecryptPlaceholder data={event.content} pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}>
{(text) => <MessageContent event={event} text={text} />}
</DecryptPlaceholder>
<Flex direction="row" gap="2" alignItems="flex-end" ref={ref}>
{!isOwn && avatar}
<Card variant="outline" w="full" ml={isOwn ? "auto" : 0} mr={isOwn ? 0 : "auto"} maxW="2xl">
{!isOwn && (
<CardHeader px="2" pt="2" pb="0" gap="2" display="flex" alignItems="center">
<UserLink pubkey={event.pubkey} fontWeight="bold" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<NoteZapButton event={event} size="xs" ml="auto" variant="ghost" />
<AddReactionButton event={event} size="xs" variant="ghost" />
</CardHeader>
)}
<CardBody px="2" py="2">
<DecryptPlaceholder
data={event.content}
pubkey={isOwn ? getMessageRecipient(event) ?? "" : event.pubkey}
variant="link"
py="4"
>
{(text) => <MessageContent event={event} text={text} />}
</DecryptPlaceholder>
{reactions.length === 0 && <Timestamp float="right" timestamp={event.created_at} />}
</CardBody>
{reactions.length > 0 && (
<CardFooter alignItems="center" display="flex" gap="2" px="2" pt="0" pb="2">
<ButtonGroup size="sm" mr="auto" variant="ghost">
<EventReactionButtons event={event} />
</ButtonGroup>
<Timestamp ml="auto" timestamp={event.created_at} />
</CardFooter>
)}
</Card>
{isOwn && avatar}
</Flex>
);
}

View File

@ -0,0 +1,89 @@
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { Button, Flex, FlexProps, Heading, useToast } from "@chakra-ui/react";
import { useSigningContext } from "../../providers/signing-provider";
import MagicTextArea, { RefType } from "../../components/magic-textarea";
import { useTextAreaUploadFileWithForm } from "../../hooks/use-textarea-upload-file";
import clientRelaysService from "../../services/client-relays";
import { unique } from "../../helpers/array";
import { DraftNostrEvent } from "../../types/nostr-event";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { useUserRelays } from "../../hooks/use-user-relays";
import { RelayMode } from "../../classes/relay";
import { useDecryptionContext } from "../../providers/dycryption-provider";
export default function SendMessageForm({ pubkey, ...props }: { pubkey: string } & Omit<FlexProps, "children">) {
const toast = useToast();
const { requestEncrypt, requestSignature } = useSigningContext();
const { getOrCreateContainer } = useDecryptionContext();
const [loadingMessage, setLoadingMessage] = useState("");
const { getValues, setValue, watch, register, handleSubmit, formState, reset } = useForm({
defaultValues: {
content: "",
},
mode: "all",
});
watch("content");
const textAreaRef = useRef<RefType | null>(null);
const { onPaste } = useTextAreaUploadFileWithForm(textAreaRef, getValues, setValue);
const usersInbox = useUserRelays(pubkey)
.filter((r) => r.mode & RelayMode.READ)
.map((r) => r.url);
const sendMessage = handleSubmit(async (values) => {
try {
if (!values.content) return;
setLoadingMessage("Encrypting...");
const encrypted = await requestEncrypt(values.content, pubkey);
const event: DraftNostrEvent = {
kind: Kind.EncryptedDirectMessage,
content: encrypted,
tags: [["p", pubkey]],
created_at: dayjs().unix(),
};
setLoadingMessage("Signing...");
const signed = await requestSignature(event);
const writeRelays = clientRelaysService.getWriteUrls();
const relays = unique([...writeRelays, ...usersInbox]);
new NostrPublishAction("Send DM", relays, signed);
reset();
// add plaintext to decryption context
getOrCreateContainer(pubkey, encrypted).plaintext.next(values.content);
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
setLoadingMessage("");
});
return (
<Flex as="form" gap="2" {...props}>
{loadingMessage ? (
<Heading size="md" mx="auto" my="4">
{loadingMessage}
</Heading>
) : (
<>
<MagicTextArea
autoFocus
mb="2"
value={getValues().content}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
rows={2}
isRequired
instanceRef={(inst) => (textAreaRef.current = inst)}
onPaste={onPaste}
/>
<Button onClick={sendMessage}>Send</Button>
</>
)}
</Flex>
);
}

View File

@ -73,7 +73,7 @@ export default function LoginNip05View() {
}
}
accountService.addAccount({ pubkey, relays: Array.from(bootstrapRelays), readonly: true });
accountService.addAccount({ type: "pubkey", pubkey, relays: Array.from(bootstrapRelays), readonly: true });
}
accountService.switchAccount(pubkey);

View File

@ -0,0 +1,70 @@
import { useState } from "react";
import { Button, Flex, FormControl, FormHelperText, FormLabel, Input, Text, useToast } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import accountService from "../../services/account";
import nostrConnectService, { NostrConnectClient } from "../../services/nostr-connect";
export default function LoginNostrConnectView() {
const navigate = useNavigate();
const toast = useToast();
const [uri, setUri] = useState("");
const [loading, setLoading] = useState<string | undefined>();
const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => {
e.preventDefault();
try {
setLoading("Connecting...");
let client: NostrConnectClient;
if (uri.startsWith("bunker://")) {
client = nostrConnectService.fromBunkerURI(uri);
await client.connect();
} else if (uri.startsWith("npub")) {
client = nostrConnectService.fromNsecBunkerToken(uri);
const [npub, hexToken] = uri.split("#");
await client.connect(hexToken);
} else throw new Error("Unknown format");
nostrConnectService.saveClient(client);
accountService.addAccount({
type: "nostr-connect",
signerRelays: client.relays,
clientSecretKey: client.secretKey,
pubkey: client.pubkey,
readonly: false,
});
accountService.switchAccount(client.pubkey);
} catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message });
}
setLoading(undefined);
};
return (
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} minWidth="350" w="full">
{loading && <Text fontSize="lg">{loading}</Text>}
{!loading && (
<FormControl>
<FormLabel>Connect URI</FormLabel>
<Input
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
isRequired
value={uri}
onChange={(e) => setUri(e.target.value)}
autoComplete="off"
/>
<FormHelperText>A bunker connect URI</FormHelperText>
</FormControl>
)}
<Flex justifyContent="space-between" gap="2">
<Button variant="link" onClick={() => navigate("../")}>
Back
</Button>
<Button colorScheme="primary" ml="auto" type="submit" isLoading={!!loading}>
Connect
</Button>
</Flex>
</Flex>
);
}

View File

@ -22,7 +22,7 @@ export default function LoginNpubView() {
}
if (!accountService.hasAccount(pubkey)) {
accountService.addAccount({ pubkey, relays: [relayUrl], readonly: true });
accountService.addAccount({ type: "pubkey", pubkey, relays: [relayUrl], readonly: true });
}
accountService.switchAccount(pubkey);
};

View File

@ -73,7 +73,7 @@ export default function LoginNsecView() {
const pubkey = getPublicKey(hexKey);
const encrypted = await signingService.encryptSecKey(hexKey);
accountService.addAccount({ pubkey, relays: [relayUrl], ...encrypted, readonly: false });
accountService.addAccount({ type: "local", pubkey, relays: [relayUrl], ...encrypted, readonly: false });
accountService.switchAccount(pubkey);
};

View File

@ -51,7 +51,7 @@ export default function LoginStartView() {
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine", COMMON_CONTACT_RELAY];
}
accountService.addAccount({ pubkey, relays, connectionType: "extension", readonly: false });
accountService.addAccount({ pubkey, relays, type: "extension", readonly: false });
}
accountService.switchAccount(pubkey);
@ -76,7 +76,7 @@ export default function LoginStartView() {
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine", COMMON_CONTACT_RELAY];
}
accountService.addAccount({ pubkey, relays, connectionType: "serial", readonly: false });
accountService.addAccount({ pubkey, relays, type: "serial", readonly: false });
}
accountService.switchAccount(pubkey);
@ -98,7 +98,7 @@ export default function LoginStartView() {
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine", COMMON_CONTACT_RELAY];
}
accountService.addAccount({ pubkey, relays, connectionType: "amber", readonly: false });
accountService.addAccount({ pubkey, relays, type: "amber", readonly: false });
}
accountService.switchAccount(pubkey);
} catch (e) {
@ -113,6 +113,9 @@ export default function LoginStartView() {
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="sm" colorScheme="primary">
Sign in with extension
</Button>
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="sm">
Nostr Connect (NIP-46)
</Button>
{serialPortService.supported && (
<ButtonGroup colorScheme="purple">
<Button onClick={signinWithSerial} leftIcon={<UsbFlashDrive boxSize={6} />} w="xs">
@ -155,7 +158,7 @@ export default function LoginStartView() {
{advanced.isOpen && (
<>
<Button as={RouterLink} to="./nip05" state={location.state} w="sm">
NIP05
DNS ID
<Badge ml="2" colorScheme="blue">
read-only
</Badge>

View File

@ -63,7 +63,7 @@ export default function CreateStep({
// login
const pubkey = getPublicKey(hex);
const encrypted = await signingService.encryptSecKey(hex);
accountService.addAccount({ pubkey, relays, ...encrypted, readonly: false });
accountService.addAccount({ type: "local", pubkey, relays, ...encrypted, readonly: false });
accountService.switchAccount(pubkey);
// set relays

View File

@ -34,7 +34,7 @@ function Warning() {
const secKey = generatePrivateKey();
const encrypted = await signingService.encryptSecKey(secKey);
const pubkey = getPublicKey(secKey);
accountService.addAccount({ ...encrypted, pubkey, readonly: false });
accountService.addAccount({ type: "local", ...encrypted, pubkey, readonly: false });
accountService.switchAccount(pubkey);
navigate("/relays");
} catch (e) {

View File

@ -44,6 +44,7 @@ export const UserProfileMenu = ({
const readRelays = userRelays.filter((r) => r.mode === RelayMode.READ).map((r) => r.url) ?? [];
if (!accountService.hasAccount(pubkey)) {
accountService.addAccount({
type: 'pubkey',
pubkey,
relays: readRelays,
readonly: true,