From c172c0f80f32c8c7b7d47133afd9bf5af48bd838 Mon Sep 17 00:00:00 2001 From: reya Date: Mon, 8 Jan 2024 20:18:07 +0700 Subject: [PATCH] feat: add onboarding modal --- apps/desktop/package.json | 1 + apps/desktop/src/routes/auth/create.tsx | 9 +- apps/desktop/src/routes/auth/follows.tsx | 5 +- apps/desktop/src/routes/error.tsx | 2 +- packages/icons/index.ts | 1 + packages/icons/src/popperFilled.tsx | 17 ++ packages/lume-column-timeline/src/home.tsx | 9 + packages/storage/index.ts | 25 +- packages/ui/package.json | 2 + .../ui/src/avatarUploadButton.tsx | 12 +- packages/ui/src/emptyFeed.tsx | 27 ++ packages/ui/src/index.ts | 1 + packages/ui/src/layouts/home.tsx | 2 + packages/ui/src/onboarding/finish.tsx | 42 +++ packages/ui/src/onboarding/follow.tsx | 277 ++++++++++++++++++ packages/ui/src/onboarding/home.tsx | 46 +++ packages/ui/src/onboarding/modal.tsx | 21 ++ .../ui/src/onboarding/profileSettings.tsx | 147 ++++++++++ packages/ui/src/onboarding/router.tsx | 31 ++ packages/ui/src/user.tsx | 54 ++-- packages/utils/src/state.ts | 2 + pnpm-lock.yaml | 9 + 22 files changed, 693 insertions(+), 49 deletions(-) create mode 100644 packages/icons/src/popperFilled.tsx rename apps/desktop/src/routes/auth/components/avatarUploader.tsx => packages/ui/src/avatarUploadButton.tsx (79%) create mode 100644 packages/ui/src/emptyFeed.tsx create mode 100644 packages/ui/src/onboarding/finish.tsx create mode 100644 packages/ui/src/onboarding/follow.tsx create mode 100644 packages/ui/src/onboarding/home.tsx create mode 100644 packages/ui/src/onboarding/modal.tsx create mode 100644 packages/ui/src/onboarding/profileSettings.tsx create mode 100644 packages/ui/src/onboarding/router.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 001059d9..fffe8b06 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -47,6 +47,7 @@ "@tauri-apps/plugin-upload": "2.0.0-alpha.5", "@vidstack/react": "^1.9.8", "framer-motion": "^10.17.9", + "jotai": "^2.6.1", "minidenticons": "^4.2.0", "nanoid": "^5.0.4", "nostr-fetch": "^0.15.0", diff --git a/apps/desktop/src/routes/auth/create.tsx b/apps/desktop/src/routes/auth/create.tsx index 1d0e6a72..6b6ef649 100644 --- a/apps/desktop/src/routes/auth/create.tsx +++ b/apps/desktop/src/routes/auth/create.tsx @@ -1,5 +1,6 @@ import { useArk, useStorage } from "@lume/ark"; import { CheckIcon, ChevronDownIcon, LoaderIcon } from "@lume/icons"; +import { onboardingAtom } from "@lume/utils"; import NDK, { NDKEvent, NDKKind, @@ -11,6 +12,7 @@ import { downloadDir } from "@tauri-apps/api/path"; import { Window } from "@tauri-apps/api/window"; import { save } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; +import { useSetAtom } from "jotai"; import { getPublicKey, nip19 } from "nostr-tools"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -38,6 +40,7 @@ export function CreateAccountScreen() { const storage = useStorage(); const navigate = useNavigate(); const services = useLoaderData() as NDKEvent[]; + const setOnboarding = useSetAtom(onboardingAtom); const [serviceId, setServiceId] = useState(services?.[0]?.id); const [loading, setIsLoading] = useState(false); @@ -80,6 +83,8 @@ export function CreateAccountScreen() { privkey: signer.privateKey, }); + setOnboarding(true); + return navigate("/auth/onboarding"); }; @@ -142,9 +147,11 @@ export function CreateAccountScreen() { ark.updateNostrSigner({ signer: remoteSigner }); - authWindow.close(); + setOnboarding(true); setIsLoading(false); + authWindow.close(); + return navigate("/auth/onboarding"); }; diff --git a/apps/desktop/src/routes/auth/follows.tsx b/apps/desktop/src/routes/auth/follows.tsx index 05ff6149..25ffb06d 100644 --- a/apps/desktop/src/routes/auth/follows.tsx +++ b/apps/desktop/src/routes/auth/follows.tsx @@ -1,6 +1,5 @@ import { useArk } from "@lume/ark"; import { - ArrowLeftIcon, ArrowRightIcon, CancelIcon, ChevronDownIcon, @@ -40,11 +39,11 @@ export function FollowsScreen() { const navigate = useNavigate(); const { status, data } = useQuery({ - queryKey: ["trending-profiles-widget"], + queryKey: ["trending-users"], queryFn: async () => { const res = await fetch("https://api.nostr.band/v0/trending/profiles"); if (!res.ok) { - throw new Error("Error"); + throw new Error("Failed to fetch trending users from nostr.band API."); } return res.json(); }, diff --git a/apps/desktop/src/routes/error.tsx b/apps/desktop/src/routes/error.tsx index 843a2d6d..4601c6df 100644 --- a/apps/desktop/src/routes/error.tsx +++ b/apps/desktop/src/routes/error.tsx @@ -51,7 +51,7 @@ export function ErrorScreen() { return (
diff --git a/packages/icons/index.ts b/packages/icons/index.ts index 64145c44..30c3ca05 100644 --- a/packages/icons/index.ts +++ b/packages/icons/index.ts @@ -103,3 +103,4 @@ export * from "./src/plusSquare"; export * from "./src/column"; export * from "./src/addMedia"; export * from "./src/check"; +export * from "./src/popperFilled"; diff --git a/packages/icons/src/popperFilled.tsx b/packages/icons/src/popperFilled.tsx new file mode 100644 index 00000000..bf749bc4 --- /dev/null +++ b/packages/icons/src/popperFilled.tsx @@ -0,0 +1,17 @@ +export function PopperFilledIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} diff --git a/packages/lume-column-timeline/src/home.tsx b/packages/lume-column-timeline/src/home.tsx index 8a941851..ee934118 100644 --- a/packages/lume-column-timeline/src/home.tsx +++ b/packages/lume-column-timeline/src/home.tsx @@ -1,5 +1,6 @@ import { RepostNote, TextNote, useArk, useStorage } from "@lume/ark"; import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons"; +import { EmptyFeed } from "@lume/ui"; import { FETCH_LIMIT } from "@lume/utils"; import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import { useInfiniteQuery } from "@tanstack/react-query"; @@ -81,6 +82,14 @@ export function HomeRoute({ colKey }: { colKey: string }) { }; }, []); + if (!storage.account.contacts.length) { + return ( +
+ +
+ ); + } + return (
diff --git a/packages/storage/index.ts b/packages/storage/index.ts index 1c9c1dc8..12f5ee5a 100644 --- a/packages/storage/index.ts +++ b/packages/storage/index.ts @@ -21,7 +21,6 @@ export class LumeStorage { public settings: { autoupdate: boolean; bunker: boolean; - outbox: boolean; media: boolean; hashtag: boolean; depot: boolean; @@ -35,7 +34,6 @@ export class LumeStorage { this.settings = { autoupdate: false, bunker: false, - outbox: false, media: true, hashtag: true, depot: false, @@ -50,12 +48,11 @@ export class LumeStorage { for (const item of settings) { if (item.key === "nsecbunker") this.settings.bunker = !!parseInt(item.value); - if (item.key === "outbox") this.settings.outbox = !!parseInt(item.value); - if (item.key === "media") this.settings.media = !!parseInt(item.value); if (item.key === "hashtag") this.settings.hashtag = !!parseInt(item.value); if (item.key === "autoupdate") this.settings.autoupdate = !!parseInt(item.value); + if (item.key === "media") this.settings.media = !!parseInt(item.value); if (item.key === "depot") this.settings.depot = !!parseInt(item.value); if (item.key === "tunnel_url") this.settings.tunnelUrl = item.value; } @@ -323,11 +320,11 @@ export class LumeStorage { } public async getColumns() { - const widgets: Array = await this.#db.select( - "SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;", + const columns: Array = await this.#db.select( + "SELECT * FROM columns WHERE account_id = $1 ORDER BY created_at DESC;", [this.account.id], ); - return widgets; + return columns; } public async createColumn( @@ -336,16 +333,16 @@ export class LumeStorage { content: string | string[], ) { const insert = await this.#db.execute( - "INSERT INTO widgets (account_id, kind, title, content) VALUES ($1, $2, $3, $4);", + "INSERT INTO columns (account_id, kind, title, content) VALUES ($1, $2, $3, $4);", [this.account.id, kind, title, content], ); if (insert) { - const widgets: Array = await this.#db.select( - "SELECT * FROM widgets ORDER BY id DESC LIMIT 1;", + const columns: Array = await this.#db.select( + "SELECT * FROM columns ORDER BY id DESC LIMIT 1;", ); - if (widgets.length < 1) console.error("get created widget failed"); - return widgets[0]; + if (columns.length < 1) console.error("get created widget failed"); + return columns[0]; } console.error("create widget failed"); @@ -353,13 +350,13 @@ export class LumeStorage { public async updateColumn(id: number, title: string, content: string) { return await this.#db.execute( - "UPDATE widgets SET title = $1, content = $2 WHERE id = $3;", + "UPDATE columns SET title = $1, content = $2 WHERE id = $3;", [title, content, id], ); } public async removeColumn(id: number) { - const res = await this.#db.execute("DELETE FROM widgets WHERE id = $1;", [ + const res = await this.#db.execute("DELETE FROM columns WHERE id = $1;", [ id, ]); if (res) return id; diff --git a/packages/ui/package.json b/packages/ui/package.json index f62dbfb3..b60f3c43 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,6 +9,7 @@ "@lume/icons": "workspace:^", "@lume/utils": "workspace:^", "@nostr-dev-kit/ndk": "^2.3.2", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", @@ -24,6 +25,7 @@ "nostr-tools": "~1.17.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.49.2", "react-hotkeys-hook": "^4.4.3", "react-router-dom": "^6.21.1", "slate": "^0.101.5", diff --git a/apps/desktop/src/routes/auth/components/avatarUploader.tsx b/packages/ui/src/avatarUploadButton.tsx similarity index 79% rename from apps/desktop/src/routes/auth/components/avatarUploader.tsx rename to packages/ui/src/avatarUploadButton.tsx index d7612492..86d728ac 100644 --- a/apps/desktop/src/routes/auth/components/avatarUploader.tsx +++ b/packages/ui/src/avatarUploadButton.tsx @@ -1,9 +1,9 @@ import { useArk } from "@lume/ark"; import { LoaderIcon } from "@lume/icons"; -import { message } from "@tauri-apps/plugin-dialog"; import { Dispatch, SetStateAction, useState } from "react"; +import { toast } from "sonner"; -export function AvatarUploader({ +export function AvatarUploadButton({ setPicture, }: { setPicture: Dispatch>; @@ -25,12 +25,8 @@ export function AvatarUploader({ return; } catch (e) { - // stop loading setLoading(false); - await message(`Upload failed, error: ${e}`, { - title: "Lume", - type: "error", - }); + toast.error(e); } }; @@ -41,7 +37,7 @@ export function AvatarUploader({ className="inline-flex items-center justify-center rounded-lg border border-blue-200 bg-blue-100 px-2 py-1.5 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800" > {loading ? ( - + ) : ( "Change avatar" )} diff --git a/packages/ui/src/emptyFeed.tsx b/packages/ui/src/emptyFeed.tsx new file mode 100644 index 00000000..1137d0f0 --- /dev/null +++ b/packages/ui/src/emptyFeed.tsx @@ -0,0 +1,27 @@ +import { InfoIcon } from "@lume/icons"; +import { cn } from "@lume/utils"; + +export function EmptyFeed({ + text, + subtext, + className, +}: { text?: string; subtext?: string; className?: string }) { + return ( +
+ +
+

{text ? text : "No events yet"}

+

+ {subtext + ? subtext + : "You can follow more users to build up your timeline"} +

+
+
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e8e16a2f..36cc350b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -11,5 +11,6 @@ export * from "./layouts/home"; export * from "./layouts/settings"; export * from "./mentions"; export * from "./replyList"; +export * from "./emptyFeed"; export * from "./routes/event"; export * from "./routes/user"; diff --git a/packages/ui/src/layouts/home.tsx b/packages/ui/src/layouts/home.tsx index 9f3af4f7..a765a44f 100644 --- a/packages/ui/src/layouts/home.tsx +++ b/packages/ui/src/layouts/home.tsx @@ -1,9 +1,11 @@ import { ColumnProvider } from "@lume/ark"; import { Outlet } from "react-router-dom"; +import { OnboardingModal } from "../onboarding/modal"; export function HomeLayout() { return ( +
diff --git a/packages/ui/src/onboarding/finish.tsx b/packages/ui/src/onboarding/finish.tsx new file mode 100644 index 00000000..ccd31932 --- /dev/null +++ b/packages/ui/src/onboarding/finish.tsx @@ -0,0 +1,42 @@ +import { CheckIcon } from "@lume/icons"; +import { onboardingAtom } from "@lume/utils"; +import { motion } from "framer-motion"; +import { useSetAtom } from "jotai"; + +export function OnboardingFinishScreen() { + const setOnboarding = useSetAtom(onboardingAtom); + + return ( + + +
+

Profile setup complete!

+

+ You can exit the setup here and start using Lume. +

+
+
+ + + Report a issue + +
+
+ ); +} diff --git a/packages/ui/src/onboarding/follow.tsx b/packages/ui/src/onboarding/follow.tsx new file mode 100644 index 00000000..0c5b464f --- /dev/null +++ b/packages/ui/src/onboarding/follow.tsx @@ -0,0 +1,277 @@ +import { useArk } from "@lume/ark"; +import { + ArrowLeftIcon, + CancelIcon, + ChevronDownIcon, + LoaderIcon, + PlusIcon, +} from "@lume/icons"; +import { cn } from "@lume/utils"; +import * as Accordion from "@radix-ui/react-accordion"; +import { useQuery } from "@tanstack/react-query"; +import { motion } from "framer-motion"; +import { nip19 } from "nostr-tools"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { User } from "../user"; + +const POPULAR_USERS = [ + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6", + "npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m", + "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s", + "npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + "npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8", + "npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a", + "npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc", + "npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza", + "npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424", + "npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac", + "npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv", + "npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk", +]; + +const LUME_USERS = [ + "npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", +]; + +export function OnboardingFollowScreen() { + const ark = useArk(); + const navigate = useNavigate(); + + const { isLoading, isError, data } = useQuery({ + queryKey: ["trending-users"], + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const res = await fetch("https://api.nostr.band/v0/trending/profiles", { + signal, + }); + if (!res.ok) { + throw new Error("Failed to fetch trending users from nostr.band API."); + } + return res.json(); + }, + }); + + const [loading, setLoading] = useState(false); + const [follows, setFollows] = useState([]); + + // toggle follow state + const toggleFollow = (pubkey: string) => { + const arr = follows.includes(pubkey) + ? follows.filter((i) => i !== pubkey) + : [...follows, pubkey]; + setFollows(arr); + }; + + const submit = async () => { + try { + setLoading(true); + if (!follows.length) return navigate("/finish"); + + const publish = await ark.newContactList({ + tags: follows.map((item) => { + if (item.startsWith("npub1")) + return ["p", nip19.decode(item).data as string]; + return ["p", item]; + }), + }); + + if (publish) { + setLoading(false); + return navigate("/finish"); + } + } catch (e) { + setLoading(false); + toast.error(e); + } + }; + + return ( + +
+ Dive into the nostrverse +
+
+ +

+ Nostr is fun when we are together. Try following some users that + interest you to build up your timeline. +

+ + + + Recommended + + + +
+ {POPULAR_USERS.map((pubkey) => ( +
+ +
+ +
+
+ ))} +
+
+
+ + + Trending users + + + +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ Error. Cannot get trending users +
+ ) : ( + data?.profiles.map((item: { pubkey: string }) => ( +
+ +
+ +
+
+ )) + )} +
+
+
+ + + Lume HQ + + + +
+ {LUME_USERS.map((pubkey) => ( +
+ +
+ +
+
+ ))} +
+
+
+
+
+
+ + +
+
+
+ ); +} diff --git a/packages/ui/src/onboarding/home.tsx b/packages/ui/src/onboarding/home.tsx new file mode 100644 index 00000000..a557434d --- /dev/null +++ b/packages/ui/src/onboarding/home.tsx @@ -0,0 +1,46 @@ +import { ArrowRightIcon, PopperFilledIcon } from "@lume/icons"; +import { onboardingAtom } from "@lume/utils"; +import { motion } from "framer-motion"; +import { useSetAtom } from "jotai"; +import { useNavigate } from "react-router-dom"; + +export function OnboardingHomeScreen() { + const navigate = useNavigate(); + const setOnboarding = useSetAtom(onboardingAtom); + + return ( + + +
+

+ Your account was successfully created! +

+

+ For starters, let's set up your profile. +

+
+
+ + +
+
+ ); +} diff --git a/packages/ui/src/onboarding/modal.tsx b/packages/ui/src/onboarding/modal.tsx new file mode 100644 index 00000000..c645d9f1 --- /dev/null +++ b/packages/ui/src/onboarding/modal.tsx @@ -0,0 +1,21 @@ +import { onboardingAtom } from "@lume/utils"; +import * as Dialog from "@radix-ui/react-dialog"; +import { useAtomValue } from "jotai"; +import { OnboardingRouter } from "./router"; + +export function OnboardingModal() { + const onboarding = useAtomValue(onboardingAtom); + + return ( + + + + +
+ +
+
+
+
+ ); +} diff --git a/packages/ui/src/onboarding/profileSettings.tsx b/packages/ui/src/onboarding/profileSettings.tsx new file mode 100644 index 00000000..1ae6b9ab --- /dev/null +++ b/packages/ui/src/onboarding/profileSettings.tsx @@ -0,0 +1,147 @@ +import { useArk, useStorage } from "@lume/ark"; +import { ArrowLeftIcon, LoaderIcon } from "@lume/icons"; +import { NDKKind } from "@nostr-dev-kit/ndk"; +import { motion } from "framer-motion"; +import { minidenticon } from "minidenticons"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { AvatarUploadButton } from "../avatarUploadButton"; + +export function OnboardingProfileSettingsScreen() { + const [picture, setPicture] = useState(""); + const [loading, setLoading] = useState(false); + + const ark = useArk(); + const storage = useStorage(); + const navigate = useNavigate(); + + const { register, handleSubmit } = useForm(); + + const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent( + minidenticon("lume new account", 90, 50), + )}`; + + const onSubmit = async (data: { name: string; about: string }) => { + try { + setLoading(true); + + if (!data.name.length && !data.about.length) { + setLoading(false); + navigate("/follow"); + } + + const oldProfile = await ark.getUserProfile({ + pubkey: storage.account.pubkey, + }); + const ensureOldProfile = oldProfile ? oldProfile : {}; + + const profile = { + ...data, + ...ensureOldProfile, + display_name: data.name, + bio: data.about, + picture: picture, + avatar: picture, + }; + + const publish = await ark.createEvent({ + content: JSON.stringify(profile), + kind: NDKKind.Metadata, + tags: [], + }); + + if (publish) { + setLoading(false); + navigate("/follow"); + } else { + toast.error("Cannot publish your profile, please try again later."); + setLoading(false); + } + } catch (e) { + return toast.error(e); + } + }; + + return ( +
+
+ Profile Settings +
+
+ + +
+ Avatar +
+ {picture.length ? ( + user's avatar + ) : ( + user's avatar + )} + +
+
+
+ + +
+
+ +