feat: add empty state and polish trending column
This commit is contained in:
@@ -13,7 +13,7 @@ export function RepostNote({
|
|||||||
event: Event;
|
event: Event;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
const { ark, settings } = useRouteContext({ strict: false });
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -104,7 +104,7 @@ export function RepostNote({
|
|||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
<div className="-ml-1 inline-flex items-center gap-4">
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
{settings.zap ? <Note.Zap /> : null}
|
||||||
</div>
|
</div>
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Event } from "@lume/types";
|
import { Event } from "@lume/types";
|
||||||
import { Note } from "@lume/ui";
|
import { Note } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
|
||||||
export function TextNote({
|
export function TextNote({
|
||||||
event,
|
event,
|
||||||
@@ -9,6 +10,8 @@ export function TextNote({
|
|||||||
event: Event;
|
event: Event;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root
|
<Note.Root
|
||||||
@@ -27,7 +30,7 @@ export function TextNote({
|
|||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
<div className="-ml-1 inline-flex items-center gap-4">
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
{settings.zap ? <Note.Zap /> : null}
|
||||||
</div>
|
</div>
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { Suggest } from "@/components/suggest";
|
|
||||||
import { TextNote } from "@/components/text";
|
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 { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
import { Column } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/antenas")({
|
export const Route = createFileRoute("/antenas")({
|
||||||
@@ -23,7 +21,6 @@ export const Route = createFileRoute("/antenas")({
|
|||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, name, account } = Route.useSearch();
|
const { label, name, account } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: [name, account],
|
queryKey: [name, account],
|
||||||
@@ -59,16 +56,7 @@ export function Screen() {
|
|||||||
<LoaderIcon className="size-5 animate-spin" />
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<div className="flex flex-col gap-3">
|
<Empty />
|
||||||
<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}>
|
<Virtualizer overscan={3}>
|
||||||
{data.map((item) => renderItem(item))}
|
{data.map((item) => renderItem(item))}
|
||||||
@@ -97,3 +85,35 @@ export function Screen() {
|
|||||||
</Column.Root>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,13 +7,18 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||||
import { CheckIcon } from "@lume/icons";
|
import { CheckIcon } from "@lume/icons";
|
||||||
|
import { AppRouteSearch } from "@lume/types";
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/backup")({
|
export const Route = createFileRoute("/auth/new/backup")({
|
||||||
|
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
};
|
||||||
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
// @ts-ignore, magic!!!
|
|
||||||
const { account } = Route.useSearch();
|
const { account } = Route.useSearch();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -32,7 +37,7 @@ function Screen() {
|
|||||||
} else {
|
} else {
|
||||||
return navigate({
|
return navigate({
|
||||||
to: "/auth/settings",
|
to: "/auth/settings",
|
||||||
search: { account, new: true },
|
search: { account },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,28 +42,28 @@ function Screen() {
|
|||||||
await requestPermission();
|
await requestPermission();
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
notification: !settings.notification,
|
notification: !newSettings.notification,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAutoUpdate = () => {
|
const toggleAutoUpdate = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
autoUpdate: !settings.autoUpdate,
|
autoUpdate: !newSettings.autoUpdate,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleEnhancedPrivacy = () => {
|
const toggleEnhancedPrivacy = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
enhancedPrivacy: !settings.enhancedPrivacy,
|
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleZap = () => {
|
const toggleZap = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
zap: !settings.zap,
|
zap: !newSettings.zap,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { Suggest } from "@/components/suggest";
|
|
||||||
import { TextNote } from "@/components/text";
|
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 { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
import { Column } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/foryou")({
|
export const Route = createFileRoute("/foryou")({
|
||||||
@@ -41,7 +39,6 @@ export const Route = createFileRoute("/foryou")({
|
|||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, name, account } = Route.useSearch();
|
const { label, name, account } = Route.useSearch();
|
||||||
const { ark, interests } = Route.useRouteContext();
|
const { ark, interests } = Route.useRouteContext();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: [name, account],
|
queryKey: [name, account],
|
||||||
@@ -84,20 +81,7 @@ export function Screen() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<Empty />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<Virtualizer overscan={3}>
|
<Virtualizer overscan={3}>
|
||||||
{data.map((item) => renderItem(item))}
|
{data.map((item) => renderItem(item))}
|
||||||
@@ -127,3 +111,35 @@ export function Screen() {
|
|||||||
</Column.Root>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { Suggest } from "@/components/suggest";
|
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
|
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { Event, Kind } from "@lume/types";
|
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
import { Column } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Virtualizer } from "virtua";
|
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,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
// @ts-ignore, just work!!!
|
const { label, name, account } = Route.useSearch();
|
||||||
const { id, name, account } = Route.useSearch();
|
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: ["global", account],
|
queryKey: ["global", account],
|
||||||
@@ -46,7 +55,7 @@ export function Screen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<Column.Root>
|
||||||
<Column.Header id={id} name={name} />
|
<Column.Header label={label} name={name} />
|
||||||
<Column.Content>
|
<Column.Content>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||||
@@ -55,32 +64,18 @@ export function Screen() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<Empty />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<Virtualizer overscan={3}>
|
<Virtualizer overscan={3}>
|
||||||
{data.map((item) => renderItem(item))}
|
{data.map((item) => renderItem(item))}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data?.length && hasNextPage ? (
|
{data?.length && hasNextPage ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div className="flex h-20 items-center justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
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"
|
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 ? (
|
{isFetchingNextPage ? (
|
||||||
@@ -98,3 +93,35 @@ export function Screen() {
|
|||||||
</Column.Root>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { Suggest } from "@/components/suggest";
|
|
||||||
import { TextNote } from "@/components/text";
|
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 { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
import { Column } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/group")({
|
export const Route = createFileRoute("/group")({
|
||||||
@@ -41,7 +39,6 @@ export const Route = createFileRoute("/group")({
|
|||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, name, account } = Route.useSearch();
|
const { label, name, account } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: [name, account],
|
queryKey: [name, account],
|
||||||
@@ -77,16 +74,7 @@ export function Screen() {
|
|||||||
<LoaderIcon className="size-5 animate-spin" />
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<div className="flex flex-col gap-3">
|
<Empty />
|
||||||
<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}>
|
<Virtualizer overscan={3}>
|
||||||
{data.map((item) => renderItem(item))}
|
{data.map((item) => renderItem(item))}
|
||||||
@@ -115,3 +103,35 @@ export function Screen() {
|
|||||||
</Column.Root>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { Suggest } from "@/components/suggest";
|
import { RepostNote } from "@/components/repost";
|
||||||
import {
|
import { TextNote } from "@/components/text";
|
||||||
LoaderIcon,
|
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
ArrowRightCircleIcon,
|
|
||||||
InfoIcon,
|
|
||||||
RepostIcon,
|
|
||||||
} from "@lume/icons";
|
|
||||||
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
||||||
import { Column, Note, User } from "@lume/ui";
|
import { Column } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/newsfeed")({
|
export const Route = createFileRoute("/newsfeed")({
|
||||||
@@ -33,10 +27,9 @@ export const Route = createFileRoute("/newsfeed")({
|
|||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, name, account } = Route.useSearch();
|
const { label, name, account } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: [name, account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await ark.get_events(20, pageParam);
|
const events = await ark.get_events(20, pageParam);
|
||||||
@@ -71,32 +64,18 @@ export function Screen() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<Empty />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<Virtualizer overscan={3}>
|
<Virtualizer overscan={3}>
|
||||||
{data.map((item) => renderItem(item))}
|
{data.map((item) => renderItem(item))}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data?.length && hasNextPage ? (
|
{data?.length && hasNextPage ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div className="flex h-20 items-center justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
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"
|
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 ? (
|
{isFetchingNextPage ? (
|
||||||
@@ -115,144 +94,41 @@ export function Screen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextNote({ event, className }: { event: Event; className?: string }) {
|
function Empty() {
|
||||||
const { settings } = Route.useRouteContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<div className="flex flex-col py-10 gap-10">
|
||||||
<Note.Root
|
<div className="text-center flex flex-col items-center justify-center">
|
||||||
className={cn(
|
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
|
||||||
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
<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" />
|
||||||
className,
|
</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="/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"
|
||||||
>
|
>
|
||||||
<Note.User />
|
<ArrowRightIcon className="size-5" />
|
||||||
<div className="flex gap-3">
|
Show global newsfeed
|
||||||
<div className="size-11 shrink-0" />
|
</Link>
|
||||||
<div className="min-w-0 flex-1">
|
<Link
|
||||||
<Note.Content className="mb-2" />
|
to="/trending/notes"
|
||||||
<Note.Thread />
|
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"
|
||||||
<div className="mt-4 flex items-center justify-between">
|
>
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
<ArrowRightIcon className="size-5" />
|
||||||
<Note.Reply />
|
Show trending notes
|
||||||
<Note.Repost />
|
</Link>
|
||||||
{settings.zap ? <Note.Zap /> : null}
|
<Link
|
||||||
</div>
|
to="/trending/users"
|
||||||
<Note.Menu />
|
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>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
67
apps/desktop2/src/routes/trending.notes.tsx
Normal file
67
apps/desktop2/src/routes/trending.notes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/desktop2/src/routes/trending.tsx
Normal file
69
apps/desktop2/src/routes/trending.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
apps/desktop2/src/routes/trending.users.tsx
Normal file
71
apps/desktop2/src/routes/trending.users.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -140,6 +140,8 @@ export class Ark {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
let until: string = undefined;
|
let until: string = undefined;
|
||||||
|
let isGlobal = global ?? false;
|
||||||
|
|
||||||
if (asOf && asOf > 0) until = asOf.toString();
|
if (asOf && asOf > 0) until = asOf.toString();
|
||||||
|
|
||||||
const dedup = true;
|
const dedup = true;
|
||||||
@@ -150,7 +152,7 @@ export class Ark {
|
|||||||
limit,
|
limit,
|
||||||
until,
|
until,
|
||||||
contacts,
|
contacts,
|
||||||
global,
|
global: isGlobal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dedup) {
|
if (dedup) {
|
||||||
@@ -175,8 +177,9 @@ export class Ark {
|
|||||||
.sort((a, b) => b.created_at - a.created_at);
|
.sort((a, b) => b.created_at - a.created_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
|
return nostrEvents;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error(String(e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<svg
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth="1.5"
|
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"
|
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"
|
||||||
></path>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,13 @@ export function GroupFeedsIcon(
|
|||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeLinecap="round"
|
strokeLinecap="square"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth="2"
|
strokeWidth="1.5"
|
||||||
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"
|
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>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
{
|
{
|
||||||
"label": "gxtcIbgD8YNPbeI5o92I8",
|
"label": "gxtcIbgD8YNPbeI5o92I8",
|
||||||
"name": "Trending",
|
"name": "Trending",
|
||||||
"content": "/trending",
|
"content": "/trending/notes",
|
||||||
"logo": "",
|
"logo": "",
|
||||||
"cover": "/trending.png",
|
"cover": "/trending.png",
|
||||||
"coverRetina": "/trending@2x.png",
|
"coverRetina": "/trending@2x.png",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ pub async fn get_events(
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
until: Option<&str>,
|
until: Option<&str>,
|
||||||
contacts: Option<Vec<&str>>,
|
contacts: Option<Vec<&str>>,
|
||||||
global: Option<bool>,
|
global: bool,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<Vec<Event>, String> {
|
) -> Result<Vec<Event>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
@@ -84,6 +84,25 @@ pub async fn get_events(
|
|||||||
Some(until) => Timestamp::from_str(until).unwrap(),
|
Some(until) => Timestamp::from_str(until).unwrap(),
|
||||||
None => Timestamp::now(),
|
None => Timestamp::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false => {
|
||||||
let authors = match contacts {
|
let authors = match contacts {
|
||||||
Some(val) => {
|
Some(val) => {
|
||||||
let c: Vec<PublicKey> = val
|
let c: Vec<PublicKey> = val
|
||||||
@@ -92,19 +111,6 @@ pub async fn get_events(
|
|||||||
.collect();
|
.collect();
|
||||||
Some(c)
|
Some(c)
|
||||||
}
|
}
|
||||||
None => match global {
|
|
||||||
Some(val) => match val {
|
|
||||||
true => None,
|
|
||||||
false => {
|
|
||||||
match client
|
|
||||||
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(val) => Some(val),
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
None => {
|
||||||
match client
|
match client
|
||||||
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
|
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
|
||||||
@@ -114,28 +120,33 @@ pub async fn get_events(
|
|||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
};
|
};
|
||||||
let filter = match authors {
|
|
||||||
Some(val) => Filter::new()
|
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])
|
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||||
|
.limit(limit)
|
||||||
.authors(val)
|
.authors(val)
|
||||||
.limit(limit)
|
.until(as_of);
|
||||||
.until(as_of),
|
|
||||||
None => Filter::new()
|
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
|
||||||
.limit(limit)
|
|
||||||
.until(as_of),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(events) = client
|
if let Ok(events) = client
|
||||||
.get_events_of(vec![filter], Some(Duration::from_secs(15)))
|
.get_events_of(vec![filter], Some(Duration::from_secs(15)))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
println!("total events: {}", events.len());
|
println!("total local events: {}", events.len());
|
||||||
Ok(events)
|
Ok(events)
|
||||||
} else {
|
} else {
|
||||||
Err("Get text event failed".into())
|
Err("Get events failed".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Err("Get local events but contact list is empty".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user