feat: add create account flow

This commit is contained in:
reya 2024-03-28 15:12:43 +07:00
parent d3fa59d2b1
commit cbbf5eaf50
18 changed files with 714 additions and 152 deletions

View File

@ -18,6 +18,7 @@
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@tanstack/query-sync-storage-persister": "^5.28.4",
"@tanstack/react-query": "^5.28.4",
"@tanstack/react-query-persist-client": "^5.28.4",

View File

@ -1,17 +1,19 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { Dispatch, SetStateAction, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils";
import { Dispatch, ReactNode, SetStateAction, useState } from "react";
import { toast } from "sonner";
export function AvatarUploader({
setPicture,
children,
className,
}: {
setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode;
className?: string;
}) {
const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
@ -32,15 +34,9 @@ export function AvatarUploader({
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex w-32 items-center justify-center rounded-lg border border-blue-200 bg-blue-100 px-2 py-1.5 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
className={cn("", className)}
>
{loading ? (
<button type="button" className="size-4" disabled>
<LoaderIcon className="size-4 animate-spin" />
</button>
) : (
t("user.avatarButton")
)}
{loading ? <LoaderIcon className="size-4 animate-spin" /> : children}
</button>
);
}

View File

@ -1,25 +0,0 @@
import { LoaderIcon } from "@lume/icons";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/auth/create")({
component: Screen,
loader: ({ context }) => {
return context.ark.create_keys();
},
pendingComponent: Pending,
});
function Screen() {
return <div className="px-5"></div>;
}
function Pending() {
return (
<div className="flex h-full w-full flex-col items-center gap-2">
<button type="button" className="size-5" disabled>
<LoaderIcon className="size-5 animate-spin" />
</button>
<p>Creating account</p>
</div>
);
}

View File

@ -0,0 +1,186 @@
import { displayNsec } from "@lume/utils";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import * as Checkbox from "@radix-ui/react-checkbox";
import { CheckIcon } from "@lume/icons";
export const Route = createFileRoute("/auth/new/backup")({
component: Screen,
});
function Screen() {
// @ts-ignore, magic!!!
const { account } = Route.useSearch();
const { t } = useTranslation();
const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
const navigate = useNavigate();
const submit = async () => {
try {
if (key) {
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
return toast.warning("You need to confirm before continue");
} else {
return navigate({
to: "/auth/settings",
search: { account, new: true },
});
}
}
const encrypted: string = await invoke("get_encrypted_key", {
npub: account,
password: passphase,
});
setKey(encrypted);
} catch (e) {
toast.error(String(e));
}
};
const copyKey = async () => {
try {
await writeText(key);
setCopied(true);
} catch (e) {
toast.error(e);
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col text-center">
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
<p className="text-neutral-700 dark:text-neutral-300">
It's use for login to Lume or other Nostr clients. You will lost
access to your account if you lose this key.
</p>
</div>
<div className="flex w-full flex-col gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="nsec" className="font-medium">
Set a passphase to secure your key
</label>
<div className="relative">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
</div>
{key ? (
<>
<div className="flex flex-col gap-2">
<label htmlFor="nsec" className="font-medium">
Copy this key and keep it in safe place
</label>
<div className="flex items-center gap-2">
<input
name="nsec"
type="text"
value={displayNsec(key, 36)}
readOnly
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
<button
type="button"
onClick={copyKey}
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="font-medium">Before you continue:</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c1}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c1: !state.c1 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
id="confirm1"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm1"
>
{t("backup.confirm1")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c2}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c2: !state.c2 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
id="confirm2"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm2"
>
{t("backup.confirm2")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c3}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c3: !state.c3 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm3"
>
{t("backup.confirm3")}
</label>
</div>
</div>
</div>
</>
) : null}
<div>
<button
type="button"
onClick={submit}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,147 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { useArk } from "@lume/ark";
import { LoaderIcon, PlusIcon } from "@lume/icons";
import { Metadata } from "@lume/types";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({
component: Screen,
loader: ({ context }) => {
return context.ark.create_keys();
},
});
function Screen() {
const ark = useArk();
const keys = Route.useLoaderData();
const navigate = useNavigate();
const { t } = useTranslation();
const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>("");
const [loading, setLoading] = useState(false);
const onSubmit = async (data: {
name: string;
about: string;
website: string;
}) => {
setLoading(true);
try {
// Save account keys
const save = await ark.save_account(keys.nsec);
// Then create profile
if (save) {
const profile: Metadata = { ...data, picture };
const eventId = await ark.create_profile(profile);
if (eventId) {
navigate({
to: "/auth/new/backup",
search: { account: keys.npub },
replace: true,
});
}
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div>
<div>
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{picture ? (
<img
src={picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</AvatarUploader>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col gap-3"
>
<div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium">
{t("user.displayName")} *
</label>
<input
type={"text"}
{...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium">
{t("user.name")}
</label>
<input
type={"text"}
{...register("name")}
placeholder="e.g. alice"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium">
{t("user.bio")}
</label>
<textarea
{...register("about")}
placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium">
{t("user.website")}
</label>
<input
type="url"
{...register("website")}
placeholder="e.g. https://alice.me"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<button
type="submit"
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("global.continue")
)}
</button>
</form>
</div>
);
}

View File

@ -1,7 +1,6 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@ -32,9 +31,8 @@ function Screen() {
try {
const npub = await ark.save_account(key, password);
navigate({
to: "/$account/home",
params: { account: npub },
search: { onboarding: true },
to: "/auth/settings",
search: { account: npub, new: false },
replace: true,
});
} catch (e) {
@ -45,57 +43,50 @@ function Screen() {
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-semibold">{t("login.title")}</h1>
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
{t("login.subtitle")}
</p>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
/>
</div>
</div>
<button
type="button"
onClick={submit}
disabled={loading}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 text-lg font-medium text-white hover:bg-blue-600"
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
{loading ? <LoaderIcon className="size-5 animate-spin" /> : "Login"}
</button>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<button
type="button"
onClick={submit}
disabled={loading}
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <LoaderIcon className="size-4 animate-spin" /> : "Login"}
</button>
</div>
</div>
);

View File

@ -0,0 +1,164 @@
import { CheckIcon } from "@lume/icons";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import * as Switch from "@radix-ui/react-switch";
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Settings } from "@lume/types";
import { useArk } from "@lume/ark";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/settings")({
component: Screen,
});
function Screen() {
const ark = useArk();
// @ts-ignore, magic!!!
const { account } = Route.useSearch();
const { t } = useTranslation();
const [settings, setSettings] = useState<Settings>({
notification: false,
enhancedPrivacy: false,
autoUpdate: false,
});
const toggleNofitication = async () => {
await requestPermission();
setSettings((prev) => ({
...prev,
notification: !settings.notification,
}));
};
const toggleAutoUpdate = () => {
setSettings((prev) => ({
...prev,
autoUpdate: !settings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setSettings((prev) => ({
...prev,
enhancedPrivacy: !settings.enhancedPrivacy,
}));
};
const saveSettings = async () => {
try {
const eventId = await ark.set_settings(settings);
if (eventId) toast.success("Settings have been updated successfully.");
} catch (e) {
toast.error(e);
}
};
useEffect(() => {
async function loadSettings() {
const permissionGranted = await isPermissionGranted(); // get notification permission
const settings = await ark.get_settings(account);
setSettings({ ...settings, notification: permissionGranted });
}
loadSettings();
}, []);
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center gap-5 text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 text-teal-500">
<CheckIcon className="size-6" />
</div>
<div>
<h1 className="text-xl font-semibold">
{t("onboardingSettings.title")}
</h1>
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
{t("onboardingSettings.subtitle")}
</p>
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root
checked={settings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div className="flex-1">
<h3 className="font-semibold">Push Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Enabling push notifications will allow you to receive
notifications from Lume.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root
checked={settings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div className="flex-1">
<h3 className="font-semibold">Enhanced Privacy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume will display external resources like image, video or link
preview as plain text.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root
checked={settings.autoUpdate}
onClick={() => toggleAutoUpdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div className="flex-1">
<h3 className="font-semibold">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-50 px-5 py-4 dark:bg-neutral-950">
<p className="text-sm text-neutral-700 dark:text-neutral-300">
There are many more settings you can configure from the 'Settings'
Screen. Be sure to visit it later.
</p>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={saveSettings}
className="inline-flex h-11 flex-1 items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Save settings
</button>
<Link
to="/$account/home"
params={{ account }}
className="inline-flex h-11 flex-1 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</Link>
</div>
</div>
</div>
);
}

View File

@ -32,7 +32,7 @@ function Screen() {
</div>
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<Link
to="/auth/create"
to="/auth/new/profile"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
>
{t("welcome.signup")}

View File

@ -17,14 +17,8 @@ export function Screen() {
// @ts-ignore, just work!!!
const { id, name, account } = Route.useSearch();
const { t } = useTranslation();
const {
data,
hasNextPage,
isLoading,
isRefetching,
isFetchingNextPage,
fetchNextPage,
} = useEvents("local", account);
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useEvents("local", account);
const renderItem = (event: Event) => {
if (!event) return;
@ -46,13 +40,17 @@ export function Screen() {
<LoaderIcon className="size-5 animate-spin" />
</button>
</div>
) : !data ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
) : !data.length ? (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-5 dark:bg-neutral-900">
<InfoIcon className="size-6" />
<div>
<p className="leading-tight">{t("emptyFeedTitle")}</p>
<p className="leading-tight">{t("emptyFeedSubtitle")}</p>
<p className="font-medium leading-tight">
{t("global.emptyFeedTitle")}
</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
{t("global.emptyFeedSubtitle")}
</p>
</div>
</div>
<Suggest />
@ -62,8 +60,9 @@ export function Screen() {
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
<div className="flex h-20 items-center justify-center">
{data?.length && hasNextPage ? (
{data?.length && hasNextPage ? (
<div className="flex h-20 items-center justify-center">
<button
type="button"
onClick={() => fetchNextPage()}
@ -79,8 +78,8 @@ export function Screen() {
</>
)}
</button>
) : null}
</div>
</div>
) : null}
</Column.Content>
</Column.Root>
);

View File

@ -6,6 +6,7 @@ import type {
EventWithReplies,
Keys,
Metadata,
Settings,
} from "@lume/types";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
@ -338,6 +339,25 @@ export class Ark {
}
}
public async create_profile(profile: Metadata) {
try {
const event: string = await invoke("create_profile", {
name: profile.name || "",
display_name: profile.display_name || "",
displayName: profile.display_name || "",
about: profile.about || "",
picture: profile.picture || "",
banner: profile.banner || "",
nip05: profile.nip05 || "",
lud16: profile.lud16 || "",
website: profile.website || "",
});
return event;
} catch (e) {
throw new Error(String(e));
}
}
public async get_contact_list() {
try {
const cmd: string[] = await invoke("get_contact_list");
@ -499,6 +519,30 @@ export class Ark {
}
}
public async get_settings(id: string) {
try {
const cmd: string = await invoke("get_settings", { id });
if (!cmd) return null;
if (!cmd.length) return null;
const settings: Settings = JSON.parse(cmd);
return settings;
} catch (e) {
throw new Error(e);
}
}
public async set_settings(settings: Settings) {
try {
const cmd: string = await invoke("set_settings", {
content: JSON.stringify(settings),
});
return cmd;
} catch (e) {
throw new Error(e);
}
}
public open_thread(id: string) {
return new WebviewWindow(`event-${id}`, {
title: "Thread",

View File

@ -1,24 +1,16 @@
import { SVGProps } from "react";
export function CheckIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5 12.713l5.017 5.012.4-.701a28.598 28.598 0 018.7-9.42L20 7"
/>
</svg>
);
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
d="M4.75 12.777 10 19.25l9.25-14.5"
/>
</svg>
);
}

View File

@ -1,13 +1,7 @@
export interface Settings {
autoupdate: boolean;
nsecbunker: boolean;
media: boolean;
hashtag: boolean;
lowPower: boolean;
translation: boolean;
translateApiKey: string;
instantZap: boolean;
defaultZapAmount: number;
notification: boolean;
enhancedPrivacy: boolean;
autoUpdate: boolean;
}
export interface Keys {

View File

@ -1,13 +1,16 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function Container({
children,
withDrag = false,
withNavigate = true,
className,
}: {
children: ReactNode;
withDrag?: boolean;
withNavigate?: boolean;
className?: string;
}) {
return (
@ -18,7 +21,29 @@ export function Container({
)}
>
{withDrag ? (
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
<div
data-tauri-drag-region
className="flex h-11 w-full shrink-0 items-center justify-end pr-2"
>
{withNavigate ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => window.history.back()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={() => window.history.forward()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<ArrowRightIcon className="size-5" />
</button>
</div>
) : null}
</div>
) : null}
{children}
</div>

View File

@ -45,6 +45,21 @@ export function formatCreatedAt(time: number, message = false) {
return formated;
}
export function displayNsec(key: string, len: number) {
if (key.length <= len) return key;
const separator = " ... ";
const sepLen = separator.length;
const charsToShow = len - sepLen;
const frontChars = Math.ceil(charsToShow / 2);
const backChars = Math.floor(charsToShow / 2);
return (
key.substr(0, frontChars) + separator + key.substr(key.length - backChars)
);
}
export function displayNpub(pubkey: string, len: number) {
const npub = pubkey.startsWith("npub1")
? pubkey

View File

@ -87,6 +87,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-switch':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/query-sync-storage-persister':
specifier: ^5.28.4
version: 5.28.4
@ -1998,6 +2001,33 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.24.0
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.66)(react@18.2.0)
'@radix-ui/react-use-size': 1.0.1(@types/react@18.2.66)(react@18.2.0)
'@types/react': 18.2.66
'@types/react-dom': 18.2.22
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-tooltip@1.0.7(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==}
peerDependencies:

View File

@ -41,10 +41,6 @@ fn main() {
// Add some bootstrap relays
// #TODO: Pull bootstrap relays from user's settings
client
.add_relay("wss://nostr.mutinywallet.com")
.await
.unwrap_or_default();
client
.add_relay("wss://relay.nostr.band")
.await

View File

@ -141,9 +141,6 @@ pub async fn load_selected_account(npub: &str, state: State<'_, Nostr>) -> Resul
}
}
// #TODO
// Subscribe new event for activity and local newsfeed
Ok(true)
} else {
Err("nsec not found".into())

View File

@ -3,6 +3,7 @@ use keyring::Entry;
use nostr_sdk::prelude::*;
use std::{str::FromStr, time::Duration};
use tauri::State;
use url::Url;
#[derive(serde::Serialize)]
pub struct CacheContact {
@ -99,22 +100,31 @@ pub async fn create_profile(
lud16: &str,
website: &str,
state: State<'_, Nostr>,
) -> Result<EventId, ()> {
) -> Result<EventId, String> {
let client = &state.client;
let metadata = Metadata::new()
let mut metadata = Metadata::new()
.name(name)
.display_name(display_name)
.about(about)
.nip05(nip05)
.lud16(lud16)
.picture(Url::parse(picture).unwrap())
.banner(Url::parse(banner).unwrap())
.website(Url::parse(website).unwrap());
.lud16(lud16);
if let Ok(url) = Url::parse(picture) {
metadata = metadata.picture(url)
}
if let Ok(url) = Url::parse(banner) {
metadata = metadata.banner(url)
}
if let Ok(url) = Url::parse(website) {
metadata = metadata.website(url)
}
if let Ok(event_id) = client.set_metadata(&metadata).await {
Ok(event_id)
} else {
Err(())
Err("Create profile failed".into())
}
}
@ -230,7 +240,7 @@ pub async fn set_settings(content: &str, state: State<'_, Nostr>) -> Result<Even
if let Ok(event_id) = client.send_event_builder(builder).await {
Ok(event_id)
} else {
Err("Set interest failed".into())
Err("Set settings failed".into())
}
}