This commit is contained in:
Ren Amamiya
2023-06-24 18:31:40 +07:00
parent 21d22320b3
commit 85b30f770c
102 changed files with 1844 additions and 2014 deletions

View File

@@ -1 +0,0 @@
export { LayoutOnboarding as Layout } from "./layout";

View File

@@ -4,39 +4,39 @@ import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
export function User({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
if (!user) {
return (
<div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink-0 rounded-md bg-zinc-800 animate-pulse" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="w-full h-4 rounded bg-zinc-800 animate-pulse" />
<span className="w-1/2 h-3 rounded bg-zinc-800 animate-pulse" />
</div>
</div>
);
}
const { status, user } = useProfile(pubkey);
return (
<div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink rounded-md">
<Image
src={user.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
decoding="async"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-100">
{user.displayName || user.name}
</span>
<span className="text-base leading-tight text-zinc-400">
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
</span>
</div>
{status === "loading" ? (
<>
<div className="relative h-11 w-11 shrink-0 rounded-md bg-zinc-800 animate-pulse" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="w-1/2 h-4 rounded bg-zinc-800 animate-pulse" />
<span className="w-1/3 h-3 rounded bg-zinc-800 animate-pulse" />
</div>
</>
) : (
<>
<div className="relative h-11 w-11 shrink rounded-md">
<Image
src={user.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
decoding="async"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-100">
{user.displayName || user.name}
</span>
<span className="text-base leading-tight text-zinc-400">
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
</span>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from "react-router-dom";
export function AuthCreateScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { createAccount } from "@libs/storage";
import { Button } from "@shared/button";
import { EyeOffIcon, EyeOnIcon } from "@shared/icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
export function CreateStep1Screen() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [type, setType] = useState("password");
const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
// toggle private key
const showPrivateKey = () => {
if (type === "password") {
setType("text");
} else {
setType("password");
}
};
const account = useMutation({
mutationFn: (data: any) =>
createAccount(data.npub, data.pubkey, data.privkey, null, 1),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
// redirect to next step
navigate("/auth/create/step-2", { replace: true });
},
});
const submit = async () => {
account.mutate({
npub,
pubkey,
privkey,
follows: null,
is_active: 1,
});
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Lume is auto-generated key for you
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400">
Public Key
</label>
<input
readOnly
value={npub}
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400">
Private Key
</label>
<div className="relative">
<input
readOnly
type={type}
value={nsec}
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === "password" ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
</div>
<Button preset="large" onClick={() => submit()}>
Continue
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { AvatarUploader } from "@shared/avatarUploader";
import { LoaderIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useOnboarding } from "@stores/onboarding";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
export function CreateStep2Screen() {
const navigate = useNavigate();
const createProfile = useOnboarding((state: any) => state.createProfile);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
setValue,
formState: { isDirty, isValid },
} = useForm();
const onSubmit = (data: any) => {
setLoading(true);
try {
const profile = { ...data, name: data.displayName };
createProfile(profile);
// redirect to step 3
navigate("/auth/create/step-3");
} catch {
console.log("error");
}
};
useEffect(() => {
setValue("picture", image);
}, [setValue, image]);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Create your profile
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-5">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<input
type={"hidden"}
{...register("picture")}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Avatar
</label>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="avatar"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader valueState={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Display Name *
</label>
<input
type={"text"}
{...register("displayName", {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Bio
</label>
<textarea
{...register("about")}
spellCheck={false}
className="resize-none relative h-20 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { Button } from "@shared/button";
import { LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useOnboarding } from "@stores/onboarding";
import { Body, fetch } from "@tauri-apps/api/http";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useState } from "react";
export function CreateStep3Screen() {
const ndk = useContext(RelayContext);
const profile = useOnboarding((state: any) => state.profile);
const { account } = useAccount();
const [username, setUsername] = useState("");
const [loading, setLoading] = useState(false);
const createNIP05 = async () => {
try {
setLoading(true);
const response = await fetch("https://lume.nu/api/user-create", {
method: "POST",
timeout: 30,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: Body.json({
username: username,
pubkey: account.pubkey,
lightningAddress: "",
}),
});
if (response.ok) {
const data = { ...profile, nip05: `${username}@lume.nu` };
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(data);
event.kind = 0;
event.pubkey = account.pubkey;
event.tags = [];
// publish event
event.publish();
// redirect to step 4
}
} catch (error) {
setLoading(false);
console.error("Error:", error);
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Create your Lume ID
</h1>
</div>
<div className="w-full flex flex-col justify-center items-center gap-4">
<div className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-zinc-800">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoCapitalize="false"
autoCorrect="none"
spellCheck="false"
placeholder="satoshi"
className="relative w-full py-3 pl-3.5 !outline-none placeholder:text-zinc-500 bg-transparent text-zinc-100"
/>
<span className="text-fuchsia-500 font-semibold pr-3.5">
@lume.nu
</span>
</div>
<Button
preset="large"
onClick={() => createNIP05()}
disabled={username.length === 0}
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</Button>
</div>
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { User } from "@app/auth/components/user";
import { updateAccount } from "@libs/storage";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CheckCircleIcon, LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { arrayToNIP02 } from "@utils/transform";
import { useContext, useState } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
import { useNavigate } from "react-router-dom";
const initialList = [
{
@@ -106,15 +108,15 @@ const initialList = [
},
];
export function Page() {
export function CreateStep4Screen() {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const [account, updateFollows] = useActiveAccount((state: any) => [
state.account,
state.updateFollows,
]);
const { account } = useAccount();
// toggle follow state
const toggleFollow = (pubkey: string) => {
@@ -124,6 +126,16 @@ export function Page() {
setFollows(arr);
};
const update = useMutation({
mutationFn: (follows: any) =>
updateAccount("follows", follows, account.pubkey),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
// redirect to next step
navigate("/auth/onboarding", { replace: true });
},
});
// save follows to database then broadcast
const submit = async () => {
try {
@@ -142,75 +154,64 @@ export function Page() {
// publish event
event.publish();
// update account follows
updateFollows(follows);
// redirect to onboarding
setTimeout(
() =>
navigate("/app/onboarding", {
overwriteLastHistoryEntry: true,
}),
2000,
);
// update
update.mutate(follows);
} catch {
console.log("error");
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Personalized your newsfeed
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
Follow at least
<span className="text-fuchsia-500 font-semibold">
{follows.length}/10
</span>{" "}
plebs
</div>
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
{initialList.map((item: { pubkey: string }, index: number) => (
<button
key={`item-${index}`}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
>
<User pubkey={item.pubkey} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon
width={16}
height={16}
className="text-green-400"
/>
</div>
)}
</button>
))}
</div>
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Personalized your newsfeed
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
Follow at least
<span className="text-fuchsia-500 font-semibold">
{follows.length}/10
</span>{" "}
plebs
</div>
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
{initialList.map((item: { pubkey: string }, index: number) => (
<button
key={`item-${index}`}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
>
<User pubkey={item.pubkey} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon
width={16}
height={16}
className="text-green-400"
/>
</div>
)}
</button>
))}
</div>
{follows.length >= 10 && (
<button
type="button"
onClick={() => submit()}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Finish →"
)}
</button>
)}
</div>
{follows.length >= 10 && (
<button
type="button"
onClick={() => submit()}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Finish →"
)}
</button>
)}
</div>
</div>
);

View File

@@ -0,0 +1,9 @@
import { Outlet } from "react-router-dom";
export function AuthImportScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { createAccount } from "@libs/storage";
import { LoaderIcon } from "@shared/icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getPublicKey, nip19 } from "nostr-tools";
import { Resolver, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
type FormValues = {
key: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.key ? values : {},
errors: !values.key
? {
key: {
type: "required",
message: "This is required.",
},
}
: {},
};
};
export function ImportStep1Screen() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const account = useMutation({
mutationFn: (data: any) =>
createAccount(data.npub, data.pubkey, data.privkey, null, 1),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
// redirect to next step
navigate("/auth/import/step-2", { replace: true });
},
});
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid, isSubmitting },
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: any) => {
try {
let privkey = data["key"];
if (privkey.substring(0, 4) === "nsec") {
privkey = nip19.decode(privkey).data;
}
if (typeof getPublicKey(privkey) === "string") {
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
// update
account.mutate({
npub,
pubkey,
privkey,
follows: null,
is_active: 1,
});
}
} catch (error) {
setError("key", {
type: "custom",
message: "Private Key is invalid, please check again",
});
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Import your key</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<input
{...register("key", { required: true, minLength: 32 })}
type={"password"}
placeholder="Paste private key here..."
className="relative w-full rounded-lg px-3 py-3 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
<span className="text-base text-red-400">
{errors.key && <p>{errors.key.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{isSubmitting ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { User } from "@app/auth/components/user";
import { updateAccount } from "@libs/storage";
import { Button } from "@shared/button";
import { LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { setToArray } from "@utils/transform";
import { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
export function ImportStep2Screen() {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const { status, account } = useAccount();
const update = useMutation({
mutationFn: (follows: any) =>
updateAccount("follows", follows, account.pubkey),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
// redirect to next step
navigate("/auth/onboarding", { replace: true });
},
});
const submit = async () => {
try {
// show loading indicator
setLoading(true);
const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows();
// follows as list
const followsList = setToArray(follows);
// update
update.mutate(followsList);
} catch {
console.log("error");
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? "Creating..." : "Continue with"}
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
{status === "loading" ? (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
<div>
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<User pubkey={account.pubkey} />
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,65 +0,0 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons";
import useSWR from "swr";
const fetcher = async () => {
const { platform } = await import("@tauri-apps/api/os");
return await platform();
};
export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
const { data: platform } = useSWR("platform", fetcher);
const goBack = () => {
window.history.back();
};
const goForward = () => {
window.history.forward();
};
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<div
data-tauri-drag-region
className="flex h-full w-full flex-1 items-center px-2"
>
<div
className={`flex h-full items-center gap-2 ${
platform === "darwin" ? "pl-[68px]" : ""
}`}
>
<button
type="button"
onClick={() => goBack()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowLeftIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
<button
type="button"
onClick={() => goForward()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowRightIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
</div>
</div>
</div>
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
</div>
</div>
);
}

View File

@@ -1,26 +1,17 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
import { Link } from "@shared/link";
import { RelayContext } from "@shared/relayProvider";
import { User } from "@shared/user";
import { useActiveAccount } from "@stores/accounts";
import { dateToUnix } from "@utils/date";
import { useContext, useEffect } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext } from "react";
import { Link, useNavigate } from "react-router-dom";
export function Page() {
export function OnboardingScreen() {
const ndk = useContext(RelayContext);
const navigate = useNavigate();
const [account, fetchAccount] = useActiveAccount((state: any) => [
state.account,
state.fetch,
]);
useEffect(() => {
if (account === null) {
fetchAccount();
}
}, [fetchAccount]);
const { status, account } = useAccount();
const publish = async () => {
try {
@@ -39,10 +30,7 @@ export function Page() {
event.publish();
// redirect to home
setTimeout(
() => navigate("/", { overwriteLastHistoryEntry: true }),
2000,
);
navigate("/", { replace: true });
} catch (error) {
console.log(error);
}
@@ -64,7 +52,7 @@ export function Page() {
</div>
<div className="w-full border-t border-zinc-800/50 bg-zinc-900 rounded-xl">
<div className="h-min w-full px-5 py-3">
{account && (
{status === "success" && (
<User
pubkey={account.pubkey}
time={Math.floor(Date.now() / 1000)}
@@ -97,7 +85,7 @@ export function Page() {
<ArrowRightCircleIcon className="w-5 h-5" />
</button>
<Link
href="/"
to="/"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
>
Skip for now

View File

@@ -1,90 +0,0 @@
import { Button } from "@shared/button";
import { EyeOffIcon, EyeOnIcon } from "@shared/icons";
import { useActiveAccount } from "@stores/accounts";
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
import { useMemo, useState } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
export function Page() {
const createAccount = useActiveAccount((state: any) => state.create);
const [type, setType] = useState("password");
const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
// toggle private key
const showPrivateKey = () => {
if (type === "password") {
setType("text");
} else {
setType("password");
}
};
const submit = async () => {
createAccount(npub, pubkey, privkey, null, 1);
navigate("/app/auth/create/step-2");
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Lume is auto-generated key for you
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400">
Public Key
</label>
<input
readOnly
value={npub}
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400">
Private Key
</label>
<div className="relative">
<input
readOnly
type={type}
value={nsec}
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === "password" ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
</div>
<Button preset="large" onClick={() => submit()}>
Continue
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,120 +0,0 @@
import { AvatarUploader } from "@shared/avatarUploader";
import { LoaderIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { navigate } from "vite-plugin-ssr/client/router";
export function Page() {
const createTempProfile = useActiveAccount(
(state: any) => state.createTempProfile,
);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
setValue,
formState: { isDirty, isValid },
} = useForm();
const onSubmit = (data: any) => {
setLoading(true);
try {
const profile = { ...data, name: data.displayName };
createTempProfile(profile);
// redirect to step 3
navigate("/app/auth/create/step-3", {
overwriteLastHistoryEntry: true,
});
} catch {
console.log("error");
}
};
useEffect(() => {
setValue("picture", image);
}, [setValue, image]);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Create your profile
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-5">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<input
type={"hidden"}
{...register("picture")}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Avatar
</label>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="avatar"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader valueState={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Display Name *
</label>
<input
type={"text"}
{...register("displayName", {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Bio
</label>
<textarea
{...register("about")}
spellCheck={false}
className="resize-none relative h-20 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,102 +0,0 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { Button } from "@shared/button";
import { LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { Body, fetch } from "@tauri-apps/api/http";
import { useContext, useState } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
export function Page() {
const ndk = useContext(RelayContext);
const [account, tempProfile] = useActiveAccount((state: any) => [
state.account,
state.tempProfile,
]);
const [username, setUsername] = useState("");
const [loading, setLoading] = useState(false);
const createNIP05 = async () => {
try {
setLoading(true);
const response = await fetch("https://lume.nu/api/user-create", {
method: "POST",
timeout: 30,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: Body.json({
username: username,
pubkey: account.pubkey,
lightningAddress: "",
}),
});
if (response.ok) {
const profile = { ...tempProfile, nip05: `${username}@lume.nu` };
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(profile);
event.kind = 0;
event.pubkey = account.pubkey;
event.tags = [];
// publish event
event.publish();
// redirect to step 4
navigate("/app/auth/create/step-4", {
overwriteLastHistoryEntry: true,
});
}
} catch (error) {
setLoading(false);
console.error("Error:", error);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Create your Lume ID
</h1>
</div>
<div className="w-full flex flex-col justify-center items-center gap-4">
<div className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-zinc-800">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoCapitalize="false"
autoCorrect="none"
spellCheck="false"
placeholder="satoshi"
className="relative w-full py-3 pl-3.5 !outline-none placeholder:text-zinc-500 bg-transparent text-zinc-100"
/>
<span className="text-fuchsia-500 font-semibold pr-3.5">
@lume.nu
</span>
</div>
<Button
preset="large"
onClick={() => createNIP05()}
disabled={username.length === 0}
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,99 +0,0 @@
import { LoaderIcon } from "@shared/icons";
import { useActiveAccount } from "@stores/accounts";
import { getPublicKey, nip19 } from "nostr-tools";
import { Resolver, useForm } from "react-hook-form";
import { navigate } from "vite-plugin-ssr/client/router";
type FormValues = {
key: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.key ? values : {},
errors: !values.key
? {
key: {
type: "required",
message: "This is required.",
},
}
: {},
};
};
export function Page() {
const createAccount = useActiveAccount((state: any) => state.create);
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid, isSubmitting },
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: any) => {
try {
let privkey = data["key"];
if (privkey.substring(0, 4) === "nsec") {
privkey = nip19.decode(privkey).data;
}
if (typeof getPublicKey(privkey) === "string") {
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
createAccount(npub, pubkey, privkey, null, 1);
navigate("/app/auth/import/step-2");
}
} catch (error) {
setError("key", {
type: "custom",
message: "Private Key is invalid, please check again",
});
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Import your key
</h1>
</div>
<div className="flex flex-col gap-4">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3"
>
<div className="flex flex-col gap-0.5">
<input
{...register("key", { required: true, minLength: 32 })}
type={"password"}
placeholder="Paste private key here..."
className="relative w-full rounded-lg px-3 py-3 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
<span className="text-base text-red-400">
{errors.key && <p>{errors.key.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{isSubmitting ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,78 +0,0 @@
import { User } from "@app/auth/components/user";
import { Button } from "@shared/button";
import { LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { setToArray } from "@utils/transform";
import { useContext, useState } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
export function Page() {
const ndk = useContext(RelayContext);
const [loading, setLoading] = useState(false);
const [account, updateFollows] = useActiveAccount((state: any) => [
state.account,
state.updateFollows,
]);
const submit = async () => {
// show loading indicator
setLoading(true);
try {
const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows();
// follows as list
const followsList = setToArray(follows);
// update account follows in store
updateFollows(followsList);
// redirect to onboarding
setTimeout(
() => navigate("/app/onboarding", { overwriteLastHistoryEntry: true }),
2000,
);
} catch {
console.log("error");
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? "Creating..." : "Continue with"}
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
{!account ? (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
<div>
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<User pubkey={account.pubkey} />
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</Button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
import { Link } from "react-router-dom";
export function Page() {
export function WelcomeScreen() {
return (
<div className="w-full h-full grid grid-cols-12 gap-4 px-4 py-4">
<div className="col-span-5 border-t border-zinc-800/50 bg-zinc-900 rounded-xl flex flex-col">
@@ -19,20 +20,20 @@ export function Page() {
</h3>
</div>
<div className="mt-auto w-full flex flex-col gap-2 px-4 py-4">
<a
href="/app/auth/import"
<Link
to="/auth/import"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg px-6 font-medium text-zinc-100 bg-fuchsia-500 hover:bg-fuchsia-600"
>
<span className="w-5" />
<span>Login with private key</span>
<ArrowRightCircleIcon className="w-5 h-5" />
</a>
<a
href="/app/auth/create"
</Link>
<Link
to="/auth/create"
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg px-6 font-medium text-zinc-200 bg-zinc-800 hover:bg-zinc-700"
>
Create new key
</a>
</Link>
</div>
</div>
<div

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -5,20 +5,24 @@ import { AvatarUploader } from "@shared/avatarUploader";
import { CancelIcon, LoaderIcon, PlusIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { navigate } from "vite-plugin-ssr/client/router";
import { useNavigate } from "react-router-dom";
export function ChannelCreateModal() {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
@@ -36,6 +40,21 @@ export function ChannelCreateModal() {
formState: { isDirty, isValid },
} = useForm();
const addChannel = useMutation({
mutationFn: (event: any) =>
createChannel(
event.id,
event.pubkey,
event.name,
event.picture,
event.about,
event.created_at,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
},
});
const onSubmit = (data: any) => {
setLoading(true);
@@ -55,7 +74,12 @@ export function ChannelCreateModal() {
event.publish();
// insert to database
createChannel(event.id, event.pubkey, event.content, event.created_at);
addChannel.mutate({
...event,
name: data.name,
picture: data.picture,
about: data.about,
});
// reset form
reset();
@@ -64,7 +88,7 @@ export function ChannelCreateModal() {
// close modal
setIsOpen(false);
// redirect to channel page
navigate(`/app/channel?id=${event.id}`);
navigate(`/app/channel/${event.id}`);
}, 1000);
} catch (e) {
console.log("error: ", e);

View File

@@ -1,29 +1,20 @@
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
import { Link } from "@shared/link";
import { usePageContext } from "@utils/hooks/usePageContext";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
export function ChannelsListItem({ data }: { data: any }) {
const channel: any = useChannelProfile(data.event_id);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pageID = searchParams.id;
const channel = useChannelProfile(data.event_id);
return (
<Link
href={`/app/channel?id=${data.event_id}`}
className={twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
pageID === data.event_id ? "bg-zinc-900 text-zinc-100" : "",
)}
<NavLink
to={`/app/channel/${data.event_id}`}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
isActive ? "bg-zinc-900/50 text-zinc-100" : "",
)
}
>
<div
className={twMerge(
"inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900",
pageID === data.event_id ? "bg-zinc-800" : "",
)}
>
<div className="inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-xs text-zinc-100">#</span>
</div>
<div className="w-full inline-flex items-center justify-between">
@@ -36,6 +27,6 @@ export function ChannelsListItem({ data }: { data: any }) {
)}
</div>
</div>
</Link>
</NavLink>
);
}

View File

@@ -1,27 +1,36 @@
import { ChannelCreateModal } from "@app/channel/components/createModal";
import { ChannelsListItem } from "@app/channel/components/item";
import { useChannels } from "@stores/channels";
import { useEffect } from "react";
import { getChannels } from "@libs/storage";
import { useQuery } from "@tanstack/react-query";
export function ChannelsList() {
const channels = useChannels((state: any) => state.channels);
const fetchChannels = useChannels((state: any) => state.fetch);
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
const {
status,
data: channels,
isFetching,
} = useQuery(
["channels"],
async () => {
return await getChannels();
},
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
);
return (
<div className="flex flex-col">
{!channels ? (
{status === "loading" ? (
<>
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
</>
) : (
@@ -29,6 +38,12 @@ export function ChannelsList() {
<ChannelsListItem key={item.event_id} data={item} />
))
)}
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
)}
<ChannelCreateModal />
</div>
);

View File

@@ -1,11 +1,14 @@
import { Member } from "@app/channel/components/member";
import { getChannelUsers } from "@libs/storage";
import useSWR from "swr";
const fetcher = ([, id]) => getChannelUsers(id);
import { useQuery } from "@tanstack/react-query";
export function ChannelMembers({ id }: { id: string }) {
const { data, isLoading }: any = useSWR(["channel-members", id], fetcher);
const { status, data, isFetching } = useQuery(
["channel-members", id],
async () => {
return await getChannelUsers(id);
},
);
return (
<div className="mt-3">
@@ -13,8 +16,7 @@ export function ChannelMembers({ id }: { id: string }) {
Members
</h5>
<div className="mt-3 w-full flex flex-wrap gap-1.5">
{isLoading && <p>Loading...</p>}
{!data ? (
{status === "loading" || isFetching ? (
<p>Loading...</p>
) : (
data.map((member: { pubkey: string }) => (

View File

@@ -3,14 +3,13 @@ import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon, EnterIcon } from "@shared/icons";
import { MediaUploader } from "@shared/mediaUploader";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useState } from "react";
export function ChannelMessageForm({ channelID }: { channelID: string }) {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const [value, setValue] = useState("");
const [replyTo, closeReply] = useChannelMessages((state: any) => [
@@ -18,6 +17,8 @@ export function ChannelMessageForm({ channelID }: { channelID: string }) {
state.closeReply,
]);
const { account } = useAccount();
const submit = () => {
let tags: string[][];

View File

@@ -1,23 +1,15 @@
import { getChannel, updateChannelMetadata } from "@libs/storage";
import { RelayContext } from "@shared/relayProvider";
import { useContext } from "react";
import useSWR from "swr";
import useSWRSubscription from "swr/subscription";
const fetcher = async ([, id]) => {
const result = await getChannel(id);
if (result) {
return result;
} else {
return null;
}
};
import { useQuery } from "@tanstack/react-query";
import { useContext, useEffect } from "react";
export function useChannelProfile(id: string) {
const ndk = useContext(RelayContext);
const { data, mutate } = useSWR(["channel-metadata", id], fetcher);
const { data } = useQuery(["channel-metadata", id], async () => {
return await getChannel(id);
});
useSWRSubscription(data ? ["channel-metadata", id] : null, () => {
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
@@ -32,14 +24,12 @@ export function useChannelProfile(id: string) {
sub.addListener("event", (event: { content: string }) => {
// update in local database
updateChannelMetadata(id, event.content);
// revaildate
mutate();
});
return () => {
sub.stop();
};
});
}, []);
return data;
}

View File

@@ -1,15 +1,20 @@
import { ChannelMessageItem } from "../components/messages/item";
import { ChannelMessageItem } from "./components/messages/item";
import { ChannelMembers } from "@app/channel/components/members";
import { ChannelMessageForm } from "@app/channel/components/messages/form";
import { ChannelMetadata } from "@app/channel/components/metadata";
import { RelayContext } from "@shared/relayProvider";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix, getHourAgo } from "@utils/date";
import { usePageContext } from "@utils/hooks/usePageContext";
import { LumeEvent } from "@utils/types";
import { useCallback, useContext, useEffect, useRef } from "react";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
} from "react";
import { useParams } from "react-router-dom";
import { Virtuoso } from "react-virtuoso";
import useSWRSubscription from "swr/subscription";
const now = new Date();
@@ -42,13 +47,11 @@ const Empty = (
</div>
);
export function Page() {
export function ChannelScreen() {
const ndk = useContext(RelayContext);
const pageContext = usePageContext();
const virtuosoRef = useRef(null);
const searchParams: any = pageContext.urlParsed.search;
const channelID = searchParams.id;
const { id } = useParams();
const [messages, fetchMessages, addMessage, clearMessages] =
useChannelMessages((state: any) => [
@@ -58,36 +61,30 @@ export function Page() {
state.clear,
]);
useSWRSubscription(
channelID ? ["channelMessagesSubscribe", channelID] : null,
() => {
// subscribe to channel
const sub = ndk.subscribe(
{
"#e": [channelID],
kinds: [42],
since: dateToUnix(),
},
{ closeOnEose: false },
);
sub.addListener("event", (event: LumeEvent) => {
addMessage(channelID, event);
});
return () => {
sub.stop();
};
},
);
useLayoutEffect(() => {
fetchMessages(id);
}, [fetchMessages]);
useEffect(() => {
fetchMessages(channelID);
// subscribe to channel
const sub = ndk.subscribe(
{
"#e": [id],
kinds: [42],
since: dateToUnix(),
},
{ closeOnEose: false },
);
sub.addListener("event", (event: LumeEvent) => {
addMessage(id, event);
});
return () => {
clearMessages();
sub.stop();
};
}, [fetchMessages]);
}, []);
const itemContent: any = useCallback(
(index: string | number) => {
@@ -135,7 +132,7 @@ export function Page() {
/>
)}
<div className="w-full inline-flex shrink-0 px-5 py-3 border-t border-zinc-800">
<ChannelMessageForm channelID={channelID} />
<ChannelMessageForm channelID={id} />
</div>
</div>
</div>
@@ -146,8 +143,8 @@ export function Page() {
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
/>
<div className="p-3 flex flex-col gap-3">
<ChannelMetadata id={channelID} />
<ChannelMembers id={channelID} />
<ChannelMetadata id={id} />
<ChannelMembers id={id} />
</div>
</div>
</div>

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -1,22 +1,16 @@
import { Image } from "@shared/image";
import { Link } from "@shared/link";
import { DEFAULT_AVATAR } from "@stores/constants";
import { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
export function ChatsListItem({ data }: { data: any }) {
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey;
const { user, isError, isLoading } = useProfile(data.sender_pubkey);
const { status, user, isFetching } = useProfile(data.sender_pubkey);
return (
<>
{isError && <div>error</div>}
{isLoading && !user ? (
{status === "loading" && isFetching ? (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div>
@@ -24,14 +18,14 @@ export function ChatsListItem({ data }: { data: any }) {
</div>
</div>
) : (
<Link
href={`/app/chat?pubkey=${data.sender_pubkey}`}
className={twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
pagePubkey === data.sender_pubkey
? "bg-zinc-900 text-zinc-100"
: "",
)}
<NavLink
to={`/app/chat/${data.sender_pubkey}`}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
isActive ? "bg-zinc-900/50 text-zinc-100" : "",
)
}
>
<div className="inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<Image
@@ -57,7 +51,7 @@ export function ChatsListItem({ data }: { data: any }) {
)}
</div>
</div>
</Link>
</NavLink>
)}
</>
);

View File

@@ -1,44 +1,47 @@
import { ChatsListItem } from "@app/chat/components/item";
import { NewMessageModal } from "@app/chat/components/modal";
import { ChatsListSelfItem } from "@app/chat/components/self";
import { useActiveAccount } from "@stores/accounts";
import { useChats } from "@stores/chats";
import { useEffect } from "react";
import { getChatsByPubkey } from "@libs/storage";
import { useQuery } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
export function ChatsList() {
const account = useActiveAccount((state: any) => state.account);
const chats = useChats((state: any) => state.chats);
const fetchChats = useChats((state: any) => state.fetch);
const { account } = useAccount();
useEffect(() => {
if (!account) return;
fetchChats(account.pubkey);
}, [fetchChats]);
if (!account)
return (
<div className="flex flex-col">
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
</div>
);
const {
status,
data: chats,
isFetching,
} = useQuery(
["chats"],
async () => {
return await getChatsByPubkey(account.pubkey);
},
{
enabled: account ? true : false,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
},
);
return (
<div className="flex flex-col">
<ChatsListSelfItem data={account} />
{!chats ? (
{account ? (
<ChatsListSelfItem data={account} />
) : (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
)}
{status === "loading" ? (
<>
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
<div className="inline-flex h-9 items-center gap-2 rounded-md px-2.5">
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
@@ -50,6 +53,12 @@ export function ChatsList() {
}
})
)}
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
)}
<NewMessageModal />
</div>
);

View File

@@ -1,85 +0,0 @@
import { ChatMessageItem } from "@app/chat/components/messages/item";
import { useActiveAccount } from "@stores/accounts";
import { useChatMessages } from "@stores/chats";
import { getHourAgo } from "@utils/date";
import { useCallback, useRef } from "react";
import { Virtuoso } from "react-virtuoso";
const now = new Date();
const Header = (
<div className="relative py-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center">
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
{getHourAgo(24, now).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
</div>
</div>
);
const Empty = (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-base font-semibold leading-none text-white">
Nothing to see here yet
</h3>
<p className="text-base leading-none text-zinc-400">
You two didn't talk yet, let's send first message
</p>
</div>
);
export function ChatMessageList() {
const account = useActiveAccount((state: any) => state.account);
const messages = useChatMessages((state: any) => state.messages);
const virtuosoRef = useRef(null);
const itemContent: any = useCallback(
(index: string | number) => {
return (
<ChatMessageItem
data={messages[index]}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
);
},
[account.privkey, account.pubkey, messages],
);
const computeItemKey = useCallback(
(index: string | number) => {
return messages[index].id;
},
[messages],
);
return (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={messages.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
components={{
Header: () => Header,
EmptyPlaceholder: () => Empty,
}}
/>
</div>
);
}

View File

@@ -2,16 +2,19 @@ import { Dialog, Transition } from "@headlessui/react";
import { getPlebs } from "@libs/storage";
import { CancelIcon, PlusIcon } from "@shared/icons";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { nip19 } from "nostr-tools";
import { Fragment, useState } from "react";
import useSWR from "swr";
import { navigate } from "vite-plugin-ssr/client/router";
const fetcher = () => getPlebs();
import { useNavigate } from "react-router-dom";
export function NewMessageModal() {
const navigate = useNavigate();
const { status, data, isFetching }: any = useQuery(["plebs"], async () => {
return await getPlebs();
});
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading }: any = useSWR("plebs", fetcher);
const closeModal = () => {
setIsOpen(false);
@@ -23,7 +26,7 @@ export function NewMessageModal() {
const openChat = (npub: string) => {
const pubkey = nip19.decode(npub).data;
navigate(`/app/chat?pubkey=${pubkey}`);
navigate(`/app/chat/${pubkey}`);
};
return (
@@ -92,8 +95,7 @@ export function NewMessageModal() {
</div>
</div>
<div className="h-[500px] flex flex-col pb-5 overflow-y-auto">
{isLoading && <p>Loading...</p>}
{!data ? (
{status === "loading" || isFetching ? (
<p>Loading...</p>
) : (
data.map((pleb) => (

View File

@@ -1,21 +1,16 @@
import { Image } from "@shared/image";
import { Link } from "@shared/link";
import { DEFAULT_AVATAR } from "@stores/constants";
import { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
export function ChatsListSelfItem({ data }: { data: any }) {
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey;
const { user, isLoading } = useProfile(data.pubkey);
const { status, user, isFetching } = useProfile(data.pubkey);
return (
<>
{isLoading && !user ? (
{status === "loading" && isFetching ? (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div>
@@ -23,12 +18,14 @@ export function ChatsListSelfItem({ data }: { data: any }) {
</div>
</div>
) : (
<Link
href={`/app/chat?pubkey=${data.pubkey}`}
className={twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
pagePubkey === data.pubkey ? "bg-zinc-900 text-zinc-100" : "",
)}
<NavLink
to={`/app/chat/${data.pubkey}`}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
isActive ? "bg-zinc-900/50 text-zinc-100" : "",
)
}
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<Image
@@ -44,7 +41,7 @@ export function ChatsListSelfItem({ data }: { data: any }) {
</h5>
<span className="text-zinc-500">(you)</span>
</div>
</Link>
</NavLink>
)}
</>
);

View File

@@ -3,14 +3,15 @@ import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { nip19 } from "nostr-tools";
import { navigate } from "vite-plugin-ssr/client/router";
import { useNavigate } from "react-router-dom";
export function ChatSidebar({ pubkey }: { pubkey: string }) {
const navigate = useNavigate();
const { user } = useProfile(pubkey);
const viewProfile = () => {
const pubkey = nip19.decode(user.npub).data;
navigate(`/app/user?pubkey=${pubkey}`);
navigate(`/app/user/${pubkey}`);
};
return (

110
src/app/chat/index.tsx Normal file
View File

@@ -0,0 +1,110 @@
import { ChatMessageForm } from "@app/chat/components/messages/form";
import { ChatMessageItem } from "@app/chat/components/messages/item";
import { ChatSidebar } from "@app/chat/components/sidebar";
import { getChatMessages } from "@libs/storage";
import { useQuery } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { useCallback, useRef } from "react";
import { useParams } from "react-router-dom";
import { Virtuoso } from "react-virtuoso";
export function ChatScreen() {
const virtuosoRef = useRef(null);
const { pubkey } = useParams();
const { account } = useAccount();
const { status, data } = useQuery(
["chat", pubkey],
async () => {
return await getChatMessages(account.pubkey, pubkey);
},
{
enabled: account ? true : false,
},
);
const itemContent: any = useCallback(
(index: string | number) => {
return (
<ChatMessageItem
data={data[index]}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
);
},
[data],
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data],
);
return (
<div className="h-full w-full grid grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
>
<h3 className="font-semibold text-zinc-100">Encrypted Chat</h3>
</div>
<div className="w-full flex-1 p-3">
{account && (
<div className="flex h-full flex-col justify-between rounded-md bg-zinc-900">
{status === "loading" ? (
<p>Loading...</p>
) : (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
components={{
EmptyPlaceholder: () => Empty,
}}
/>
</div>
)}
<div className="shrink-0 px-5 p-3 border-t border-zinc-800">
<ChatMessageForm
receiverPubkey={pubkey}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
</div>
</div>
)}
</div>
</div>
<div className="col-span-1">
<div
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
/>
{pubkey && <ChatSidebar pubkey={pubkey} />}
</div>
</div>
);
}
const Empty = (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-base font-semibold leading-none text-white">
Nothing to see here yet
</h3>
<p className="text-base leading-none text-zinc-400">
You two didn't talk yet, let's send first message
</p>
</div>
);

View File

@@ -1,85 +0,0 @@
import { ChatSidebar } from "../components/sidebar";
import { ChatMessageList } from "@app/chat/components/messageList";
import { ChatMessageForm } from "@app/chat/components/messages/form";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { useChatMessages } from "@stores/chats";
import { dateToUnix } from "@utils/date";
import { usePageContext } from "@utils/hooks/usePageContext";
import { LumeEvent } from "@utils/types";
import { useContext, useEffect } from "react";
import useSWRSubscription from "swr/subscription";
export function Page() {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pubkey = searchParams.pubkey;
const [add, fetchMessages, clear] = useChatMessages((state: any) => [
state.add,
state.fetch,
state.clear,
]);
useSWRSubscription(account !== pubkey ? ["chat", pubkey] : null, () => {
const sub = ndk.subscribe({
kinds: [4],
authors: [pubkey],
"#p": [account.pubkey],
since: dateToUnix(),
});
sub.addListener("event", (event: LumeEvent) => {
add(account.pubkey, event);
});
return () => {
sub.stop();
};
});
useEffect(() => {
fetchMessages(account.pubkey, pubkey);
return () => {
clear();
};
}, [pubkey, fetchMessages]);
if (!account) return <div>Fuck SSR</div>;
return (
<div className="h-full w-full grid grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
>
<h3 className="font-semibold text-zinc-100">Encrypted Chat</h3>
</div>
<div className="w-full flex-1 p-3">
<div className="flex h-full flex-col justify-between rounded-md bg-zinc-900">
<ChatMessageList />
<div className="shrink-0 px-5 p-3 border-t border-zinc-800">
<ChatMessageForm
receiverPubkey={pubkey}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
</div>
</div>
</div>
</div>
<div className="col-span-1">
<div
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
/>
<ChatSidebar pubkey={pubkey} />
</div>
</div>
);
}

17
src/app/error.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { useRouteError } from "react-router-dom";
export function ErrorScreen() {
const error: any = useRouteError();
return (
<div className="w-full h-full flex items-center justify-center">
<div>
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
</div>
);
}

View File

@@ -1 +0,0 @@
export const filesystemRoutingRoot = "/";

View File

@@ -1,29 +0,0 @@
import { useActiveAccount } from "@stores/accounts";
import { useEffect } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
export function Page() {
const fetchLastLogin = useActiveAccount((state: any) => state.fetchLastLogin);
const fetchAccount = useActiveAccount((state: any) => state.fetch);
const account = useActiveAccount((state: any) => state.account);
const lastLogin = useActiveAccount((state: any) => state.lastLogin);
useEffect(() => {
if (account === null) {
fetchAccount();
}
if (lastLogin === null) {
fetchLastLogin();
}
if (!account) {
navigate("/app/auth", { overwriteLastHistoryEntry: true });
}
if (account) {
navigate("/app/prefetch", { overwriteLastHistoryEntry: true });
}
}, [fetchAccount, fetchLastLogin, account, lastLogin]);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100" />
);
}

View File

@@ -1 +0,0 @@
export { LayoutOnboarding as Layout } from "./layout";

View File

@@ -1,65 +0,0 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons";
import useSWR from "swr";
const fetcher = async () => {
const { platform } = await import("@tauri-apps/api/os");
return await platform();
};
export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
const { data: platform } = useSWR("platform", fetcher);
const goBack = () => {
window.history.back();
};
const goForward = () => {
window.history.forward();
};
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<div
data-tauri-drag-region
className="flex h-full w-full flex-1 items-center px-2"
>
<div
className={`flex h-full items-center gap-2 ${
platform === "darwin" ? "pl-[68px]" : ""
}`}
>
<button
type="button"
onClick={() => goBack()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowLeftIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
<button
type="button"
onClick={() => goForward()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowRightIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
</div>
</div>
</div>
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
export function Page() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100"># TODO</h1>
</div>
</div>
</div>
);
}

View File

@@ -5,29 +5,25 @@ import {
createChat,
createNote,
getChannels,
getLastLogin,
} from "@libs/storage";
import { NDKFilter } from "@nostr-dev-kit/ndk";
import { LumeIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { dateToUnix, getHourAgo } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useEffect, useRef } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
import { useNavigate } from "react-router-dom";
let totalNotes: number;
const totalNotes = await countTotalNotes();
const lastLogin = await getLastLogin();
if (typeof window !== "undefined") {
totalNotes = await countTotalNotes();
}
export function Page() {
export function Root() {
const ndk = useContext(RelayContext);
const now = useRef(new Date());
const navigate = useNavigate();
const [account, lastLogin] = useActiveAccount((state: any) => [
state.account,
state.lastLogin,
]);
const { status, account } = useAccount();
async function fetchNotes() {
try {
@@ -150,12 +146,15 @@ export function Page() {
const chats = await fetchChats();
const channels = await fetchChannelMessages();
if (chats && channels) {
navigate("/app/space", { overwriteLastHistoryEntry: true });
navigate("/app/space", { replace: true });
}
}
}
prefetch();
}, []);
if (status === "success" && account) {
prefetch();
}
}, [status]);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -1,19 +1,15 @@
import { getNotesByAuthor } from "@libs/storage";
import { CancelIcon } from "@shared/icons";
import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import { useActiveAccount } from "@stores/accounts";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useMemo, useRef } from "react";
import useSWRInfinite from "swr/infinite";
import { useEffect, useRef } from "react";
const ITEM_PER_PAGE = 10;
const TIME = Math.floor(Date.now() / 1000);
const fetcher = async ([pubkey, offset]) =>
getNotesByAuthor(pubkey, TIME, ITEM_PER_PAGE, offset);
export function FeedBlock({ params }: { params: any }) {
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
@@ -21,26 +17,36 @@ export function FeedBlock({ params }: { params: any }) {
removeBlock(params.id, true);
};
const getKey = (pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.data) return null;
if (pageIndex === 0) return [params.content, 0];
return [params.content, previousPageData.nextCursor];
};
const { data, isLoading, size, setSize } = useSWRInfinite(getKey, fetcher);
const notes = useMemo(
() => (data ? data.flatMap((d) => d.data) : []),
[data],
);
const {
status,
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
}: any = useInfiniteQuery({
queryKey: ["newsfeed", params.content],
queryFn: async ({ pageParam = 0 }) => {
return await getNotesByAuthor(
params.content,
TIME,
ITEM_PER_PAGE,
pageParam,
);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: notes.length,
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
estimateSize: () => 500,
overscan: 2,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
useEffect(() => {
@@ -50,10 +56,25 @@ export function FeedBlock({ params }: { params: any }) {
return;
}
if (lastItem.index >= notes.length - 1) {
setSize(size + 1);
if (
lastItem.index >= notes.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [notes.length, rowVirtualizer.getVirtualItems()]);
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const renderItem = (index: string | number) => {
const note = notes[index];
if (!note) return;
return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} block={params.id} />
</div>
);
};
return (
<div className="shrink-0 w-[400px] border-r border-zinc-900">
@@ -63,7 +84,7 @@ export function FeedBlock({ params }: { params: any }) {
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"
style={{ contain: "strict" }}
>
{!data || isLoading ? (
{status === "loading" || isFetching ? (
<div className="px-3 py-1.5">
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
@@ -85,20 +106,9 @@ export function FeedBlock({ params }: { params: any }) {
}px)`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const note = notes[virtualRow.index];
if (note) {
return (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<Note event={note} block={params.id} />
</div>
);
}
})}
{rowVirtualizer
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}

View File

@@ -1,68 +1,44 @@
import { createNote, getNotes } from "@libs/storage";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { NDKEvent, NDKFilter, NDKSubscription } from "@nostr-dev-kit/ndk";
import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { RelayContext } from "@shared/relayProvider";
import { TitleBar } from "@shared/titleBar";
import { useActiveAccount } from "@stores/accounts";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { dateToUnix } from "@utils/date";
import { useContext, useEffect, useMemo, useRef } from "react";
import useSWRInfinite from "swr/infinite";
import useSWRSubscription from "swr/subscription";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useEffect, useRef } from "react";
const ITEM_PER_PAGE = 10;
const TIME = Math.floor(Date.now() / 1000);
const fetcher = async ([, offset]) => getNotes(TIME, ITEM_PER_PAGE, offset);
export function FollowingBlock({ block }: { block: number }) {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const getKey = (pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.data) return null;
if (pageIndex === 0) return ["following", 0];
return ["following", previousPageData.nextCursor];
};
const { account } = useAccount();
// fetch initial notes
const { data, isLoading, size, setSize } = useSWRInfinite(getKey, fetcher);
// fetch live notes
useSWRSubscription(account ? "eventCollector" : null, () => {
const follows = JSON.parse(account.follows);
const sub = ndk.subscribe({
kinds: [1, 6],
authors: follows,
since: dateToUnix(),
});
sub.addListener("event", (event: NDKEvent) => {
// save note
createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
);
});
return () => {
sub.stop();
};
const {
status,
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
}: any = useInfiniteQuery({
queryKey: ["newsfeed-circle"],
queryFn: async ({ pageParam = 0 }) => {
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = useMemo(
() => (data ? data.flatMap((d) => d.data) : []),
[data],
);
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: notes.length,
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
@@ -77,10 +53,43 @@ export function FollowingBlock({ block }: { block: number }) {
return;
}
if (lastItem.index >= notes.length - 1) {
setSize(size + 1);
if (
lastItem.index >= notes.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [notes.length, rowVirtualizer.getVirtualItems()]);
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
useEffect(() => {
let sub: NDKSubscription;
if (account) {
const follows = JSON.parse(account.follows);
const filter: NDKFilter = {
kinds: [1, 6],
authors: follows,
since: dateToUnix(),
};
sub = ndk.subscribe(filter);
sub.addListener("event", (event: NDKEvent) => {
createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
);
});
}
return () => {
sub.stop();
};
}, [account]);
const renderItem = (index: string | number) => {
const note = notes[index];
@@ -101,7 +110,7 @@ export function FollowingBlock({ block }: { block: number }) {
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"
style={{ contain: "strict" }}
>
{!data || isLoading ? (
{status === "loading" ? (
<div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
@@ -129,6 +138,13 @@ export function FollowingBlock({ block }: { block: number }) {
</div>
</div>
)}
{isFetching && !isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
</div>
</div>
)}
</div>
</div>
);

View File

@@ -1,5 +1,4 @@
import { getNoteByID } from "@libs/storage";
import { ArrowLeftIcon } from "@shared/icons";
import { Kind1 } from "@shared/notes/contents/kind1";
import { Kind1063 } from "@shared/notes/contents/kind1063";
import { NoteMetadata } from "@shared/notes/metadata";
@@ -9,15 +8,19 @@ import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import { User } from "@shared/user";
import { useActiveAccount } from "@stores/accounts";
import { useQuery } from "@tanstack/react-query";
import { parser } from "@utils/parser";
import useSWR from "swr";
const fetcher = ([, id]) => getNoteByID(id);
export function ThreadBlock({ params }: { params: any }) {
const { data } = useSWR(["thread", params.content], fetcher);
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
const { status, data, isFetching } = useQuery(
["thread", params.content],
async () => {
return await getNoteByID(params.content);
},
);
const content = data ? parser(data) : null;
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
const close = () => {
removeBlock(params.id, false);
@@ -27,7 +30,7 @@ export function ThreadBlock({ params }: { params: any }) {
<div className="shrink-0 w-[400px] border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => close()} />
<div className="scrollbar-hide flex w-full h-full flex-col gap-1.5 pt-1.5 pb-20 overflow-y-auto">
{!data ? (
{status === "loading" || isFetching ? (
<div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />

View File

@@ -3,18 +3,11 @@ import { FeedBlock } from "@app/space/components/blocks/feed";
import { FollowingBlock } from "@app/space/components/blocks/following";
import { ImageBlock } from "@app/space/components/blocks/image";
import { ThreadBlock } from "@app/space/components/blocks/thread";
import { useActiveAccount } from "@stores/accounts";
import { useEffect } from "react";
import { getBlocks } from "@libs/storage";
export function Page() {
const blocks = useActiveAccount((state: any) => state.blocks);
const fetchBlocks = useActiveAccount((state: any) => state.fetchBlocks);
useEffect(() => {
if (blocks !== null) return;
fetchBlocks();
}, [fetchBlocks]);
const blocks = await getBlocks();
export function SpaceScreen() {
return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
<FollowingBlock block={1} />

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -1,16 +1,20 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { compactNumber } from "@utils/number";
import { shortenKey } from "@utils/shortenKey";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function Profile({ data }: { data: any }) {
const { data: userStats, error } = useSWR(
`https://api.nostr.band/v0/stats/profile/${data.pubkey}`,
fetcher,
);
const {
status,
data: userStats,
isFetching,
} = useQuery(["user-stats", data.pubkey], async () => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${data.pubkey}`,
);
return res.json();
});
const embedProfile = data.profile ? JSON.parse(data.profile.content) : null;
const profile = embedProfile;
@@ -47,8 +51,7 @@ export function Profile({ data }: { data: any }) {
</p>
</div>
<div className="mt-8">
{error && <p>Failed to fetch user stats</p>}
{!userStats ? (
{status === "loading" || isFetching ? (
<p>Loading...</p>
) : (
<div className="w-full flex items-center gap-8">

View File

@@ -1,22 +1,22 @@
import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
import { useQuery } from "@tanstack/react-query";
export function TrendingNotes() {
const { data, error } = useSWR(
"https://api.nostr.band/v0/trending/notes",
fetcher,
const { status, data, isFetching } = useQuery(
["trending-notes"],
async () => {
const res = await fetch("https://api.nostr.band/v0/trending/notes");
return res.json();
},
);
return (
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900">
<TitleBar title="Trending Posts" />
<div className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto">
{error && <p>Failed to load...</p>}
{!data ? (
{status === "loading" || isFetching ? (
<div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />

View File

@@ -1,22 +1,22 @@
import { Profile } from "@app/trending/components/profile";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
import { useQuery } from "@tanstack/react-query";
export function TrendingProfiles() {
const { data, error } = useSWR(
"https://api.nostr.band/v0/trending/profiles",
fetcher,
const { status, data, isFetching } = useQuery(
["trending-profiles"],
async () => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles");
return res.json();
},
);
return (
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900">
<TitleBar title="Trending Profiles" />
<div className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto">
{error && <p>Failed to load...</p>}
{!data ? (
{status === "loading" || isFetching ? (
<div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />

View File

@@ -1,7 +1,7 @@
import { TrendingNotes } from "@app/trending/components/trendingNotes";
import { TrendingProfiles } from "@app/trending/components/trendingProfiles";
export function Page() {
export function TrendingScreen() {
return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
<TrendingProfiles />

View File

@@ -1 +0,0 @@
export { DefaultLayout as Layout } from "@shared/layout";

View File

@@ -3,27 +3,30 @@ import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { dateToUnix } from "@utils/date";
import { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from "@utils/hooks/useProfile";
import { compactNumber } from "@utils/number";
import { shortenKey } from "@utils/shortenKey";
import { useContext } from "react";
import useSWR from "swr";
import { Link } from "react-router-dom";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function Page() {
export function UserScreen() {
const ndk = useContext(RelayContext);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pubkey = searchParams.pubkey || "";
const { user } = useProfile(pubkey);
const { data: userStats, error } = useSWR(
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
fetcher,
);
const { data: userStats, error } = useQuery(["user", pubkey], async () => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
);
if (res.ok) {
return await res.json();
}
});
const account = useActiveAccount((state: any) => state.account);
const follows = account ? JSON.parse(account.follows) : [];
@@ -180,12 +183,12 @@ export function Page() {
Follow
</button>
)}
<a
href={`/app/chat?pubkey=${pubkey}`}
<Link
to={`/app/chat/${pubkey}`}
className="inline-flex w-44 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
>
Message
</a>
</Link>
</div>
</div>
</div>