mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
NIP-46 and better DMs
This commit is contained in:
parent
907e6df271
commit
53b2c9e399
5
.changeset/eight-pots-sort.md
Normal file
5
.changeset/eight-pots-sort.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add reactions and zaps to DMs
|
5
.changeset/long-oranges-type.md
Normal file
5
.changeset/long-oranges-type.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Make DMs view more readable
|
5
.changeset/smart-dryers-wave.md
Normal file
5
.changeset/smart-dryers-wave.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for NIP-46 signer
|
@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 />}
|
||||
|
@ -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)) ?? [];
|
@ -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" />
|
||||
|
@ -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 />}
|
||||
|
72
src/hooks/use-textarea-upload-file.ts
Normal file
72
src/hooks/use-textarea-upload-file.ts
Normal 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 };
|
||||
}
|
@ -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";
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
224
src/services/nostr-connect.ts
Normal file
224
src/services/nostr-connect.ts
Normal 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;
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
89
src/views/messages/send-message-form.tsx
Normal file
89
src/views/messages/send-message-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
70
src/views/signin/nostr-connect.tsx
Normal file
70
src/views/signin/nostr-connect.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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);
|
||||
};
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user