wip
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export { LayoutOnboarding as Layout } from "./layout";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
9
src/app/auth/create/index.tsx
Normal file
9
src/app/auth/create/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/app/auth/create/step-1.tsx
Normal file
105
src/app/auth/create/step-1.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/app/auth/create/step-2.tsx
Normal file
112
src/app/auth/create/step-2.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/app/auth/create/step-3.tsx
Normal file
95
src/app/auth/create/step-3.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
9
src/app/auth/import/index.tsx
Normal file
9
src/app/auth/import/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/app/auth/import/step-1.tsx
Normal file
111
src/app/auth/import/step-1.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/app/auth/import/step-2.tsx
Normal file
81
src/app/auth/import/step-2.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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[][];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
110
src/app/chat/index.tsx
Normal 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>
|
||||
);
|
||||
@@ -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
17
src/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const filesystemRoutingRoot = "/";
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { LayoutOnboarding as Layout } from "./layout";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
@@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
@@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
@@ -1 +0,0 @@
|
||||
export { DefaultLayout as Layout } from "@shared/layout";
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user