feat: add empty state and polish trending column

This commit is contained in:
reya 2024-04-13 08:30:58 +07:00
parent 35cf0abda4
commit a14aeaeb55
19 changed files with 505 additions and 456 deletions

View File

@ -13,7 +13,7 @@ export function RepostNote({
event: Event;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const { ark, settings } = useRouteContext({ strict: false });
const { t } = useTranslation();
const {
isLoading,
@ -104,7 +104,7 @@ export function RepostNote({
<div className="-ml-1 inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
{settings.zap ? <Note.Zap /> : null}
</div>
<Note.Menu />
</div>

View File

@ -1,56 +0,0 @@
import { LoaderIcon } from "@lume/icons";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { User } from "@lume/ui";
export function Suggest() {
const { t } = useTranslation();
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();
},
});
return (
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
<div className="h-10 shrink-0 text-lg font-semibold">
Suggested Follows
</div>
{isLoading ? (
<div className="flex h-44 w-full items-center justify-center">
<LoaderIcon className="size-4 animate-spin" />
</div>
) : isError ? (
<div className="flex h-44 w-full items-center justify-center">
{t("suggestion.error")}
</div>
) : (
data?.profiles.map((item: { pubkey: string }) => (
<div key={item.pubkey} className="h-max w-full overflow-hidden py-5">
<User.Provider pubkey={item.pubkey}>
<User.Root>
<div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-full" />
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
</div>
<User.Button className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800" />
</div>
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
</div>
</User.Root>
</User.Provider>
</div>
))
)}
</div>
);
}

View File

@ -1,6 +1,7 @@
import { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
export function TextNote({
event,
@ -9,6 +10,8 @@ export function TextNote({
event: Event;
className?: string;
}) {
const { settings } = useRouteContext({ strict: false });
return (
<Note.Provider event={event}>
<Note.Root
@ -27,7 +30,7 @@ export function TextNote({
<div className="-ml-1 inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
{settings.zap ? <Note.Zap /> : null}
</div>
<Note.Menu />
</div>

View File

@ -1,12 +1,10 @@
import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text";
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
import { Column } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/antenas")({
@ -23,7 +21,6 @@ export const Route = createFileRoute("/antenas")({
export function Screen() {
const { label, name, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const { t } = useTranslation();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: [name, account],
@ -59,16 +56,7 @@ export function Screen() {
<LoaderIcon className="size-5 animate-spin" />
</div>
) : !data.length ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
<InfoIcon className="size-6" />
<div>
<p className="leading-tight">{t("emptyFeedTitle")}</p>
<p className="leading-tight">{t("emptyFeedSubtitle")}</p>
</div>
</div>
<Suggest />
</div>
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
@ -97,3 +85,35 @@ export function Screen() {
</Column.Root>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@ -7,13 +7,18 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import * as Checkbox from "@radix-ui/react-checkbox";
import { CheckIcon } from "@lume/icons";
import { AppRouteSearch } from "@lume/types";
export const Route = createFileRoute("/auth/new/backup")({
validateSearch: (search: Record<string, string>): AppRouteSearch => {
return {
account: search.account,
};
},
component: Screen,
});
function Screen() {
// @ts-ignore, magic!!!
const { account } = Route.useSearch();
const { t } = useTranslation();
@ -32,7 +37,7 @@ function Screen() {
} else {
return navigate({
to: "/auth/settings",
search: { account, new: true },
search: { account },
});
}
}

View File

@ -42,28 +42,28 @@ function Screen() {
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !settings.notification,
notification: !newSettings.notification,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !settings.autoUpdate,
autoUpdate: !newSettings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
...prev,
enhancedPrivacy: !settings.enhancedPrivacy,
enhancedPrivacy: !newSettings.enhancedPrivacy,
}));
};
const toggleZap = () => {
setNewSettings((prev) => ({
...prev,
zap: !settings.zap,
zap: !newSettings.zap,
}));
};

View File

@ -1,12 +1,10 @@
import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text";
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
import { Column } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/foryou")({
@ -41,7 +39,6 @@ export const Route = createFileRoute("/foryou")({
export function Screen() {
const { label, name, account } = Route.useSearch();
const { ark, interests } = Route.useRouteContext();
const { t } = useTranslation();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: [name, account],
@ -84,20 +81,7 @@ export function Screen() {
</button>
</div>
) : !data.length ? (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-5 dark:bg-neutral-900">
<InfoIcon className="size-6" />
<div>
<p className="font-medium leading-tight">
{t("global.emptyFeedTitle")}
</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
{t("global.emptyFeedSubtitle")}
</p>
</div>
</div>
<Suggest />
</div>
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
@ -127,3 +111,35 @@ export function Screen() {
</Column.Root>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@ -1,23 +1,32 @@
import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text";
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
import { Column } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/global")({
export const Route = createFileRoute("/global")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
export function Screen() {
// @ts-ignore, just work!!!
const { id, name, account } = Route.useSearch();
const { label, name, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const { t } = useTranslation();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["global", account],
@ -46,7 +55,7 @@ export function Screen() {
return (
<Column.Root>
<Column.Header id={id} name={name} />
<Column.Header label={label} name={name} />
<Column.Content>
{isLoading ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
@ -55,32 +64,18 @@ export function Screen() {
</button>
</div>
) : !data.length ? (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-5 dark:bg-neutral-900">
<InfoIcon className="size-6" />
<div>
<p className="font-medium leading-tight">
{t("global.emptyFeedTitle")}
</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
{t("global.emptyFeedSubtitle")}
</p>
</div>
</div>
<Suggest />
</div>
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div className="flex h-20 items-center justify-center">
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isFetchingNextPage}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
@ -98,3 +93,35 @@ export function Screen() {
</Column.Root>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@ -1,12 +1,10 @@
import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text";
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
import { Column } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/group")({
@ -41,7 +39,6 @@ export const Route = createFileRoute("/group")({
export function Screen() {
const { label, name, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const { t } = useTranslation();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: [name, account],
@ -77,16 +74,7 @@ export function Screen() {
<LoaderIcon className="size-5 animate-spin" />
</div>
) : !data.length ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
<InfoIcon className="size-6" />
<div>
<p className="leading-tight">{t("emptyFeedTitle")}</p>
<p className="leading-tight">{t("emptyFeedSubtitle")}</p>
</div>
</div>
<Suggest />
</div>
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
@ -115,3 +103,35 @@ export function Screen() {
</Column.Root>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@ -1,16 +1,10 @@
import { Suggest } from "@/components/suggest";
import {
LoaderIcon,
ArrowRightCircleIcon,
InfoIcon,
RepostIcon,
} from "@lume/icons";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
import { Column, Note, User } from "@lume/ui";
import { cn } from "@lume/utils";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { Column } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/newsfeed")({
@ -33,10 +27,9 @@ export const Route = createFileRoute("/newsfeed")({
export function Screen() {
const { label, name, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const { t } = useTranslation();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: [name, account],
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(20, pageParam);
@ -71,32 +64,18 @@ export function Screen() {
</button>
</div>
) : !data.length ? (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-5 dark:bg-neutral-900">
<InfoIcon className="size-6" />
<div>
<p className="font-medium leading-tight">
{t("global.emptyFeedTitle")}
</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
{t("global.emptyFeedSubtitle")}
</p>
</div>
</div>
<Suggest />
</div>
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div className="flex h-20 items-center justify-center">
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isFetchingNextPage}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
@ -115,144 +94,41 @@ export function Screen() {
);
}
function TextNote({ event, className }: { event: Event; className?: string }) {
const { settings } = Route.useRouteContext();
function Empty() {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
className,
)}
>
<Note.User />
<div className="flex gap-3">
<div className="size-11 shrink-0" />
<div className="min-w-0 flex-1">
<Note.Content className="mb-2" />
<Note.Thread />
<div className="mt-4 flex items-center justify-between">
<div className="-ml-1 inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
{settings.zap ? <Note.Zap /> : null}
</div>
<Note.Menu />
</div>
</div>
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
</Note.Root>
</Note.Provider>
);
}
function RepostNote({
event,
className,
}: {
event: Event;
className?: string;
}) {
const { ark, settings } = Route.useRouteContext();
const { t } = useTranslation();
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed: Event = JSON.parse(event.content);
return embed;
}
const id = event.tags.find((el) => el[0] === "e")[1];
return await ark.get_event(id);
} catch {
throw new Error("Failed to get repost event");
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
});
if (isLoading) {
return <div className="w-full px-3 pb-3">Loading...</div>;
}
if (isError || !repostEvent) {
return (
<Note.Root
className={cn(
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex h-14 gap-2 px-3">
<div className="inline-flex w-10 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<div className="mb-3 select-text px-3">
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
<p className="text-red-500">Failed to get event</p>
</div>
</div>
</Note.Root>
);
}
return (
<Note.Root
className={cn(
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex gap-3">
<div className="inline-flex w-11 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<Note.Provider event={repostEvent}>
<div className="flex flex-col gap-2">
<Note.User />
<div className="flex gap-3">
<div className="size-11 shrink-0" />
<div className="min-w-0 flex-1">
<Note.Content />
<div className="mt-4 flex items-center justify-between">
<div className="-ml-1 inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
{settings.zap ? <Note.Zap /> : null}
</div>
<Note.Menu />
</div>
</div>
</div>
</div>
</Note.Provider>
</Note.Root>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/global"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show global newsfeed
</Link>
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@ -1,71 +0,0 @@
import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text";
import { LoaderIcon, InfoIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { Column } from "@lume/ui";
import { useQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { Virtualizer } from "virtua";
import { fetch } from "@tauri-apps/plugin-http";
export const Route = createLazyFileRoute("/trending")({
component: Screen,
});
export function Screen() {
// @ts-ignore, just work!!!
const { id, name, account } = Route.useSearch();
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["trending", account],
queryFn: async () => {
const res = await fetch("https://api.nostr.band/v0/trending/notes");
const data = await res.json();
const events = data.notes.map((item) => item.event) as Event[];
return events.sort((a, b) => b.created_at - a.created_at);
},
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<Column.Root>
<Column.Header id={id} name={name} />
<Column.Content>
{isLoading ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button type="button" className="size-5" disabled>
<LoaderIcon className="size-5 animate-spin" />
</button>
</div>
) : !data.length ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
<InfoIcon className="size-6" />
<div>
<p className="leading-tight">{t("emptyFeedTitle")}</p>
<p className="leading-tight">{t("emptyFeedSubtitle")}</p>
</div>
</div>
<Suggest />
</div>
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
</Column.Content>
</Column.Root>
);
}

View File

@ -0,0 +1,67 @@
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { LoaderIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { Await, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
import { fetch } from "@tauri-apps/plugin-http";
import { defer } from "@tanstack/react-router";
import { Suspense } from "react";
export const Route = createFileRoute("/trending/notes")({
loader: async ({ abortController }) => {
try {
return {
data: defer(
fetch("https://api.nostr.band/v0/trending/notes", {
signal: abortController.signal,
})
.then((res) => res.json())
.then((res) => res.notes.map((item) => item.event) as Event[]),
),
};
} catch (e) {
throw new Error(String(e));
}
},
component: Screen,
});
export function Screen() {
const { data } = Route.useLoaderData();
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="w-full h-full">
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<LoaderIcon className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(notes) => notes.map((event) => renderItem(event))}
</Await>
</Suspense>
</Virtualizer>
</div>
);
}

View File

@ -0,0 +1,69 @@
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
import { ColumnRouteSearch } from "@lume/types";
import { Column } from "@lume/ui";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/trending")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
export function Screen() {
const { label, name } = Route.useSearch();
return (
<Column.Root>
<Column.Header label={label} name={name}>
<div className="inline-flex h-full w-full items-center gap-1">
<Link to="/trending/notes">
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive
? "bg-neutral-100 dark:bg-neutral-900"
: "opacity-50",
)}
>
<ArticleIcon className="size-4" />
Notes
</div>
)}
</Link>
<Link to="/trending/users">
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive
? "bg-neutral-100 dark:bg-neutral-900"
: "opacity-50",
)}
>
<GroupFeedsIcon className="size-4" />
Users
</div>
)}
</Link>
</div>
</Column.Header>
<Column.Content>
<Outlet />
</Column.Content>
</Column.Root>
);
}

View File

@ -0,0 +1,71 @@
import { LoaderIcon } from "@lume/icons";
import { User } from "@lume/ui";
import { Await, defer } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
export const Route = createFileRoute("/trending/users")({
loader: async ({ abortController }) => {
try {
return {
data: defer(
fetch("https://api.nostr.band/v0/trending/profiles", {
signal: abortController.signal,
}).then((res) => res.json()),
),
};
} catch (e) {
throw new Error(String(e));
}
},
component: Screen,
});
export function Screen() {
const { data } = Route.useLoaderData();
return (
<div className="w-full h-full px-3">
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<LoaderIcon className="size-5 animate-spin" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(users) =>
users.profiles.map((item) => (
<div
key={item.pubkey}
className="h-max w-full overflow-hidden py-5 border-b border-neutral-100 dark:border-neutral-900"
>
<User.Provider pubkey={item.pubkey}>
<User.Root>
<div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
</div>
<User.Button className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800" />
</div>
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</div>
);
}

View File

@ -140,6 +140,8 @@ export class Ark {
) {
try {
let until: string = undefined;
let isGlobal = global ?? false;
if (asOf && asOf > 0) until = asOf.toString();
const dedup = true;
@ -150,7 +152,7 @@ export class Ark {
limit,
until,
contacts,
global,
global: isGlobal,
});
if (dedup) {
@ -175,8 +177,9 @@ export class Ark {
.sort((a, b) => b.created_at - a.created_at);
}
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
} catch {
return nostrEvents;
} catch (e) {
console.error(String(e));
return [];
}
}

View File

@ -1,22 +1,17 @@
import { SVGProps } from 'react';
import { SVGProps } from "react";
export function ArticleIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function ArticleIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M16.25 12V4.75a1 1 0 00-1-1H3.75a1 1 0 00-1 1v13a2.5 2.5 0 002.5 2.5H18.5M16.25 12v5.75a2.5 2.5 0 005 0V13a1 1 0 00-1-1h-4zm-9.5 3.75h5.5m-5.5-8h5.5v4.5h-5.5v-4.5z"
></path>
d="M20.248 15.25H17.25a2 2 0 0 0-2 2v2.998m4.998-4.998c.002-.026.002-.052.002-.078V5.75a2 2 0 0 0-2-2H5.75a2 2 0 0 0-2 2v12.5a2 2 0 0 0 2 2h9.422c.026 0 .052 0 .078-.002m4.998-4.998a2 2 0 0 1-.584 1.336l-3.078 3.078a2 2 0 0 1-1.336.584M8.75 8.75h6.5m-6.5 4h2.5"
/>
</svg>
);
}

View File

@ -1,24 +1,17 @@
import { SVGProps } from "react";
export function GroupFeedsIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7.328 8.191a2.596 2.596 0 110-5.191 2.596 2.596 0 010 5.191zm0 0c1.932 0 3.639.959 4.673 2.426a5.704 5.704 0 014.672-2.426m-9.345 0a5.704 5.704 0 00-4.672 2.426m14.017-2.426a2.596 2.596 0 110-5.191 2.596 2.596 0 010 5.191zm0 0c1.93 0 3.638.959 4.672 2.426M7.328 18.575a2.596 2.596 0 110-5.192 2.596 2.596 0 010 5.192zm0 0c1.932 0 3.639.958 4.673 2.426a5.704 5.704 0 014.672-2.426m-9.345 0A5.704 5.704 0 002.656 21m14.017-2.426a2.596 2.596 0 110-5.192 2.596 2.596 0 010 5.192zm0 0c1.93 0 3.638.958 4.672 2.426"
/>
</svg>
);
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="square"
strokeLinejoin="round"
strokeWidth="1.5"
d="M17.25 6.75v-2a2 2 0 0 0-2-2H4.75a2 2 0 0 0-2 2v10.5a2 2 0 0 0 2 2h2m2.576 4c.461-2.286 2.379-4 4.674-4 2.295 0 4.213 1.714 4.674 4m-9.348 0H8.75a2 2 0 0 1-2-2V8.75a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v10.5a2 2 0 0 1-2 2h-.576m-9.348 0h9.348M16.25 12.5a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"
/>
</svg>
);
}

View File

@ -32,7 +32,7 @@
{
"label": "gxtcIbgD8YNPbeI5o92I8",
"name": "Trending",
"content": "/trending",
"content": "/trending/notes",
"logo": "",
"cover": "/trending.png",
"coverRetina": "/trending@2x.png",

View File

@ -76,7 +76,7 @@ pub async fn get_events(
limit: usize,
until: Option<&str>,
contacts: Option<Vec<&str>>,
global: Option<bool>,
global: bool,
state: State<'_, Nostr>,
) -> Result<Vec<Event>, String> {
let client = &state.client;
@ -84,18 +84,34 @@ pub async fn get_events(
Some(until) => Timestamp::from_str(until).unwrap(),
None => Timestamp::now(),
};
let authors = match contacts {
Some(val) => {
let c: Vec<PublicKey> = val
.into_iter()
.map(|key| PublicKey::from_str(key).unwrap())
.collect();
Some(c)
match global {
true => {
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(limit)
.until(as_of);
if let Ok(events) = client
.get_events_of(vec![filter], Some(Duration::from_secs(15)))
.await
{
println!("total global events: {}", events.len());
Ok(events)
} else {
Err("Get events failed".into())
}
}
None => match global {
Some(val) => match val {
true => None,
false => {
false => {
let authors = match contacts {
Some(val) => {
let c: Vec<PublicKey> = val
.into_iter()
.map(|key| PublicKey::from_str(key).unwrap())
.collect();
Some(c)
}
None => {
match client
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
.await
@ -104,38 +120,33 @@ pub async fn get_events(
Err(_) => None,
}
}
},
None => {
match client
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
.await
{
Ok(val) => Some(val),
Err(_) => None,
}
}
},
};
let filter = match authors {
Some(val) => Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(val)
.limit(limit)
.until(as_of),
None => Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(limit)
.until(as_of),
};
};
if let Ok(events) = client
.get_events_of(vec![filter], Some(Duration::from_secs(15)))
.await
{
println!("total events: {}", events.len());
Ok(events)
} else {
Err("Get text event failed".into())
match authors {
Some(val) => {
if val.is_empty() {
Err("Get local events but contact list is empty".into())
} else {
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(limit)
.authors(val)
.until(as_of);
if let Ok(events) = client
.get_events_of(vec![filter], Some(Duration::from_secs(15)))
.await
{
println!("total local events: {}", events.len());
Ok(events)
} else {
Err("Get events failed".into())
}
}
}
None => Err("Get local events but contact list is empty".into()),
}
}
}
}