feat: add set up inbox relay screen

This commit is contained in:
reya
2024-07-30 09:06:22 +07:00
parent 6ceac40394
commit b3ca859256
14 changed files with 445 additions and 123 deletions

View File

@@ -48,9 +48,9 @@ pub async fn get_metadata(id: String, state: State<'_, Nostr>) -> Result<String,
#[specta::specta]
pub async fn create_account(
name: String,
picture: String,
picture: Option<String>,
state: State<'_, Nostr>,
) -> Result<(), String> {
) -> Result<String, String> {
let client = &state.client;
let keys = Keys::generate();
let npub = keys.public_key().to_bech32().map_err(|e| e.to_string())?;
@@ -66,11 +66,18 @@ pub async fn create_account(
client.set_signer(Some(signer)).await;
// Update metadata
let url = Url::parse(&picture).map_err(|e| e.to_string())?;
let metadata = Metadata::new().display_name(name).picture(url);
let url = match picture {
Some(p) => Some(Url::parse(&p).map_err(|e| e.to_string())?),
None => None,
};
let metadata = match url {
Some(picture) => Metadata::new().display_name(name).picture(picture),
None => Metadata::new().display_name(name),
};
match client.set_metadata(&metadata).await {
Ok(_) => Ok(()),
Ok(_) => Ok(npub),
Err(e) => Err(e.to_string()),
}
}
@@ -150,6 +157,51 @@ pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, ()
Ok(list)
}
#[tauri::command]
#[specta::specta]
pub async fn get_inbox(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client;
let public_key = PublicKey::parse(id).map_err(|e| e.to_string())?;
let inbox = Filter::new().kind(Kind::Custom(10050)).author(public_key).limit(1);
match client.get_events_of(vec![inbox], None).await {
Ok(events) => {
if let Some(event) = events.into_iter().next() {
let urls = event
.tags()
.iter()
.filter_map(|tag| {
if let Some(TagStandard::Relay(relay)) = tag.as_standardized() {
Some(relay.to_string())
} else {
None
}
})
.collect::<Vec<_>>();
Ok(urls)
} else {
Ok(Vec::new())
}
}
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn set_inbox(relays: Vec<String>, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let tags = relays.into_iter().map(|t| Tag::custom(TagKind::Relay, vec![t])).collect::<Vec<_>>();
let event = EventBuilder::new(Kind::Custom(10050), "", tags);
match client.send_event_builder(event).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn login(
@@ -222,6 +274,8 @@ pub async fn login(
let mut inbox_relays = state.inbox_relays.lock().await;
inbox_relays.insert(public_key, urls);
} else {
return Err("404".into());
}
}

View File

@@ -30,6 +30,8 @@ fn main() {
get_contact_list,
get_chats,
get_chat_messages,
get_inbox,
set_inbox,
connect_inbox,
disconnect_inbox,
send_message,

View File

@@ -12,7 +12,7 @@ try {
else return { status: "error", error: e as any };
}
},
async createAccount(name: string, picture: string) : Promise<Result<null, string>> {
async createAccount(name: string, picture: string | null) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("create_account", { name, picture }) };
} catch (e) {
@@ -71,6 +71,22 @@ try {
else return { status: "error", error: e as any };
}
},
async getInbox(id: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_inbox", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setInbox(relays: string[]) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_inbox", { relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async connectInbox(id: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("connect_inbox", { id }) };

20
src/components/back.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { cn } from "@/commons";
import { useRouter } from "@tanstack/react-router";
import type { ReactNode } from "react";
export function GoBack({
children,
className,
}: { children: ReactNode | ReactNode[]; className?: string }) {
const { history } = useRouter();
return (
<button
type="button"
onClick={() => history.go(-1)}
className={cn(className)}
>
{children}
</button>
);
}

View File

@@ -23,6 +23,7 @@ const NostrConnectLazyImport = createFileRoute('/nostr-connect')()
const NewLazyImport = createFileRoute('/new')()
const ImportKeyLazyImport = createFileRoute('/import-key')()
const CreateAccountLazyImport = createFileRoute('/create-account')()
const AccountRelaysLazyImport = createFileRoute('/$account/relays')()
const AccountChatsLazyImport = createFileRoute('/$account/chats')()
const AccountChatsNewLazyImport = createFileRoute('/$account/chats/new')()
@@ -53,7 +54,14 @@ const CreateAccountLazyRoute = CreateAccountLazyImport.update({
const IndexRoute = IndexImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any)
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
const AccountRelaysLazyRoute = AccountRelaysLazyImport.update({
path: '/$account/relays',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/$account.relays.lazy').then((d) => d.Route),
)
const AccountChatsLazyRoute = AccountChatsLazyImport.update({
path: '/$account/chats',
@@ -136,6 +144,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AccountChatsLazyImport
parentRoute: typeof rootRoute
}
'/$account/relays': {
id: '/$account/relays'
path: '/$account/relays'
fullPath: '/$account/relays'
preLoaderRoute: typeof AccountRelaysLazyImport
parentRoute: typeof rootRoute
}
'/$account/chats/$id': {
id: '/$account/chats/$id'
path: '/$id'
@@ -166,6 +181,7 @@ export const routeTree = rootRoute.addChildren({
AccountChatsIdRoute,
AccountChatsNewLazyRoute,
}),
AccountRelaysLazyRoute,
})
/* prettier-ignore-end */
@@ -182,7 +198,8 @@ export const routeTree = rootRoute.addChildren({
"/new",
"/nostr-connect",
"/$account/contacts",
"/$account/chats"
"/$account/chats",
"/$account/relays"
]
},
"/": {
@@ -210,6 +227,9 @@ export const routeTree = rootRoute.addChildren({
"/$account/chats/new"
]
},
"/$account/relays": {
"filePath": "$account.relays.lazy.tsx"
},
"/$account/chats/$id": {
"filePath": "$account.chats.$id.tsx",
"parent": "/$account/chats"

View File

@@ -2,7 +2,13 @@ import { commands } from "@/commands";
import { ago, cn } from "@/commons";
import { Spinner } from "@/components/spinner";
import { User } from "@/components/user";
import { ArrowRight, CirclesFour, Plus, X } from "@phosphor-icons/react";
import {
ArrowRight,
CaretDown,
CirclesFour,
Plus,
X,
} from "@phosphor-icons/react";
import * as Dialog from "@radix-ui/react-dialog";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
@@ -48,7 +54,7 @@ function Header() {
data-tauri-drag-region
className={cn(
"shrink-0 h-12 flex items-center justify-between",
platform === "macos" ? "pl-24 pr-3.5" : "px-3.5",
platform === "macos" ? "pl-[78px] pr-3.5" : "px-3.5",
)}
>
<CurrentUser />
@@ -153,6 +159,12 @@ function ChatList() {
</div>
))}
</div>
) : !data?.length ? (
<div className="p-2">
<div className="px-2 h-12 w-full rounded-lg bg-black/5 dark:bg-white/5 flex items-center justify-center text-sm">
No chats.
</div>
</div>
) : (
data.map((item) => (
<Link
@@ -380,13 +392,14 @@ function CurrentUser() {
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="shrink-0 size-8 flex items-center justify-center rounded-full ring-1 ring-teal-500"
className="h-8 inline-flex items-center gap-1.5"
>
<User.Provider pubkey={params.account}>
<User.Root className="shrink-0">
<User.Avatar className="size-7 rounded-full" />
<User.Avatar className="size-8 rounded-full" />
</User.Root>
</User.Provider>
<CaretDown className="size-3 text-neutral-600 dark:text-neutral-400" />
</button>
);
}

View File

@@ -1,5 +1,5 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { CoopIcon } from "@/icons/coop";
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/$account/chats/new")({
component: Screen,
@@ -7,8 +7,14 @@ export const Route = createLazyFileRoute("/$account/chats/new")({
function Screen() {
return (
<div className="size-full flex items-center justify-center">
<div
data-tauri-drag-region
className="size-full flex flex-col gap-3 items-center justify-center"
>
<CoopIcon className="size-10 text-neutral-200 dark:text-neutral-800" />
<h1 className="text-center font-bold text-neutral-300 dark:text-neutral-700">
coop on nostr.
</h1>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { commands } from "@/commands";
import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner";
import { Plus, X } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState, useTransition } from "react";
export const Route = createLazyFileRoute("/$account/relays")({
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const { account } = Route.useParams();
const [newRelay, setNewRelay] = useState("");
const [relays, setRelays] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const add = () => {
try {
let url = newRelay;
if (relays.length >= 3) {
return message("You should keep relay lists small (1 - 3 relays).", {
kind: "info",
});
}
if (!url.startsWith("wss://")) {
url = `wss://${url}`;
}
// Validate URL
const relay = new URL(url);
// Update
setRelays((prev) => [...prev, relay.toString()]);
setNewRelay("");
} catch {
message("URL is not valid.", { kind: "error" });
}
};
const submit = async () => {
startTransition(async () => {
if (!relays.length) {
await message("You need to add at least 1 relay", { kind: "info" });
return;
}
const res = await commands.setInbox(relays);
if (res.status === "ok") {
navigate({
to: "/",
params: { account },
replace: true,
});
} else {
await message(res.error, {
title: "Inbox Relays",
kind: "error",
});
return;
}
});
};
useEffect(() => {
async function getRelays() {
const res = await commands.getInbox(account);
if (res.status === "ok") {
setRelays((prev) => [...prev, ...res.data]);
}
}
getRelays();
}, []);
return (
<div className="size-full flex items-center justify-center">
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold">Inbox Relays</h1>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Inbox Relay is used to receive message from others
</p>
</div>
<div className="flex flex-col gap-3">
<Frame
className="flex flex-col gap-3 p-3 rounded-xl overflow-hidden"
shadow
>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
<p className="mb-1.5">
You need to set at least 1 inbox relay in order to receive
message from others.
</p>
<p>
If you don't know which relay to add, you can use{" "}
<span
onClick={() => setNewRelay("wss://auth.nostr1.com")}
onKeyDown={() => setNewRelay("wss://auth.nostr1.com")}
className="font-semibold"
>
auth.nostr1.com
</span>
</p>
</div>
<div className="flex gap-2">
<input
name="relay"
type="text"
placeholder="ex: relay.nostr.net, ..."
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") add();
}}
className="flex-1 px-3 rounded-lg h-9 bg-transparent border border-neutral-200 dark:border-neutral-800 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
/>
<button
type="submit"
onClick={() => add()}
className="inline-flex items-center justify-center size-9 rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
<Plus className="size-5" />
</button>
</div>
<div className="flex flex-col gap-2">
{relays.map((relay) => (
<div
key={relay}
className="flex items-center justify-between h-9 px-2 rounded-lg bg-neutral-100 dark:bg-neutral-900"
>
<div className="text-sm font-medium">{relay}</div>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
>
<X className="size-3" />
</button>
</div>
</div>
))}
</div>
</Frame>
<div className="flex flex-col items-center gap-1">
<button
type="button"
onClick={() => submit()}
disabled={isPending || !relays.length}
className="inline-flex items-center justify-center w-full h-9 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner /> : "Continue"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { commands } from "@/commands";
import { GoBack } from "@/components/back";
import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner";
import { createLazyFileRoute } from "@tanstack/react-router";
@@ -12,7 +13,7 @@ export const Route = createLazyFileRoute("/create-account")({
function Screen() {
const navigate = Route.useNavigate();
const [picture, setPicture] = useState("");
const [picture, setPicture] = useState(null);
const [name, setName] = useState("");
const [isPending, startTransition] = useTransition();
@@ -21,7 +22,11 @@ function Screen() {
const res = await commands.createAccount(name, picture);
if (res.status === "ok") {
navigate({ to: "/", replace: true });
navigate({
to: "/$account/relays",
params: { account: res.data },
replace: true,
});
} else {
await message(res.error, {
title: "New Identity",
@@ -36,9 +41,7 @@ function Screen() {
<div className="size-full flex items-center justify-center">
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold">
Import Private Key
</h1>
<h1 className="leading-tight text-xl font-semibold">New Identity</h1>
</div>
<div className="flex flex-col gap-3">
<Frame
@@ -86,6 +89,9 @@ function Screen() {
>
{isPending ? <Spinner /> : "Continue"}
</button>
<GoBack className="mt-2 w-full text-sm text-neutral-600 dark:text-neutral-400 inline-flex items-center justify-center">
Back
</GoBack>
</div>
</div>
</div>

View File

@@ -1,7 +1,8 @@
import { commands } from "@/commands";
import { GoBack } from "@/components/back";
import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner";
import { createLazyFileRoute } from "@tanstack/react-router";
import { Link, createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react";
@@ -94,6 +95,9 @@ function Screen() {
>
{isPending ? <Spinner /> : "Continue"}
</button>
<GoBack className="mt-2 w-full text-sm text-neutral-600 dark:text-neutral-400 inline-flex items-center justify-center">
Back
</GoBack>
</div>
</div>
</div>

109
src/routes/index.lazy.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { commands } from "@/commands";
import { npub } from "@/commons";
import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner";
import { User } from "@/components/user";
import { Plus } from "@phosphor-icons/react";
import { Link, createLazyFileRoute } from "@tanstack/react-router";
import { useMemo, useState, useTransition } from "react";
export const Route = createLazyFileRoute("/")({
component: Screen,
});
function Screen() {
const context = Route.useRouteContext();
const navigate = Route.useNavigate();
const currentDate = useMemo(
() =>
new Date().toLocaleString("default", {
weekday: "long",
month: "long",
day: "numeric",
}),
[],
);
const [value, setValue] = useState("");
const [isPending, startTransition] = useTransition();
const loginWith = async (npub: string) => {
setValue(npub);
startTransition(async () => {
const bunker: string = localStorage.getItem(`${npub}_bunker`);
const verifyBunker = bunker?.length && bunker?.startsWith("bunker://");
const res = await commands.login(npub, verifyBunker ? bunker : null);
if (res.status === "ok") {
navigate({
to: "/$account/chats/new",
params: { account: res.data },
replace: true,
});
} else {
if (res.error === "404") {
navigate({
to: "/$account/relays",
params: { account: npub },
replace: true,
});
}
}
});
};
return (
<div className="size-full flex items-center justify-center">
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h3 className="leading-tight text-neutral-700 dark:text-neutral-300">
{currentDate}
</h3>
<h1 className="leading-tight text-xl font-semibold">Welcome back!</h1>
</div>
<Frame
className="flex flex-col w-full divide-y divide-neutral-100 dark:divide-white/5 rounded-xl overflow-hidden"
shadow
>
{context.accounts.map((account) => (
<div
key={account}
onClick={() => loginWith(account)}
onKeyDown={() => loginWith(account)}
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2.5 p-3">
<User.Avatar className="rounded-full size-10" />
<div className="inline-flex flex-col items-start">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{npub(account, 16)}
</span>
</div>
</User.Root>
</User.Provider>
<div className="inline-flex items-center justify-center size-10">
{value === account && isPending ? <Spinner /> : null}
</div>
</div>
))}
<Link
to="/new"
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2.5 p-3">
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
<Plus className="size-5" />
</div>
<span className="truncate text-sm font-medium leading-tight">
Add an account
</span>
</div>
</Link>
</Frame>
</div>
</div>
);
}

View File

@@ -1,11 +1,5 @@
import { commands } from "@/commands";
import { npub } from "@/commons";
import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner";
import { User } from "@/components/user";
import { Plus } from "@phosphor-icons/react";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { useMemo, useState, useTransition } from "react";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
beforeLoad: async () => {
@@ -20,94 +14,4 @@ export const Route = createFileRoute("/")({
return { accounts };
},
component: Screen,
});
function Screen() {
const context = Route.useRouteContext();
const navigate = Route.useNavigate();
const currentDate = useMemo(
() =>
new Date().toLocaleString("default", {
weekday: "long",
month: "long",
day: "numeric",
}),
[],
);
const [value, setValue] = useState("");
const [isPending, startTransition] = useTransition();
const loginWith = async (npub: string) => {
setValue(npub);
startTransition(async () => {
const bunker: string = localStorage.getItem(`${npub}_bunker`);
const verifyBunker = bunker?.length && bunker?.startsWith("bunker://");
const res = await commands.login(npub, verifyBunker ? bunker : null);
if (res.status === "ok") {
navigate({
to: "/$account/chats/new",
params: { account: res.data },
replace: true,
});
}
});
};
return (
<div className="size-full flex items-center justify-center">
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h3 className="leading-tight text-neutral-700 dark:text-neutral-300">
{currentDate}
</h3>
<h1 className="leading-tight text-xl font-semibold">Welcome back!</h1>
</div>
<Frame
className="flex flex-col w-full divide-y divide-neutral-100 dark:divide-white/5 rounded-xl overflow-hidden"
shadow
>
{context.accounts.map((account) => (
<div
key={account}
onClick={() => loginWith(account)}
onKeyDown={() => loginWith(account)}
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2.5 p-3">
<User.Avatar className="rounded-full size-10" />
<div className="inline-flex flex-col items-start">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{npub(account, 16)}
</span>
</div>
</User.Root>
</User.Provider>
<div className="inline-flex items-center justify-center size-10">
{value === account && isPending ? <Spinner /> : null}
</div>
</div>
))}
<Link
to="/new"
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2.5 p-3">
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
<Plus className="size-5" />
</div>
<span className="truncate text-sm font-medium leading-tight">
Add an account
</span>
</div>
</Link>
</Frame>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { createLazyFileRoute, Link } from "@tanstack/react-router";
import { Link, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/new")({
component: Screen,
@@ -16,13 +16,13 @@ function Screen() {
<div className="flex flex-col gap-3">
<Link
to="/create-account"
className="w-full h-9 bg-blue-500 hover:bg-blue-600 text-white rounded-lg inline-flex items-center justify-center shadow"
className="w-full h-10 bg-blue-500 hover:bg-blue-600 text-white rounded-lg inline-flex items-center justify-center shadow"
>
Create a new identity
</Link>
<Link
to="/nostr-connect"
className="w-full h-9 bg-white hover:bg-neutral-100 dark:hover:bg-neutral-950 dark:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
className="w-full h-10 bg-white hover:bg-neutral-100 dark:hover:bg-neutral-950 dark:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
>
Login with Nostr Connect
</Link>

View File

@@ -1,4 +1,5 @@
import { commands } from "@/commands";
import { GoBack } from "@/components/back";
import { Frame } from "@/components/frame";
import { Spinner } from "@/components/spinner";
import { createLazyFileRoute } from "@tanstack/react-router";
@@ -47,9 +48,7 @@ function Screen() {
<div className="size-full flex items-center justify-center">
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h1 className="leading-tight text-xl font-semibold">
Nostr Connect.
</h1>
<h1 className="leading-tight text-xl font-semibold">Nostr Connect</h1>
</div>
<div className="flex flex-col gap-3">
<Frame
@@ -85,6 +84,9 @@ function Screen() {
Waiting confirmation...
</p>
) : null}
<GoBack className="mt-2 w-full text-sm text-neutral-600 dark:text-neutral-400 inline-flex items-center justify-center">
Back
</GoBack>
</div>
</div>
</div>