feat: add discover newsfeeds and interests
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 63 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 984 B |
Before Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 276 KiB |
Before Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 12 KiB |
@ -5,7 +5,7 @@ use specta::Type;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use tauri::{Emitter, Manager, State};
|
||||
|
||||
use crate::{common::process_event, Nostr, RichEvent};
|
||||
use crate::{common::process_event, Nostr, RichEvent, FETCH_LIMIT};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
pub struct Mention {
|
||||
@ -259,6 +259,35 @@ pub async fn get_all_newsfeeds(
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_local_newsfeeds(
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::FollowSet)
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
let events = client
|
||||
.database()
|
||||
.query(vec![filter])
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_interest(
|
||||
@ -361,6 +390,35 @@ pub async fn get_all_interests(
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_local_interests(
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::Interests, Kind::InterestSet])
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
let events = client
|
||||
.database()
|
||||
.query(vec![filter])
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let alt_events = process_event(client, events, false).await;
|
||||
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_all_profiles(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
|
||||
|
@ -97,9 +97,11 @@ fn main() {
|
||||
set_group,
|
||||
get_group,
|
||||
get_all_newsfeeds,
|
||||
get_all_local_newsfeeds,
|
||||
set_interest,
|
||||
get_interest,
|
||||
get_all_interests,
|
||||
get_all_local_interests,
|
||||
set_wallet,
|
||||
load_wallet,
|
||||
remove_wallet,
|
||||
|
@ -208,6 +208,14 @@ async getAllNewsfeeds(id: string) : Promise<Result<RichEvent[], string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getAllLocalNewsfeeds(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_all_local_newsfeeds", { until }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async setInterest(title: string, description: string | null, image: string | null, hashtags: string[]) : Promise<Result<string, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("set_interest", { title, description, image, hashtags }) };
|
||||
@ -232,6 +240,14 @@ async getAllInterests(id: string) : Promise<Result<RichEvent[], string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getAllLocalInterests(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_all_local_interests", { until }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async setWallet(uri: string) : Promise<Result<boolean, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("set_wallet", { uri }) };
|
||||
|
@ -51,6 +51,12 @@ const ColumnsLayoutSearchLazyImport = createFileRoute(
|
||||
const ColumnsLayoutOnboardingLazyImport = createFileRoute(
|
||||
'/columns/_layout/onboarding',
|
||||
)()
|
||||
const ColumnsLayoutDiscoverNewsfeedsLazyImport = createFileRoute(
|
||||
'/columns/_layout/discover-newsfeeds',
|
||||
)()
|
||||
const ColumnsLayoutDiscoverInterestsLazyImport = createFileRoute(
|
||||
'/columns/_layout/discover-interests',
|
||||
)()
|
||||
const ColumnsLayoutUsersIdLazyImport = createFileRoute(
|
||||
'/columns/_layout/users/$id',
|
||||
)()
|
||||
@ -196,6 +202,28 @@ const ColumnsLayoutOnboardingLazyRoute =
|
||||
import('./routes/columns/_layout/onboarding.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
const ColumnsLayoutDiscoverNewsfeedsLazyRoute =
|
||||
ColumnsLayoutDiscoverNewsfeedsLazyImport.update({
|
||||
id: '/discover-newsfeeds',
|
||||
path: '/discover-newsfeeds',
|
||||
getParentRoute: () => ColumnsLayoutRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/columns/_layout/discover-newsfeeds.lazy').then(
|
||||
(d) => d.Route,
|
||||
),
|
||||
)
|
||||
|
||||
const ColumnsLayoutDiscoverInterestsLazyRoute =
|
||||
ColumnsLayoutDiscoverInterestsLazyImport.update({
|
||||
id: '/discover-interests',
|
||||
path: '/discover-interests',
|
||||
getParentRoute: () => ColumnsLayoutRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/columns/_layout/discover-interests.lazy').then(
|
||||
(d) => d.Route,
|
||||
),
|
||||
)
|
||||
|
||||
const SettingsIdWalletRoute = SettingsIdWalletImport.update({
|
||||
id: '/wallet',
|
||||
path: '/wallet',
|
||||
@ -469,6 +497,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsIdWalletImport
|
||||
parentRoute: typeof SettingsIdLazyImport
|
||||
}
|
||||
'/columns/_layout/discover-interests': {
|
||||
id: '/columns/_layout/discover-interests'
|
||||
path: '/discover-interests'
|
||||
fullPath: '/columns/discover-interests'
|
||||
preLoaderRoute: typeof ColumnsLayoutDiscoverInterestsLazyImport
|
||||
parentRoute: typeof ColumnsLayoutImport
|
||||
}
|
||||
'/columns/_layout/discover-newsfeeds': {
|
||||
id: '/columns/_layout/discover-newsfeeds'
|
||||
path: '/discover-newsfeeds'
|
||||
fullPath: '/columns/discover-newsfeeds'
|
||||
preLoaderRoute: typeof ColumnsLayoutDiscoverNewsfeedsLazyImport
|
||||
parentRoute: typeof ColumnsLayoutImport
|
||||
}
|
||||
'/columns/_layout/onboarding': {
|
||||
id: '/columns/_layout/onboarding'
|
||||
path: '/onboarding'
|
||||
@ -602,6 +644,8 @@ const ColumnsLayoutCreateNewsfeedRouteWithChildren =
|
||||
interface ColumnsLayoutRouteChildren {
|
||||
ColumnsLayoutCreateNewsfeedRoute: typeof ColumnsLayoutCreateNewsfeedRouteWithChildren
|
||||
ColumnsLayoutGlobalRoute: typeof ColumnsLayoutGlobalRoute
|
||||
ColumnsLayoutDiscoverInterestsLazyRoute: typeof ColumnsLayoutDiscoverInterestsLazyRoute
|
||||
ColumnsLayoutDiscoverNewsfeedsLazyRoute: typeof ColumnsLayoutDiscoverNewsfeedsLazyRoute
|
||||
ColumnsLayoutOnboardingLazyRoute: typeof ColumnsLayoutOnboardingLazyRoute
|
||||
ColumnsLayoutSearchLazyRoute: typeof ColumnsLayoutSearchLazyRoute
|
||||
ColumnsLayoutTrendingLazyRoute: typeof ColumnsLayoutTrendingLazyRoute
|
||||
@ -620,6 +664,10 @@ const ColumnsLayoutRouteChildren: ColumnsLayoutRouteChildren = {
|
||||
ColumnsLayoutCreateNewsfeedRoute:
|
||||
ColumnsLayoutCreateNewsfeedRouteWithChildren,
|
||||
ColumnsLayoutGlobalRoute: ColumnsLayoutGlobalRoute,
|
||||
ColumnsLayoutDiscoverInterestsLazyRoute:
|
||||
ColumnsLayoutDiscoverInterestsLazyRoute,
|
||||
ColumnsLayoutDiscoverNewsfeedsLazyRoute:
|
||||
ColumnsLayoutDiscoverNewsfeedsLazyRoute,
|
||||
ColumnsLayoutOnboardingLazyRoute: ColumnsLayoutOnboardingLazyRoute,
|
||||
ColumnsLayoutSearchLazyRoute: ColumnsLayoutSearchLazyRoute,
|
||||
ColumnsLayoutTrendingLazyRoute: ColumnsLayoutTrendingLazyRoute,
|
||||
@ -685,6 +733,8 @@ export interface FileRoutesByFullPath {
|
||||
'/settings/$id/general': typeof SettingsIdGeneralRoute
|
||||
'/settings/$id/relay': typeof SettingsIdRelayRoute
|
||||
'/settings/$id/wallet': typeof SettingsIdWalletRoute
|
||||
'/columns/discover-interests': typeof ColumnsLayoutDiscoverInterestsLazyRoute
|
||||
'/columns/discover-newsfeeds': typeof ColumnsLayoutDiscoverNewsfeedsLazyRoute
|
||||
'/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
|
||||
'/columns/search': typeof ColumnsLayoutSearchLazyRoute
|
||||
'/columns/trending': typeof ColumnsLayoutTrendingLazyRoute
|
||||
@ -720,6 +770,8 @@ export interface FileRoutesByTo {
|
||||
'/settings/$id/general': typeof SettingsIdGeneralRoute
|
||||
'/settings/$id/relay': typeof SettingsIdRelayRoute
|
||||
'/settings/$id/wallet': typeof SettingsIdWalletRoute
|
||||
'/columns/discover-interests': typeof ColumnsLayoutDiscoverInterestsLazyRoute
|
||||
'/columns/discover-newsfeeds': typeof ColumnsLayoutDiscoverNewsfeedsLazyRoute
|
||||
'/columns/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
|
||||
'/columns/search': typeof ColumnsLayoutSearchLazyRoute
|
||||
'/columns/trending': typeof ColumnsLayoutTrendingLazyRoute
|
||||
@ -758,6 +810,8 @@ export interface FileRoutesById {
|
||||
'/settings/$id/general': typeof SettingsIdGeneralRoute
|
||||
'/settings/$id/relay': typeof SettingsIdRelayRoute
|
||||
'/settings/$id/wallet': typeof SettingsIdWalletRoute
|
||||
'/columns/_layout/discover-interests': typeof ColumnsLayoutDiscoverInterestsLazyRoute
|
||||
'/columns/_layout/discover-newsfeeds': typeof ColumnsLayoutDiscoverNewsfeedsLazyRoute
|
||||
'/columns/_layout/onboarding': typeof ColumnsLayoutOnboardingLazyRoute
|
||||
'/columns/_layout/search': typeof ColumnsLayoutSearchLazyRoute
|
||||
'/columns/_layout/trending': typeof ColumnsLayoutTrendingLazyRoute
|
||||
@ -796,6 +850,8 @@ export interface FileRouteTypes {
|
||||
| '/settings/$id/general'
|
||||
| '/settings/$id/relay'
|
||||
| '/settings/$id/wallet'
|
||||
| '/columns/discover-interests'
|
||||
| '/columns/discover-newsfeeds'
|
||||
| '/columns/onboarding'
|
||||
| '/columns/search'
|
||||
| '/columns/trending'
|
||||
@ -830,6 +886,8 @@ export interface FileRouteTypes {
|
||||
| '/settings/$id/general'
|
||||
| '/settings/$id/relay'
|
||||
| '/settings/$id/wallet'
|
||||
| '/columns/discover-interests'
|
||||
| '/columns/discover-newsfeeds'
|
||||
| '/columns/onboarding'
|
||||
| '/columns/search'
|
||||
| '/columns/trending'
|
||||
@ -866,6 +924,8 @@ export interface FileRouteTypes {
|
||||
| '/settings/$id/general'
|
||||
| '/settings/$id/relay'
|
||||
| '/settings/$id/wallet'
|
||||
| '/columns/_layout/discover-interests'
|
||||
| '/columns/_layout/discover-newsfeeds'
|
||||
| '/columns/_layout/onboarding'
|
||||
| '/columns/_layout/search'
|
||||
| '/columns/_layout/trending'
|
||||
@ -975,6 +1035,8 @@ export const routeTree = rootRoute
|
||||
"children": [
|
||||
"/columns/_layout/create-newsfeed",
|
||||
"/columns/_layout/global",
|
||||
"/columns/_layout/discover-interests",
|
||||
"/columns/_layout/discover-newsfeeds",
|
||||
"/columns/_layout/onboarding",
|
||||
"/columns/_layout/search",
|
||||
"/columns/_layout/trending",
|
||||
@ -1040,6 +1102,14 @@ export const routeTree = rootRoute
|
||||
"filePath": "settings.$id/wallet.tsx",
|
||||
"parent": "/settings/$id"
|
||||
},
|
||||
"/columns/_layout/discover-interests": {
|
||||
"filePath": "columns/_layout/discover-interests.lazy.tsx",
|
||||
"parent": "/columns/_layout"
|
||||
},
|
||||
"/columns/_layout/discover-newsfeeds": {
|
||||
"filePath": "columns/_layout/discover-newsfeeds.lazy.tsx",
|
||||
"parent": "/columns/_layout"
|
||||
},
|
||||
"/columns/_layout/onboarding": {
|
||||
"filePath": "columns/_layout/onboarding.lazy.tsx",
|
||||
"parent": "/columns/_layout"
|
||||
|
182
src/routes/columns/_layout/discover-interests.lazy.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Spinner, User } from "@/components";
|
||||
import { LumeWindow } from "@/system";
|
||||
import { ArrowDown } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { type RefObject, useCallback, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/discover-interests")(
|
||||
{
|
||||
component: Screen,
|
||||
},
|
||||
);
|
||||
|
||||
function Screen() {
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
data,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["local-interests"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const until = pageParam > 0 ? pageParam.toString() : null;
|
||||
const res = await commands.getAllLocalInterests(until);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const data = toLumeEvents(res.data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
|
||||
if (lastEvent) {
|
||||
return lastEvent.created_at - 1;
|
||||
}
|
||||
},
|
||||
select: (data) =>
|
||||
data?.pages
|
||||
.flat()
|
||||
.filter(
|
||||
(item) => item.tags.filter((tag) => tag[0] === "p")?.length > 0,
|
||||
),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: NostrEvent) => {
|
||||
const name =
|
||||
item.tags.find((tag) => tag[0] === "title")?.[1] || "Unnamed";
|
||||
const label =
|
||||
item.tags.find((tag) => tag[0] === "label")?.[1] || nanoid();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex flex-col rounded-xl overflow-hidden bg-white dark:bg-neutral-800/50 shadow-lg shadow-primary dark:ring-1 dark:ring-neutral-800"
|
||||
>
|
||||
<div className="px-2 pt-2">
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport className="p-3 h-16 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{item.tags
|
||||
.filter((tag) => tag[0] === "t")
|
||||
.map((tag) => (
|
||||
<div key={tag[1]} className="text-sm font-medium">
|
||||
{tag[1].includes("#") ? tag[1] : `#${tag[1]}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
<div className="p-3 flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Provider pubkey={item.pubkey}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<h5 className="text-xs font-medium">{name}</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
LumeWindow.openColumn({
|
||||
label,
|
||||
name,
|
||||
url: `/columns/interests/${item.id}`,
|
||||
})
|
||||
}
|
||||
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-neutral-100 group-hover:bg-blue-600 dark:group-hover:bg-blue-400 group-hover:text-white"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
|
||||
<Virtualizer scrollRef={ref as unknown as RefObject<HTMLElement>}>
|
||||
{isLoading ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<Spinner className="size-4" />
|
||||
Loading...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="mb-3 flex flex-col items-center justify-center h-16 w-full rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50">
|
||||
<p className="text-center">{error?.message ?? "Error"}</p>
|
||||
</div>
|
||||
) : !data?.length ? (
|
||||
<div className="mb-3 flex flex-col items-center justify-center h-16 w-full rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50">
|
||||
<p className="text-center">Empty.</p>
|
||||
</div>
|
||||
) : (
|
||||
data?.map((item) => renderItem(item))
|
||||
)}
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="h-11 w-full px-3 flex items-center justify-center gap-1.5 bg-neutral-200/50 hover:bg-neutral-200 rounded-full text-sm font-medium text-blue-600 dark:hover:bg-neutral-800 dark:bg-neutral-800/50 dark:text-blue-400"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowDown className="size-4" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
184
src/routes/columns/_layout/discover-newsfeeds.lazy.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Spinner, User } from "@/components";
|
||||
import { LumeWindow } from "@/system";
|
||||
import { ArrowDown } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { type RefObject, useCallback, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/discover-newsfeeds")(
|
||||
{
|
||||
component: Screen,
|
||||
},
|
||||
);
|
||||
|
||||
function Screen() {
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
data,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["local-newsfeeds"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const until = pageParam > 0 ? pageParam.toString() : null;
|
||||
const res = await commands.getAllLocalNewsfeeds(until);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const data = toLumeEvents(res.data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
|
||||
if (lastEvent) {
|
||||
return lastEvent.created_at - 1;
|
||||
}
|
||||
},
|
||||
select: (data) =>
|
||||
data?.pages
|
||||
.flat()
|
||||
.filter(
|
||||
(item) => item.tags.filter((tag) => tag[0] === "p")?.length > 0,
|
||||
),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: NostrEvent) => {
|
||||
const name =
|
||||
item.tags.find((tag) => tag[0] === "title")?.[1] || "Unnamed";
|
||||
|
||||
const label = item.tags.find((tag) => tag[0] === "d")?.[1] || nanoid();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="mb-3 group flex flex-col rounded-xl overflow-hidden bg-white dark:bg-neutral-800/50 shadow-lg shadow-primary dark:ring-1 dark:ring-neutral-800"
|
||||
>
|
||||
<div className="px-2 pt-2">
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport className="p-3 h-16 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{item.tags
|
||||
.filter((tag) => tag[0] === "p")
|
||||
.map((tag) => (
|
||||
<User.Provider key={tag[1]} pubkey={tag[1]}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-8 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
<div className="p-2 flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Provider pubkey={item.pubkey}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<h5 className="text-xs font-medium">{name}</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
LumeWindow.openColumn({
|
||||
label,
|
||||
name,
|
||||
url: `/columns/groups/${item.id}`,
|
||||
})
|
||||
}
|
||||
className="h-6 w-16 inline-flex items-center justify-center gap-1 text-xs font-semibold rounded-full bg-neutral-100 group-hover:bg-blue-600 dark:group-hover:bg-blue-400 group-hover:text-white"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
|
||||
<Virtualizer scrollRef={ref as unknown as RefObject<HTMLElement>}>
|
||||
{isLoading ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<Spinner className="size-4" />
|
||||
Loading...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="mb-3 flex flex-col items-center justify-center h-16 w-full rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50">
|
||||
<p className="text-center">{error?.message ?? "Error"}</p>
|
||||
</div>
|
||||
) : !data?.length ? (
|
||||
<div className="mb-3 flex flex-col items-center justify-center h-16 w-full rounded-xl overflow-hidden bg-neutral-200/50 dark:bg-neutral-800/50">
|
||||
<p className="text-center">Empty.</p>
|
||||
</div>
|
||||
) : (
|
||||
data?.map((item) => renderItem(item))
|
||||
)}
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="h-11 w-full px-3 flex items-center justify-center gap-1.5 bg-neutral-200/50 hover:bg-neutral-200 rounded-full text-sm font-medium text-blue-600 dark:hover:bg-neutral-800 dark:bg-neutral-800/50 dark:text-blue-400"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowDown className="size-4" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
@ -182,10 +182,20 @@ function Newsfeeds() {
|
||||
) : (
|
||||
data?.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="h-12 px-3 flex items-center justify-between items-betwe bg-neutral-200/50 rounded-xl text-blue-600 dark:text-blue-400">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
LumeWindow.openColumn({
|
||||
name: "Newsfeeds",
|
||||
url: "/columns/discover-newsfeeds",
|
||||
label: "discover_newsfeeds",
|
||||
})
|
||||
}
|
||||
className="h-12 px-3 flex items-center justify-between bg-neutral-200/50 rounded-xl text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<span className="text-sm font-medium">Discover newsfeeds</span>
|
||||
<ArrowRight className="size-4" weight="bold" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -326,10 +336,20 @@ function Interests() {
|
||||
) : (
|
||||
data?.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="h-12 px-3 flex items-center justify-between items-betwe bg-neutral-200/50 rounded-xl text-blue-600 dark:text-blue-400">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
LumeWindow.openColumn({
|
||||
name: "Interests",
|
||||
url: "/columns/discover-interests",
|
||||
label: "discover_interests",
|
||||
})
|
||||
}
|
||||
className="h-12 px-3 flex items-center justify-between bg-neutral-200/50 rounded-xl text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<span className="text-sm font-medium">Discover interests</span>
|
||||
<ArrowRight className="size-4" weight="bold" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -2,55 +2,61 @@ import { commands } from "@/commands.gen";
|
||||
import type { NostrEvent } from "@/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useMemo } from "react";
|
||||
import { LumeEvent } from "./event";
|
||||
|
||||
export function useEvent(id: string, repost?: string) {
|
||||
const hex = useMemo(() => {
|
||||
try {
|
||||
const normalized = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
||||
const decoded = nip19.decode(normalized);
|
||||
|
||||
switch (decoded.type) {
|
||||
case "note":
|
||||
return decoded.data;
|
||||
case "nevent":
|
||||
return decoded.data.id;
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const { isLoading, isError, error, data } = useQuery({
|
||||
queryKey: ["ids", "event", id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
if (repost?.length) {
|
||||
const nostrEvent: NostrEvent = JSON.parse(repost);
|
||||
const res = await commands.getMetaFromEvent(nostrEvent.content);
|
||||
|
||||
if (res.status === "ok") {
|
||||
nostrEvent.meta = res.data;
|
||||
}
|
||||
|
||||
return new LumeEvent(nostrEvent);
|
||||
}
|
||||
|
||||
let normalizedId = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
||||
|
||||
if (normalizedId.startsWith("nevent")) {
|
||||
const decoded = nip19.decode(normalizedId);
|
||||
|
||||
if (decoded.type === "nevent") {
|
||||
normalizedId = decoded.data.id;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await commands.getEvent(normalizedId);
|
||||
if (repost?.length) {
|
||||
const nostrEvent: NostrEvent = JSON.parse(repost);
|
||||
const res = await commands.getMetaFromEvent(nostrEvent.content);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const data = res.data;
|
||||
const raw: NostrEvent = JSON.parse(data.raw);
|
||||
|
||||
if (data.parsed) {
|
||||
raw.meta = data.parsed;
|
||||
}
|
||||
|
||||
return new LumeEvent(raw);
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
nostrEvent.meta = res.data;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
|
||||
return new LumeEvent(nostrEvent);
|
||||
}
|
||||
|
||||
const res = await commands.getEvent(hex);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const data = res.data;
|
||||
const raw: NostrEvent = JSON.parse(data.raw);
|
||||
|
||||
if (data.parsed) {
|
||||
raw.meta = data.parsed;
|
||||
}
|
||||
|
||||
return new LumeEvent(raw);
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
enabled: !!hex,
|
||||
});
|
||||
|
||||
return { isLoading, isError, error, data };
|
||||
|
@ -19,7 +19,7 @@ export function useProfile(pubkey: string, data?: string) {
|
||||
case "naddr":
|
||||
return decoded.data.pubkey;
|
||||
default:
|
||||
return pubkey;
|
||||
return normalized;
|
||||
}
|
||||
} catch {
|
||||
return pubkey;
|
||||
|