mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
Add support for nsecBunker OAuth flow
This commit is contained in:
parent
b15f9bc1a8
commit
e8e3dc0fac
5
.changeset/little-rockets-breathe.md
Normal file
5
.changeset/little-rockets-breathe.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for nsecBunker OAuth flow
|
@ -86,6 +86,8 @@ import CacheRelayView from "./views/relays/cache";
|
||||
import RelaySetView from "./views/relays/relay-set";
|
||||
import AppRelays from "./views/relays/app";
|
||||
import MailboxesView from "./views/relays/mailboxes";
|
||||
import LoginNostrAddressView from "./views/signin/address";
|
||||
import LoginNostrAddressCreate from "./views/signin/address/create";
|
||||
const TracksView = lazy(() => import("./views/tracks"));
|
||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||
const UserVideosTab = lazy(() => import("./views/user/videos"));
|
||||
@ -173,6 +175,13 @@ const router = createHashRouter([
|
||||
{ path: "npub", element: <LoginNpubView /> },
|
||||
{ path: "nip05", element: <LoginNip05View /> },
|
||||
{ path: "nsec", element: <LoginNsecView /> },
|
||||
{
|
||||
path: "address",
|
||||
children: [
|
||||
{ path: "", element: <LoginNostrAddressView /> },
|
||||
{ path: "create", element: <LoginNostrAddressCreate /> },
|
||||
],
|
||||
},
|
||||
{ path: "nostr-connect", element: <LoginNostrConnectView /> },
|
||||
],
|
||||
},
|
||||
|
@ -125,6 +125,9 @@ export default class NostrMultiSubscription {
|
||||
|
||||
return this;
|
||||
}
|
||||
waitForConnection(): Promise<void> {
|
||||
return Promise.all(this.relays.map((r) => r.waitForConnection())).then((v) => void 0);
|
||||
}
|
||||
close() {
|
||||
if (this.state !== NostrMultiSubscription.OPEN) return this;
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { offlineMode } from "../services/offline-mode";
|
||||
import relayScoreboardService from "../services/relay-scoreboard";
|
||||
import { RawIncomingNostrEvent, NostrEvent, CountResponse } from "../types/nostr-event";
|
||||
import { NostrOutgoingMessage } from "../types/nostr-query";
|
||||
import createDefer, { Deferred } from "./deferred";
|
||||
import { PersistentSubject, Subject } from "./subject";
|
||||
|
||||
export type IncomingEvent = {
|
||||
@ -54,6 +55,8 @@ export default class Relay {
|
||||
onCommandResult = new Subject<IncomingCommandResult>(undefined, false);
|
||||
ws?: WebSocket;
|
||||
|
||||
private connectionPromises: Deferred<void>[] = [];
|
||||
|
||||
private connectionTimer?: () => void;
|
||||
private ejectTimer?: () => void;
|
||||
private intentionalClose = false;
|
||||
@ -77,6 +80,9 @@ export default class Relay {
|
||||
if (this.connectionTimer) {
|
||||
this.connectionTimer();
|
||||
this.connectionTimer = undefined;
|
||||
|
||||
for (const p of this.connectionPromises) p.reject();
|
||||
this.connectionPromises = [];
|
||||
}
|
||||
// relayScoreboardService.relayTimeouts.get(this.url).addIncident();
|
||||
}, CONNECTION_TIMEOUT);
|
||||
@ -101,6 +107,9 @@ export default class Relay {
|
||||
}
|
||||
|
||||
this.sendQueued();
|
||||
|
||||
for (const p of this.connectionPromises) p.resolve();
|
||||
this.connectionPromises = [];
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.onClose.next(this);
|
||||
@ -129,6 +138,13 @@ export default class Relay {
|
||||
this.subscriptionResTimer.clear();
|
||||
}
|
||||
|
||||
waitForConnection(): Promise<void> {
|
||||
if (this.connected) return Promise.resolve();
|
||||
const p = createDefer<void>();
|
||||
this.connectionPromises.push(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
private startSubResTimer(sub: string) {
|
||||
this.subscriptionResTimer.set(sub, relayScoreboardService.relayResponseTimes.get(this.url).createTimer());
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { useAsync } from "react-use";
|
||||
import { useUserMetadata } from "../hooks/use-user-metadata";
|
||||
import { getIdenticon } from "../helpers/identicon";
|
||||
import { safeUrl } from "../helpers/parse";
|
||||
import { getUserDisplayName } from "../helpers/user-metadata";
|
||||
import { Kind0ParsedContent, getUserDisplayName } from "../helpers/user-metadata";
|
||||
import useAppSettings from "../hooks/use-app-settings";
|
||||
import useCurrentAccount from "../hooks/use-current-account";
|
||||
import { buildImageProxyURL } from "../helpers/image";
|
||||
@ -18,41 +18,52 @@ export const UserIdenticon = memo(({ pubkey }: { pubkey: string }) => {
|
||||
|
||||
const RESIZE_PROFILE_SIZE = 96;
|
||||
|
||||
export type UserAvatarProps = Omit<AvatarProps, "src"> & {
|
||||
export type UserAvatarProps = Omit<MetadataAvatarProps, "pubkey" | "metadata"> & {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
noProxy?: boolean;
|
||||
};
|
||||
export const UserAvatar = forwardRef<HTMLDivElement, UserAvatarProps>(({ pubkey, noProxy, relay, ...props }, ref) => {
|
||||
const { imageProxy, proxyUserMedia, hideUsernames } = useAppSettings();
|
||||
const account = useCurrentAccount();
|
||||
const metadata = useUserMetadata(pubkey, relay ? [relay] : undefined);
|
||||
const picture = useMemo(() => {
|
||||
if (hideUsernames && pubkey !== account?.pubkey) return undefined;
|
||||
if (metadata?.picture) {
|
||||
const src = safeUrl(metadata?.picture);
|
||||
if (src) {
|
||||
const proxyURL = buildImageProxyURL(src, RESIZE_PROFILE_SIZE);
|
||||
if (proxyURL) return proxyURL;
|
||||
} else if (!noProxy && proxyUserMedia) {
|
||||
const last4 = String(pubkey).slice(pubkey.length - 4, pubkey.length);
|
||||
return `https://media.nostr.band/thumbs/${last4}/${pubkey}-picture-64`;
|
||||
}
|
||||
return src;
|
||||
}
|
||||
}, [metadata?.picture, imageProxy, proxyUserMedia, hideUsernames, account]);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
src={picture}
|
||||
icon={<UserIdenticon pubkey={pubkey} />}
|
||||
overflow="hidden"
|
||||
title={getUserDisplayName(metadata, pubkey)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <MetadataAvatar pubkey={pubkey} metadata={metadata} noProxy={noProxy} ref={ref} {...props} />;
|
||||
});
|
||||
UserAvatar.displayName = "UserAvatar";
|
||||
|
||||
export type MetadataAvatarProps = Omit<AvatarProps, "src"> & {
|
||||
metadata?: Kind0ParsedContent;
|
||||
pubkey?: string;
|
||||
noProxy?: boolean;
|
||||
};
|
||||
export const MetadataAvatar = forwardRef<HTMLDivElement, MetadataAvatarProps>(
|
||||
({ pubkey, metadata, noProxy, ...props }, ref) => {
|
||||
const { imageProxy, proxyUserMedia, hideUsernames } = useAppSettings();
|
||||
const account = useCurrentAccount();
|
||||
const picture = useMemo(() => {
|
||||
if (hideUsernames && pubkey && pubkey !== account?.pubkey) return undefined;
|
||||
if (metadata?.picture) {
|
||||
const src = safeUrl(metadata?.picture);
|
||||
if (src) {
|
||||
const proxyURL = buildImageProxyURL(src, RESIZE_PROFILE_SIZE);
|
||||
if (proxyURL) return proxyURL;
|
||||
} else if (!noProxy && proxyUserMedia && pubkey) {
|
||||
const last4 = String(pubkey).slice(pubkey.length - 4, pubkey.length);
|
||||
return `https://media.nostr.band/thumbs/${last4}/${pubkey}-picture-64`;
|
||||
}
|
||||
return src;
|
||||
}
|
||||
}, [metadata?.picture, imageProxy, proxyUserMedia, hideUsernames, account]);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
src={picture}
|
||||
icon={pubkey ? <UserIdenticon pubkey={pubkey} /> : undefined}
|
||||
overflow="hidden"
|
||||
title={getUserDisplayName(metadata, pubkey ?? "")}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
UserAvatar.displayName = "UserAvatar";
|
||||
|
||||
export default memo(UserAvatar);
|
||||
|
@ -6,6 +6,7 @@ export type Kind0ParsedContent = {
|
||||
pubkey?: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
displayName?: string;
|
||||
about?: string;
|
||||
/** @deprecated */
|
||||
image?: string;
|
||||
@ -37,11 +38,11 @@ export function parseKind0Event(event: NostrEvent): Kind0ParsedContent {
|
||||
export function getSearchNames(metadata: Kind0ParsedContent) {
|
||||
if (!metadata) return [];
|
||||
|
||||
return [metadata.display_name, metadata.name].filter(Boolean) as string[];
|
||||
return [metadata.displayName, metadata.display_name, metadata.name].filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export function getUserDisplayName(metadata: Kind0ParsedContent | undefined, pubkey: string) {
|
||||
return metadata?.display_name || metadata?.name || truncatedId(nip19.npubEncode(pubkey));
|
||||
return metadata?.displayName || metadata?.display_name || metadata?.name || truncatedId(nip19.npubEncode(pubkey));
|
||||
}
|
||||
|
||||
export function fixWebsiteUrl(website: string) {
|
||||
|
14
src/hooks/use-nip05-providers.ts
Normal file
14
src/hooks/use-nip05-providers.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
import useTimelineLoader from "./use-timeline-loader";
|
||||
import { recommendedReadRelays } from "../services/client-relays";
|
||||
import useSubject from "./use-subject";
|
||||
|
||||
export default function useNip05Providers() {
|
||||
const timeline = useTimelineLoader("nip05-providers", recommendedReadRelays, {
|
||||
kinds: [kinds.Handlerinformation],
|
||||
"#k": [String(kinds.NostrConnect)],
|
||||
});
|
||||
|
||||
return useSubject(timeline.timeline);
|
||||
}
|
@ -5,10 +5,13 @@ import useSubject from "./use-subject";
|
||||
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||
import { COMMON_CONTACT_RELAY } from "../const";
|
||||
|
||||
export function useUserMetadata(pubkey: string, additionalRelays: Iterable<string> = [], opts: RequestOptions = {}) {
|
||||
export function useUserMetadata(pubkey?: string, additionalRelays: Iterable<string> = [], opts: RequestOptions = {}) {
|
||||
const relays = useReadRelays([...additionalRelays, COMMON_CONTACT_RELAY]);
|
||||
|
||||
const subject = useMemo(() => userMetadataService.requestMetadata(pubkey, relays, opts), [pubkey, relays]);
|
||||
const subject = useMemo(
|
||||
() => (pubkey ? userMetadataService.requestMetadata(pubkey, relays, opts) : undefined),
|
||||
[pubkey, relays],
|
||||
);
|
||||
const metadata = useSubject(subject);
|
||||
|
||||
return metadata;
|
||||
|
@ -14,12 +14,15 @@ export function parseAddress(address: string): { name?: string; domain?: string
|
||||
type IdentityJson = {
|
||||
names: Record<string, string | undefined>;
|
||||
relays?: Record<string, string[]>;
|
||||
nip46?: Record<string, string[]>;
|
||||
};
|
||||
export type DnsIdentity = {
|
||||
name: string;
|
||||
domain: string;
|
||||
pubkey: string;
|
||||
relays: string[];
|
||||
hasNip46?: boolean;
|
||||
nip46Relays?: string[];
|
||||
};
|
||||
|
||||
function getIdentityFromJson(name: string, domain: string, json: IdentityJson): DnsIdentity | undefined {
|
||||
@ -27,7 +30,9 @@ function getIdentityFromJson(name: string, domain: string, json: IdentityJson):
|
||||
if (!pubkey) return;
|
||||
|
||||
const relays: string[] = json.relays?.[pubkey] ?? [];
|
||||
return { name, domain, pubkey, relays };
|
||||
const hasNip46 = !!json.nip46;
|
||||
const nip46Relays = json.nip46?.[pubkey];
|
||||
return { name, domain, pubkey, relays, nip46Relays, hasNip46 };
|
||||
}
|
||||
|
||||
class DnsIdentityService {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey, nip04, nip19 } from "nostr-tools";
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey, kinds, nip04, nip19 } from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@ -11,10 +11,16 @@ import createDefer, { Deferred } from "../classes/deferred";
|
||||
import { truncatedId } from "../helpers/nostr/events";
|
||||
import { NostrConnectAccount } from "./account";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
import { normalizeRelayURL } from "../helpers/relay";
|
||||
import { safeRelayUrl } from "../helpers/relay";
|
||||
import Subject from "../classes/subject";
|
||||
|
||||
export function isErrorResponse(response: any): response is NostrConnectErrorResponse {
|
||||
return !!response.error;
|
||||
}
|
||||
|
||||
export enum NostrConnectMethod {
|
||||
Connect = "connect",
|
||||
CreateAccount = "create_account",
|
||||
Disconnect = "disconnect",
|
||||
GetPublicKey = "get_pubic_key",
|
||||
SignEvent = "sign_event",
|
||||
@ -23,6 +29,7 @@ export enum NostrConnectMethod {
|
||||
}
|
||||
type RequestParams = {
|
||||
[NostrConnectMethod.Connect]: [string] | [string, string];
|
||||
[NostrConnectMethod.CreateAccount]: [string, string] | [string, string, string];
|
||||
[NostrConnectMethod.Disconnect]: [];
|
||||
[NostrConnectMethod.GetPublicKey]: [];
|
||||
[NostrConnectMethod.SignEvent]: [string];
|
||||
@ -31,6 +38,7 @@ type RequestParams = {
|
||||
};
|
||||
type ResponseResults = {
|
||||
[NostrConnectMethod.Connect]: "ack";
|
||||
[NostrConnectMethod.CreateAccount]: string;
|
||||
[NostrConnectMethod.Disconnect]: "ack";
|
||||
[NostrConnectMethod.GetPublicKey]: string;
|
||||
[NostrConnectMethod.SignEvent]: string;
|
||||
@ -43,6 +51,11 @@ export type NostrConnectResponse<N extends NostrConnectMethod> = {
|
||||
result: ResponseResults[N];
|
||||
error?: string;
|
||||
};
|
||||
export type NostrConnectErrorResponse = {
|
||||
id: string;
|
||||
result: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export class NostrConnectClient {
|
||||
sub: NostrMultiSubscription;
|
||||
@ -50,27 +63,41 @@ export class NostrConnectClient {
|
||||
|
||||
isConnected = false;
|
||||
pubkey: string;
|
||||
provider?: string;
|
||||
relays: string[];
|
||||
|
||||
secretKey: string;
|
||||
publicKey: string;
|
||||
|
||||
onAuthURL = new Subject<string>(undefined, false);
|
||||
|
||||
supportedMethods: NostrConnectMethod[] | undefined;
|
||||
|
||||
constructor(pubkey: string, relays: string[], secretKey?: string) {
|
||||
constructor(pubkey: string, relays: string[], secretKey?: string, provider?: string) {
|
||||
this.sub = new NostrMultiSubscription(`${truncatedId(pubkey)}-nostr-connect`);
|
||||
this.pubkey = pubkey;
|
||||
this.relays = relays;
|
||||
this.provider = provider;
|
||||
|
||||
this.secretKey = secretKey || bytesToHex(generateSecretKey());
|
||||
this.publicKey = getPublicKey(hexToBytes(this.secretKey));
|
||||
|
||||
this.sub.onEvent.subscribe(this.handleEvent, this);
|
||||
this.sub.setQueryMap(createSimpleQueryMap(this.relays, { kinds: [24133], "#p": [this.publicKey] }));
|
||||
this.sub.setQueryMap(
|
||||
createSimpleQueryMap(this.relays, {
|
||||
kinds: [kinds.NostrConnect, 24134],
|
||||
"#p": [this.publicKey],
|
||||
}),
|
||||
);
|
||||
|
||||
this.log("Secret Key:", this.secretKey);
|
||||
this.log("Public Key:", this.publicKey);
|
||||
}
|
||||
|
||||
open() {
|
||||
async open() {
|
||||
this.sub.open();
|
||||
await this.sub.waitForConnection();
|
||||
this.log("Connected to relays", this.relays);
|
||||
}
|
||||
close() {
|
||||
this.sub.close();
|
||||
@ -78,34 +105,35 @@ export class NostrConnectClient {
|
||||
|
||||
private requests = new Map<string, Deferred<any>>();
|
||||
async handleEvent(event: NostrEvent) {
|
||||
if (event.kind !== 24133) return;
|
||||
if (this.provider && event.pubkey !== this.provider) return;
|
||||
|
||||
const to = event.tags.find(isPTag)?.[1];
|
||||
if (!to) return;
|
||||
|
||||
try {
|
||||
const responseStr = await nip04.decrypt(this.secretKey, this.pubkey, event.content);
|
||||
const responseStr = await nip04.decrypt(this.secretKey, event.pubkey, event.content);
|
||||
const response = JSON.parse(responseStr);
|
||||
this.log("Got Response", response);
|
||||
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));
|
||||
if (response.result === "auth_url") this.onAuthURL.next(response.error);
|
||||
else p.reject(response);
|
||||
} else if (response.result) {
|
||||
this.log(response.id, response);
|
||||
this.log(response.id, response.result);
|
||||
p.resolve(response.result);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
private createEvent(content: string) {
|
||||
private createEvent(content: string, target = this.pubkey, kind = kinds.NostrConnect) {
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: 24133,
|
||||
kind,
|
||||
created_at: dayjs().unix(),
|
||||
tags: [["p", this.pubkey]],
|
||||
tags: [["p", target]],
|
||||
content,
|
||||
},
|
||||
hexToBytes(this.secretKey),
|
||||
@ -114,22 +142,44 @@ export class NostrConnectClient {
|
||||
private async makeRequest<T extends NostrConnectMethod>(
|
||||
method: T,
|
||||
params: RequestParams[T],
|
||||
kind = kinds.NostrConnect,
|
||||
): Promise<ResponseResults[T]> {
|
||||
const id = nanoid();
|
||||
const request: NostrConnectRequest<T> = { method, id, params };
|
||||
const id = nanoid(8);
|
||||
const request: NostrConnectRequest<T> = { id, method, 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 event = this.createEvent(encrypted, this.pubkey, kind);
|
||||
this.log(`Sending request ${id} (${method}) ${JSON.stringify(params)}`, event);
|
||||
this.sub.sendAll(event);
|
||||
|
||||
const p = createDefer<ResponseResults[T]>();
|
||||
this.requests.set(id, p);
|
||||
return p;
|
||||
}
|
||||
private async makeAdminRequest<T extends NostrConnectMethod>(
|
||||
method: T,
|
||||
params: RequestParams[T],
|
||||
kind = 24134,
|
||||
): Promise<ResponseResults[T]> {
|
||||
if (!this.provider) throw new Error("Missing provider");
|
||||
const id = nanoid(8);
|
||||
const request: NostrConnectRequest<T> = { id, method, params };
|
||||
const encrypted = await nip04.encrypt(this.secretKey, this.provider, JSON.stringify(request));
|
||||
const event = this.createEvent(encrypted, this.provider, kind);
|
||||
this.log(`Sending admin request ${id} (${method}) ${JSON.stringify(params)}`, event);
|
||||
this.sub.sendAll(event);
|
||||
|
||||
const p = createDefer<ResponseResults[T]>();
|
||||
this.requests.set(id, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
connect(token?: string) {
|
||||
this.open();
|
||||
async connect(token?: string) {
|
||||
await this.open();
|
||||
try {
|
||||
const result = this.makeRequest(NostrConnectMethod.Connect, token ? [this.publicKey, token] : [this.publicKey]);
|
||||
const result = await this.makeRequest(
|
||||
NostrConnectMethod.Connect,
|
||||
token ? [this.publicKey, token] : [this.publicKey],
|
||||
);
|
||||
this.isConnected = true;
|
||||
return result;
|
||||
} catch (e) {
|
||||
@ -138,6 +188,24 @@ export class NostrConnectClient {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(name: string, domain: string, email?: string) {
|
||||
await this.open();
|
||||
|
||||
try {
|
||||
const newPubkey = await this.makeAdminRequest(
|
||||
NostrConnectMethod.CreateAccount,
|
||||
email ? [name, domain, email] : [name, domain],
|
||||
);
|
||||
this.pubkey = newPubkey;
|
||||
this.isConnected = true;
|
||||
return newPubkey;
|
||||
} catch (e) {
|
||||
this.isConnected = false;
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
ensureConnected() {
|
||||
if (!this.isConnected) return this.connect();
|
||||
}
|
||||
@ -172,21 +240,26 @@ class NostrConnectService {
|
||||
if (!this.clients.includes(client)) this.clients.push(client);
|
||||
}
|
||||
|
||||
createClient(pubkey: string, relays: string[], secretKey?: string) {
|
||||
createClient(pubkey: string, relays: string[], secretKey?: string, provider?: string) {
|
||||
if (this.getClient(pubkey)) throw new Error("A client for that pubkey already exists");
|
||||
|
||||
const client = new NostrConnectClient(pubkey, relays, secretKey);
|
||||
const client = new NostrConnectClient(pubkey, relays, secretKey, provider);
|
||||
client.log = this.log.extend(pubkey);
|
||||
|
||||
this.log(`Created client for ${pubkey} using ${relays.join(", ")}`);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
fromHostedBunker(pubkey: string, relays: string[], provider?: string) {
|
||||
return this.getClient(pubkey) || this.createClient(pubkey, relays, undefined, provider);
|
||||
}
|
||||
fromBunkerAddress(address: string) {
|
||||
const parts = address.replace("bunker://", "").split("@");
|
||||
if (parts.length !== 2) throw new Error("Invalid bunker address");
|
||||
const pubkey = normalizeToHexPubkey(parts[0]);
|
||||
const pathRelay = normalizeRelayURL("wss://" + parts[1]);
|
||||
const pathRelay = safeRelayUrl("wss://" + parts[1]);
|
||||
if (!pathRelay) throw new Error("Missing relay");
|
||||
if (!pubkey || !isHexKey(pubkey)) throw new Error("Missing pubkey");
|
||||
|
||||
return this.getClient(pubkey) || this.createClient(pubkey, [pathRelay]);
|
||||
@ -204,7 +277,7 @@ class NostrConnectService {
|
||||
|
||||
return this.getClient(pubkey) || this.createClient(pubkey, relays);
|
||||
}
|
||||
fromNsecBunkerToken(token: string) {
|
||||
fromBunkerToken(token: string) {
|
||||
const [npub, hexToken] = token.split("#");
|
||||
const decoded = nip19.decode(npub);
|
||||
const pubkey = getPubkeyFromDecodeResult(decoded);
|
||||
|
@ -61,7 +61,7 @@ if (import.meta.env.DEV) {
|
||||
// random helper for logging
|
||||
export function nameOrPubkey(pubkey: string) {
|
||||
const parsed = userMetadataService.getSubject(pubkey).value;
|
||||
return parsed?.name || parsed?.display_name || pubkey;
|
||||
return parsed?.displayName || parsed?.display_name || parsed?.name || pubkey;
|
||||
}
|
||||
|
||||
export default userMetadataService;
|
||||
|
@ -193,7 +193,7 @@ export const ProfileEditView = () => {
|
||||
|
||||
const defaultValues = useMemo<FormData>(
|
||||
() => ({
|
||||
displayName: metadata?.display_name,
|
||||
displayName: metadata?.displayName || metadata?.display_name,
|
||||
username: metadata?.name,
|
||||
picture: metadata?.picture,
|
||||
about: metadata?.about,
|
||||
@ -209,7 +209,7 @@ export const ProfileEditView = () => {
|
||||
name: data.username,
|
||||
picture: data.picture,
|
||||
};
|
||||
if (data.displayName) metadata.display_name = data.displayName;
|
||||
if (data.displayName) metadata.displayName = metadata.display_name = data.displayName;
|
||||
if (data.about) metadata.about = data.about;
|
||||
if (data.website) metadata.website = data.website;
|
||||
if (data.nip05) metadata.nip05 = data.nip05;
|
||||
|
155
src/views/signin/address/create.tsx
Normal file
155
src/views/signin/address/create.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Flex,
|
||||
Heading,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightAddon,
|
||||
LinkBox,
|
||||
Tag,
|
||||
Text,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import useNip05Providers from "../../../hooks/use-nip05-providers";
|
||||
import { Kind0ParsedContent } from "../../../helpers/user-metadata";
|
||||
import HoverLinkOverlay from "../../../components/hover-link-overlay";
|
||||
import { getEventCoordinate } from "../../../helpers/nostr/events";
|
||||
import { MetadataAvatar } from "../../../components/user-avatar";
|
||||
import { ErrorBoundary } from "../../../components/error-boundary";
|
||||
import dnsIdentityService from "../../../services/dns-identity";
|
||||
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
||||
import nostrConnectService from "../../../services/nostr-connect";
|
||||
import accountService from "../../../services/account";
|
||||
|
||||
function ProviderCard({ onClick, provider }: { onClick: () => void; provider: NostrEvent }) {
|
||||
const metadata = JSON.parse(provider.content) as Kind0ParsedContent;
|
||||
const features = provider.tags.filter((t) => t[0] === "f").map((t) => t[1]);
|
||||
|
||||
if (!metadata.nip05) return null;
|
||||
|
||||
return (
|
||||
<Card as={LinkBox}>
|
||||
<CardHeader p="4" display="flex" gap="2" alignItems="center">
|
||||
<MetadataAvatar metadata={metadata} size="sm" />
|
||||
<HoverLinkOverlay onClick={onClick} cursor="pointer">
|
||||
<Heading size="sm">{metadata.displayName || metadata.name}</Heading>
|
||||
</HoverLinkOverlay>
|
||||
</CardHeader>
|
||||
<CardBody p="4" pt="0">
|
||||
<Text>{metadata.about}</Text>
|
||||
{features.length > 0 && (
|
||||
<Flex gap="2" mt="2">
|
||||
{features.map((f) => (
|
||||
<Tag key={f} colorScheme="primary">
|
||||
{f}
|
||||
</Tag>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginNostrAddressCreate() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const [loading, setLoading] = useState<string>();
|
||||
const [name, setName] = useState("");
|
||||
const providers = useNip05Providers();
|
||||
const [selected, setSelected] = useState<NostrEvent>();
|
||||
const metadata = useUserMetadata(selected?.pubkey);
|
||||
|
||||
const createAccount: React.FormEventHandler<HTMLDivElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selected || !name) return;
|
||||
|
||||
try {
|
||||
setLoading("Creating...");
|
||||
if (!metadata) throw new Error("Cant verify provider");
|
||||
if (!metadata.nip05) throw new Error("Provider missing nip05 address");
|
||||
const nip05 = await dnsIdentityService.fetchIdentity(metadata.nip05);
|
||||
if (!nip05 || nip05.pubkey !== selected.pubkey) throw new Error("Invalid provider");
|
||||
if (nip05.name !== "_") throw new Error("Provider dose not own the domain");
|
||||
if (!nip05.hasNip46) throw new Error("Provider dose not support NIP-46");
|
||||
|
||||
const client = nostrConnectService.createClient("", nip05.nip46Relays || nip05.relays, undefined, nip05.pubkey);
|
||||
client.onAuthURL.subscribe((url) => {
|
||||
window.open(url, "auth", "width=400,height=600,resizable=no,status=no,location=no,toolbar=no,menubar=no");
|
||||
});
|
||||
|
||||
const newPubkey = await client.createAccount(name, nip05.domain);
|
||||
|
||||
await client.connect();
|
||||
|
||||
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({ description: e.message, status: "error" });
|
||||
}
|
||||
setLoading(undefined);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) return <Text fontSize="lg">{loading}</Text>;
|
||||
|
||||
if (selected) {
|
||||
const metadata = JSON.parse(selected.content) as Kind0ParsedContent;
|
||||
const [_, domain] = metadata.nip05!.split("@");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<MetadataAvatar metadata={metadata} size="sm" />
|
||||
<Heading size="sm">{metadata.displayName || metadata.name}</Heading>
|
||||
</Flex>
|
||||
<InputGroup>
|
||||
<Input name="name" isRequired value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<InputRightAddon as="button" onClick={() => setSelected(undefined)} cursor="pointer">
|
||||
@{domain}
|
||||
</InputRightAddon>
|
||||
</InputGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{providers.map((p) => (
|
||||
<ErrorBoundary key={getEventCoordinate(p)}>
|
||||
<ProviderCard provider={p} onClick={() => setSelected(p)} />
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex as="form" direction="column" gap="2" onSubmit={createAccount} w="full">
|
||||
{renderContent()}
|
||||
<Flex justifyContent="space-between" gap="2" mt="2">
|
||||
<Button variant="link" onClick={() => navigate("../")}>
|
||||
Back
|
||||
</Button>
|
||||
<Button colorScheme="primary" ml="auto" type="submit" isLoading={!!loading} isDisabled={!selected}>
|
||||
Create Account
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
157
src/views/signin/address/index.tsx
Normal file
157
src/views/signin/address/index.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, CardProps, Flex, FormControl, FormLabel, Image, Input, Text, useToast } from "@chakra-ui/react";
|
||||
import { useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import { useDebounce } from "react-use";
|
||||
|
||||
import dnsIdentityService, { DnsIdentity } from "../../../services/dns-identity";
|
||||
import { CheckIcon } from "../../../components/icons";
|
||||
import nostrConnectService from "../../../services/nostr-connect";
|
||||
import accountService from "../../../services/account";
|
||||
import { COMMON_CONTACT_RELAY } from "../../../const";
|
||||
import { safeRelayUrls } from "../../../helpers/relay";
|
||||
|
||||
export default function LoginNostrAddressView() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const [provider, setProvider] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const userSpecifiedDomain = address.includes("@");
|
||||
|
||||
const fullAddress = userSpecifiedDomain ? address || undefined : provider ? address + "@" + provider : undefined;
|
||||
|
||||
const [rootNip05, setRootNip05] = useState<DnsIdentity>();
|
||||
const [nip05, setNip05] = useState<DnsIdentity>();
|
||||
useDebounce(
|
||||
async () => {
|
||||
if (!fullAddress) return setNip05(undefined);
|
||||
let [name, domain] = fullAddress.split("@");
|
||||
if (!name || !domain || !domain.includes(".")) return setNip05(undefined);
|
||||
setNip05(await dnsIdentityService.fetchIdentity(fullAddress));
|
||||
setRootNip05(await dnsIdentityService.fetchIdentity(`_@${domain}`));
|
||||
},
|
||||
300,
|
||||
[fullAddress],
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState<string | undefined>();
|
||||
const connect: React.FormEventHandler<HTMLDivElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!nip05) return;
|
||||
|
||||
try {
|
||||
if (nip05.hasNip46) {
|
||||
setLoading("Connecting...");
|
||||
const relays = safeRelayUrls(nip05.nip46Relays || rootNip05?.nip46Relays || rootNip05?.relays || nip05.relays);
|
||||
const client = nostrConnectService.fromHostedBunker(nip05.pubkey, relays, rootNip05?.pubkey);
|
||||
client.onAuthURL.subscribe((url) => {
|
||||
window.open(url, "auth", "width=400,height=600,resizable=no,status=no,location=no,toolbar=no,menubar=no");
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
nostrConnectService.saveClient(client);
|
||||
accountService.addAccount({
|
||||
type: "nostr-connect",
|
||||
signerRelays: client.relays,
|
||||
clientSecretKey: client.secretKey,
|
||||
pubkey: client.pubkey!,
|
||||
readonly: false,
|
||||
});
|
||||
accountService.switchAccount(client.pubkey!);
|
||||
} else {
|
||||
accountService.addAccount({
|
||||
type: "pubkey",
|
||||
pubkey: nip05.pubkey,
|
||||
relays: [...nip05.relays, COMMON_CONTACT_RELAY],
|
||||
readonly: true,
|
||||
});
|
||||
accountService.switchAccount(nip05.pubkey);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ status: "error", description: e.message });
|
||||
}
|
||||
setLoading(undefined);
|
||||
};
|
||||
|
||||
const renderStatus = () => {
|
||||
if (!address) return null;
|
||||
|
||||
const cardProps: CardProps = {
|
||||
variant: "outline",
|
||||
p: "2",
|
||||
flexDirection: "row",
|
||||
gap: "2",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
if (nip05) {
|
||||
if (nip05.hasNip46) {
|
||||
return (
|
||||
<Card {...cardProps}>
|
||||
<Image w="7" h="7" src={`//${nip05.domain}/favicon.ico`} />
|
||||
<Text fontWeight="bold">{fullAddress}</Text>
|
||||
<Text color="green.500" ml="auto">
|
||||
Found provider <CheckIcon boxSize={5} />
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<Card {...cardProps}>
|
||||
<Text fontWeight="bold">{fullAddress}</Text>
|
||||
<Text color="yellow.500" ml="auto">
|
||||
Read-only
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Card {...cardProps}>
|
||||
<Text fontWeight="bold">{fullAddress}</Text>
|
||||
<Text color="red.500" ml="auto">
|
||||
Cant find identity
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex as="form" direction="column" gap="2" onSubmit={connect} w="full">
|
||||
{loading && <Text fontSize="lg">{loading}</Text>}
|
||||
{!loading && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="address">Nostr Address</FormLabel>
|
||||
<Flex gap="2">
|
||||
<Input
|
||||
id="address"
|
||||
type="email"
|
||||
isRequired
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
{renderStatus()}
|
||||
</>
|
||||
)}
|
||||
<Flex justifyContent="space-between" gap="2" mt="2">
|
||||
<Button variant="link" onClick={() => navigate("../")}>
|
||||
Back
|
||||
</Button>
|
||||
{nip05 ? (
|
||||
<Button colorScheme="primary" ml="auto" type="submit" isLoading={!!loading} isDisabled={!nip05}>
|
||||
Connect
|
||||
</Button>
|
||||
) : (
|
||||
<Button colorScheme="primary" ml="auto" as={RouterLink} to="/signin/address/create">
|
||||
Find Provider
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { Avatar, Center, Flex, Heading } from "@chakra-ui/react";
|
||||
import { Avatar, Flex, Heading } from "@chakra-ui/react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { ReloadPrompt } from "../../components/reload-prompt";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
@ -13,15 +13,15 @@ export default function LoginView() {
|
||||
return (
|
||||
<>
|
||||
<ReloadPrompt />
|
||||
<Center w="full" h="full">
|
||||
<Flex direction="column" alignItems="center" gap="2" maxW="sm" w="full" mx="4">
|
||||
<Flex w="full" justifyContent="center">
|
||||
<Flex direction="column" alignItems="center" gap="2" maxW="md" w="full" px="4" py="10">
|
||||
<Avatar src="/apple-touch-icon.png" size="lg" flexShrink={0} />
|
||||
<Heading size="lg" mb="2">
|
||||
Sign in
|
||||
</Heading>
|
||||
<Outlet />
|
||||
</Flex>
|
||||
</Center>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ export default function LoginNostrConnectView() {
|
||||
|
||||
await client.connect();
|
||||
} else if (uri.startsWith("npub")) {
|
||||
client = nostrConnectService.fromNsecBunkerToken(uri);
|
||||
client = nostrConnectService.fromBunkerToken(uri);
|
||||
const [npub, hexToken] = uri.split("#");
|
||||
await client.connect(hexToken);
|
||||
} else throw new Error("Unknown format");
|
||||
@ -44,12 +44,13 @@ export default function LoginNostrConnectView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} minWidth="350" w="full">
|
||||
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} w="full">
|
||||
{loading && <Text fontSize="lg">{loading}</Text>}
|
||||
{!loading && (
|
||||
<FormControl>
|
||||
<FormLabel>Connect URI</FormLabel>
|
||||
<Input
|
||||
name="nostr-address"
|
||||
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
|
||||
isRequired
|
||||
value={uri}
|
||||
|
@ -28,7 +28,7 @@ export default function LoginNpubView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} minWidth="350" w="full">
|
||||
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} w="full">
|
||||
<FormControl>
|
||||
<FormLabel>Enter user npub</FormLabel>
|
||||
<Input type="text" placeholder="npub1" isRequired value={npub} onChange={(e) => setNpub(e.target.value)} />
|
||||
|
@ -85,7 +85,7 @@ export default function LoginNsecView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} minWidth="350" w="full">
|
||||
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} w="full">
|
||||
<Alert status="warning" maxWidth="30rem">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
|
@ -24,6 +24,7 @@ import { COMMON_CONTACT_RELAY } from "../../const";
|
||||
import accountService from "../../services/account";
|
||||
import serialPortService from "../../services/serial-port";
|
||||
import amberSignerService from "../../services/amber-signer";
|
||||
import { AtIcon } from "../../components/icons";
|
||||
|
||||
export default function LoginStartView() {
|
||||
const location = useLocation();
|
||||
@ -109,14 +110,14 @@ export default function LoginStartView() {
|
||||
if (loading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" flexShrink={0} alignItems="center">
|
||||
<>
|
||||
{window.nostr && (
|
||||
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="sm" colorScheme="primary">
|
||||
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="full" colorScheme="primary">
|
||||
Sign in with extension
|
||||
</Button>
|
||||
)}
|
||||
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="sm" colorScheme="blue">
|
||||
Nostr Connect (NIP-46)
|
||||
<Button as={RouterLink} to="./address" state={location.state} w="full" colorScheme="blue" leftIcon={<AtIcon />}>
|
||||
Nostr Address
|
||||
</Button>
|
||||
{serialPortService.supported && (
|
||||
<ButtonGroup colorScheme="purple">
|
||||
@ -151,27 +152,24 @@ export default function LoginStartView() {
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={advanced.onToggle}
|
||||
mt="2"
|
||||
w="sm"
|
||||
py="2"
|
||||
w="full"
|
||||
rightIcon={advanced.isOpen ? <ChevronUp /> : <ChevronDown />}
|
||||
>
|
||||
Show Advanced
|
||||
</Button>
|
||||
{advanced.isOpen && (
|
||||
<>
|
||||
<Button as={RouterLink} to="./nip05" state={location.state} w="sm">
|
||||
DNS ID
|
||||
<Badge ml="2" colorScheme="blue">
|
||||
read-only
|
||||
</Badge>
|
||||
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="full">
|
||||
Nostr Connect / Bunker
|
||||
</Button>
|
||||
<Button as={RouterLink} to="./npub" state={location.state} w="sm">
|
||||
<Button as={RouterLink} to="./npub" state={location.state} w="full">
|
||||
Public key (npub)
|
||||
<Badge ml="2" colorScheme="blue">
|
||||
read-only
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button as={RouterLink} to="./nsec" state={location.state} w="sm">
|
||||
<Button as={RouterLink} to="./nsec" state={location.state} w="full">
|
||||
Secret key (nsec)
|
||||
</Button>
|
||||
</>
|
||||
@ -179,9 +177,17 @@ export default function LoginStartView() {
|
||||
<Text fontWeight="bold" mt="4">
|
||||
Don't have an account?
|
||||
</Text>
|
||||
<Button as={RouterLink} to="/signup" state={location.state}>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/signup"
|
||||
state={location.state}
|
||||
colorScheme="primary"
|
||||
variant="outline"
|
||||
maxW="xs"
|
||||
w="full"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -88,7 +88,7 @@ export default function CreateStep({
|
||||
<Flex gap="4" {...containerProps}>
|
||||
<Avatar size="xl" src={preview} />
|
||||
<Flex direction="column" alignItems="center">
|
||||
<Heading size="md">{metadata.display_name}</Heading>
|
||||
<Heading size="md">{metadata.displayName}</Heading>
|
||||
{metadata.about && <Text>{metadata.about}</Text>}
|
||||
</Flex>
|
||||
<Button w="full" colorScheme="primary" isLoading={loading} onClick={createProfile} autoFocus>
|
||||
|
@ -33,7 +33,7 @@ export default function SignupView() {
|
||||
case "profile":
|
||||
return (
|
||||
<ProfileImageStep
|
||||
displayName={metadata.display_name}
|
||||
displayName={metadata.displayName}
|
||||
onSubmit={(file) => {
|
||||
setProfileImage(file);
|
||||
navigate("/signup/relays");
|
||||
|
@ -20,7 +20,7 @@ export default function NameStep({ onSubmit }: { onSubmit: (metadata: Kind0Parse
|
||||
|
||||
onSubmit({
|
||||
name: username,
|
||||
display_name: displayName,
|
||||
displayName: displayName,
|
||||
about: values.about,
|
||||
});
|
||||
});
|
||||
@ -38,7 +38,7 @@ export default function NameStep({ onSubmit }: { onSubmit: (metadata: Kind0Parse
|
||||
Next
|
||||
</Button>
|
||||
<Text fontWeight="bold">Already have an account?</Text>
|
||||
<Button as={RouterLink} to="/signin" state={location.state}>
|
||||
<Button as={RouterLink} to="/signin" state={location.state} variant="outline" w="xs" colorScheme="primary">
|
||||
Sign in
|
||||
</Button>
|
||||
</Flex>
|
||||
|
Loading…
x
Reference in New Issue
Block a user