mirror of
https://github.com/lumehq/lume.git
synced 2025-03-18 05:41:53 +01:00
feat: add for you column
This commit is contained in:
parent
a3460418f6
commit
b726ae3c7c
@ -9,6 +9,7 @@
|
||||
"dependencies": {
|
||||
"@columns/antenas": "workspace:^",
|
||||
"@columns/default": "workspace:^",
|
||||
"@columns/foryou": "workspace:^",
|
||||
"@columns/group": "workspace:^",
|
||||
"@columns/hashtag": "workspace:^",
|
||||
"@columns/thread": "workspace:^",
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { CheckIcon, ChevronDownIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { onboardingAtom } from "@lume/utils";
|
||||
import NDK, {
|
||||
NDKEvent,
|
||||
NDKKind,
|
||||
@ -13,6 +14,7 @@ import { desktopDir } from "@tauri-apps/api/path";
|
||||
import { Window } from "@tauri-apps/api/window";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { nanoid } from "nanoid";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useState } from "react";
|
||||
@ -40,6 +42,7 @@ export function CreateAccountScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const services = useLoaderData() as NDKEvent[];
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [serviceId, setServiceId] = useState(services?.[0]?.id);
|
||||
@ -85,6 +88,8 @@ export function CreateAccountScreen() {
|
||||
privkey: signer.privateKey,
|
||||
});
|
||||
|
||||
setOnboarding({ open: true, newUser: true });
|
||||
|
||||
return navigate("/auth/onboarding", { replace: true });
|
||||
};
|
||||
|
||||
@ -176,6 +181,7 @@ export function CreateAccountScreen() {
|
||||
await ark.createEvent({ kind: NDKKind.Contacts, content: "", tags: [] });
|
||||
|
||||
setIsLoading(false);
|
||||
setOnboarding({ open: true, newUser: true });
|
||||
|
||||
return navigate("/auth/onboarding", { replace: true });
|
||||
} catch (e) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Antenas } from "@columns/antenas";
|
||||
import { Default } from "@columns/default";
|
||||
import { ForYou } from "@columns/foryou";
|
||||
import { Group } from "@columns/group";
|
||||
import { Hashtag } from "@columns/hashtag";
|
||||
import { Thread } from "@columns/thread";
|
||||
@ -24,6 +25,8 @@ export function HomeScreen() {
|
||||
return <Default key={column.id} column={column} />;
|
||||
case COL_TYPES.newsfeed:
|
||||
return <Timeline key={column.id} column={column} />;
|
||||
case COL_TYPES.foryou:
|
||||
return <ForYou key={column.id} column={column} />;
|
||||
case COL_TYPES.thread:
|
||||
return <Thread key={column.id} column={column} />;
|
||||
case COL_TYPES.user:
|
||||
|
@ -35,6 +35,7 @@
|
||||
"react-router-dom": "^6.21.3",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"sonner": "^1.3.1",
|
||||
"string-strip-html": "^13.4.5",
|
||||
"tippy.js": "^6.3.7",
|
||||
"use-context-selector": "^1.4.1"
|
||||
},
|
||||
|
@ -30,6 +30,12 @@ export function ColumnProvider({ children }: { children: ReactNode }) {
|
||||
content: "",
|
||||
kind: COL_TYPES.newsfeed,
|
||||
},
|
||||
{
|
||||
id: 9998,
|
||||
title: "For You",
|
||||
content: "",
|
||||
kind: COL_TYPES.foryou,
|
||||
},
|
||||
]);
|
||||
|
||||
const loadAllColumns = useCallback(async () => {
|
||||
|
@ -13,10 +13,11 @@ import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import getUrls from "get-urls";
|
||||
import { nanoid } from "nanoid";
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { toast } from "sonner";
|
||||
import { stripHtml } from "string-strip-html";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
import { MentionUser } from "./mentions/user";
|
||||
@ -28,10 +29,8 @@ import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteContent({
|
||||
className,
|
||||
mini = false,
|
||||
}: {
|
||||
className?: string;
|
||||
mini?: boolean;
|
||||
}) {
|
||||
const storage = useStorage();
|
||||
const event = useNoteContext();
|
||||
@ -45,7 +44,9 @@ export function NoteContent({
|
||||
const richContent = useMemo(() => {
|
||||
if (event.kind !== NDKKind.Text) return content;
|
||||
|
||||
let parsedContent: string | ReactNode[] = content.replace(/\n+/g, "\n");
|
||||
let parsedContent: string | ReactNode[] = stripHtml(
|
||||
content.replace(/\n{2,}\s*/g, "\n"),
|
||||
).result;
|
||||
let linkPreview: string = undefined;
|
||||
let images: string[] = [];
|
||||
let videos: string[] = [];
|
||||
@ -56,7 +57,7 @@ export function NoteContent({
|
||||
const words = text.split(/( |\n)/);
|
||||
const urls = [...getUrls(text)];
|
||||
|
||||
if (storage.settings.media && !storage.settings.lowPower && !mini) {
|
||||
if (storage.settings.media && !storage.settings.lowPower) {
|
||||
images = urls.filter((word) =>
|
||||
IMAGES.some((el) => {
|
||||
const url = new URL(word);
|
||||
@ -83,11 +84,9 @@ export function NoteContent({
|
||||
);
|
||||
}
|
||||
|
||||
if (!mini) {
|
||||
events = words.filter((word) =>
|
||||
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
||||
);
|
||||
}
|
||||
events = words.filter((word) =>
|
||||
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
||||
);
|
||||
|
||||
const hashtags = words.filter((word) => word.startsWith("#"));
|
||||
const mentions = words.filter((word) =>
|
||||
@ -184,11 +183,9 @@ export function NoteContent({
|
||||
},
|
||||
);
|
||||
|
||||
if (!mini) {
|
||||
parsedContent = reactStringReplace(parsedContent, "\n", () => {
|
||||
return <div key={nanoid()} className="h-3" />;
|
||||
});
|
||||
}
|
||||
parsedContent = reactStringReplace(parsedContent, "\n", () => {
|
||||
return <div key={nanoid()} className="h-3" />;
|
||||
});
|
||||
|
||||
if (typeof parsedContent[0] === "string") {
|
||||
parsedContent[0] = parsedContent[0].trimStart();
|
||||
@ -235,12 +232,7 @@ export function NoteContent({
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"break-p select-text text-balance leading-normal",
|
||||
!mini ? "whitespace-pre-line" : "",
|
||||
)}
|
||||
>
|
||||
<div className="break-p select-text text-balance leading-normal whitespace-pre-line">
|
||||
{richContent}
|
||||
</div>
|
||||
{storage.settings.translation && translate.translatable ? (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { PinIcon } from "@lume/icons";
|
||||
import { COL_TYPES, NOSTR_MENTIONS } from "@lume/utils";
|
||||
import { ReactNode, memo, useMemo } from "react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { useEvent } from "../../../hooks/useEvent";
|
||||
@ -9,7 +9,7 @@ import { User } from "../../user";
|
||||
import { Hashtag } from "./hashtag";
|
||||
import { MentionUser } from "./user";
|
||||
|
||||
export const MentionNote = memo(function MentionNote({
|
||||
export function MentionNote({
|
||||
eventId,
|
||||
openable = true,
|
||||
}: { eventId: string; openable?: boolean }) {
|
||||
@ -66,7 +66,7 @@ export const MentionNote = memo(function MentionNote({
|
||||
to={url.toString()}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-p font-normal text-blue-500 hover:text-blue-600"
|
||||
className="break-p inline-block truncate w-full font-normal text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url.toString()}
|
||||
</Link>
|
||||
@ -104,50 +104,48 @@ export const MentionNote = memo(function MentionNote({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col w-full my-1 rounded-lg cursor-default bg-neutral-100 dark:bg-neutral-900 border border-neutral-100 dark:border-neutral-900">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-10 px-3 items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-md object-cover" />
|
||||
<div className="flex-1 inline-flex gap-2">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<User.Time
|
||||
time={data.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="px-3 select-text text-balance leading-normal line-clamp-4 whitespace-pre-line">
|
||||
{richContent}
|
||||
</div>
|
||||
{openable ? (
|
||||
<div className="px-3 h-10 flex items-center justify-between">
|
||||
<Link
|
||||
to={`/events/${data.id}`}
|
||||
className="text-sm font-medium text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
Show more
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () =>
|
||||
await addColumn({
|
||||
kind: COL_TYPES.thread,
|
||||
title: "Thread",
|
||||
content: data.id,
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center justify-center rounded-md text-neutral-600 dark:text-neutral-400 size-6 bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<PinIcon className="size-4" />
|
||||
</button>
|
||||
<div className="flex flex-col w-full my-1 rounded-lg cursor-default bg-neutral-100 dark:bg-neutral-900 border border-black/5 dark:border-white/5">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-10 px-3 items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-md object-cover" />
|
||||
<div className="flex-1 inline-flex gap-2">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<User.Time
|
||||
time={data.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3" />
|
||||
)}
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="px-3 select-text text-balance leading-normal line-clamp-4 whitespace-pre-line">
|
||||
{richContent}
|
||||
</div>
|
||||
{openable ? (
|
||||
<div className="px-3 h-10 flex items-center justify-between">
|
||||
<Link
|
||||
to={`/events/${data.id}`}
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
Show more
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () =>
|
||||
await addColumn({
|
||||
kind: COL_TYPES.thread,
|
||||
title: "Thread",
|
||||
content: data.id,
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center justify-center rounded-md text-neutral-600 dark:text-neutral-400 size-6 bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<PinIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -1,14 +1,11 @@
|
||||
import { COL_TYPES } from "@lume/utils";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useArk } from "../../../hooks/useArk";
|
||||
import { useProfile } from "../../../hooks/useProfile";
|
||||
import { useColumnContext } from "../../column/provider";
|
||||
|
||||
export const MentionUser = memo(function MentionUser({
|
||||
pubkey,
|
||||
}: { pubkey: string }) {
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const ark = useArk();
|
||||
const cleanPubkey = ark.getCleanPubkey(pubkey);
|
||||
|
||||
@ -51,4 +48,4 @@ export const MentionUser = memo(function MentionUser({
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ export function LinkPreview({ url }: { url: string }) {
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<div className="flex flex-col w-full my-1 rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-900">
|
||||
<div className="flex flex-col w-full my-1 rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-900 border border-black/5 dark:border-white/5">
|
||||
<div className="w-full h-48 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
<div className="w-2/3 h-3 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
@ -24,7 +24,7 @@ export function LinkPreview({ url }: { url: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.title && !data.image) {
|
||||
if (!data.title && !data.image && !data.description) {
|
||||
return (
|
||||
<Link
|
||||
to={url}
|
||||
@ -48,6 +48,8 @@ export function LinkPreview({ url }: { url: string }) {
|
||||
<img
|
||||
src={data.image}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="object-cover w-full h-48 bg-white rounded-t-lg"
|
||||
/>
|
||||
) : null}
|
||||
@ -59,7 +61,7 @@ export function LinkPreview({ url }: { url: string }) {
|
||||
</div>
|
||||
) : null}
|
||||
{data.description ? (
|
||||
<div className="mb-2 text-sm break-p line-clamp-3 text-neutral-700 dark:text-neutral-400">
|
||||
<div className="mb-2 text-balance text-sm break-p line-clamp-3 text-neutral-700 dark:text-neutral-400">
|
||||
{data.description}
|
||||
</div>
|
||||
) : null}
|
||||
|
@ -107,3 +107,4 @@ export * from "./src/popperFilled";
|
||||
export * from "./src/composeFilled";
|
||||
export * from "./src/settingsFilled";
|
||||
export * from "./src/bellFilled";
|
||||
export * from "./src/foryou";
|
||||
|
24
packages/icons/src/foryou.tsx
Normal file
24
packages/icons/src/foryou.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export function ForyouIcon(
|
||||
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="M14 21h.001M10 21H6a2 2 0 01-2-2 4 4 0 014-4h3.533M18 14c-.637 1.617-1.34 2.345-3 3 1.66.655 2.363 1.384 3 3 .637-1.616 1.34-2.345 3-3-1.66-.655-2.363-1.383-3-3zm-2-7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
27
packages/lume-column-foryou/package.json
Normal file
27
packages/lume-column-foryou/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@columns/foryou",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.tsx",
|
||||
"dependencies": {
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/storage": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@nostr-dev-kit/ndk": "^2.3.3",
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"react": "^18.2.0",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"sonner": "^1.3.1",
|
||||
"virtua": "^0.20.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@types/react": "^18.2.48",
|
||||
"tailwind": "^4.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
118
packages/lume-column-foryou/src/home.tsx
Normal file
118
packages/lume-column-foryou/src/home.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { TextNote, useArk } from "@lume/ark";
|
||||
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { CacheSnapshot, VList, VListHandle } from "virtua";
|
||||
|
||||
export function HomeRoute({ colKey }: { colKey: string }) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const ref = useRef<VListHandle>();
|
||||
const cacheKey = `${colKey}-vlist`;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [offset, cache] = useMemo(() => {
|
||||
const serialized = sessionStorage.getItem(cacheKey);
|
||||
if (!serialized) return [];
|
||||
return JSON.parse(serialized) as [number, CacheSnapshot];
|
||||
}, []);
|
||||
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [colKey],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text],
|
||||
"#t": storage.interests.hashtags,
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
initialData: () => {
|
||||
const queryCacheData = queryClient.getQueryState([colKey])
|
||||
?.data as NDKEvent[];
|
||||
if (queryCacheData) {
|
||||
return {
|
||||
pageParams: [undefined, 1],
|
||||
pages: [queryCacheData],
|
||||
};
|
||||
}
|
||||
},
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
staleTime: 120 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const handle = ref.current;
|
||||
|
||||
if (offset) {
|
||||
handle.scrollTo(offset);
|
||||
}
|
||||
|
||||
return () => {
|
||||
sessionStorage.setItem(
|
||||
cacheKey,
|
||||
JSON.stringify([handle.scrollOffset, handle.cache]),
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<VList ref={ref} cache={cache} overscan={2} className="flex-1 px-3">
|
||||
{isLoading ? (
|
||||
<div className="w-full flex h-16 items-center justify-center gap-2 px-3 py-1.5">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
data.map((event) => (
|
||||
<TextNote key={event.id} event={event} className="mt-3" />
|
||||
))
|
||||
)}
|
||||
<div className="flex items-center justify-center h-16">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex items-center justify-center w-full h-12 gap-2 font-medium bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800 rounded-xl focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
</div>
|
||||
);
|
||||
}
|
51
packages/lume-column-foryou/src/index.tsx
Normal file
51
packages/lume-column-foryou/src/index.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { Column } from "@lume/ark";
|
||||
import { ForyouIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { IColumn } from "@lume/types";
|
||||
import { EventRoute, UserRoute } from "@lume/ui";
|
||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { HomeRoute } from "./home";
|
||||
|
||||
export function ForYou({ column }: { column: IColumn }) {
|
||||
const colKey = `foryou-${column.id}`;
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
const since = useRef(Math.floor(Date.now() / 1000));
|
||||
|
||||
const refresh = async (events: NDKEvent[]) => {
|
||||
const uniqEvents = new Set(events);
|
||||
await queryClient.setQueryData(
|
||||
[colKey],
|
||||
(prev: { pageParams: number; pages: Array<NDKEvent[]> }) => ({
|
||||
...prev,
|
||||
pages: [[...uniqEvents], ...prev.pages],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Column.Root>
|
||||
<Column.Header
|
||||
id={column.id}
|
||||
queryKey={[colKey]}
|
||||
title="For You"
|
||||
icon={<ForyouIcon className="size-4" />}
|
||||
/>
|
||||
<Column.Live
|
||||
filter={{
|
||||
kinds: [NDKKind.Text],
|
||||
"#t": storage.interests.hashtags,
|
||||
since: since.current,
|
||||
}}
|
||||
onClick={refresh}
|
||||
/>
|
||||
<Column.Content>
|
||||
<Column.Route path="/" element={<HomeRoute colKey={colKey} />} />
|
||||
<Column.Route path="/events/:id" element={<EventRoute />} />
|
||||
<Column.Route path="/users/:id" element={<UserRoute />} />
|
||||
</Column.Content>
|
||||
</Column.Root>
|
||||
);
|
||||
}
|
8
packages/lume-column-foryou/tailwind.config.js
Normal file
8
packages/lume-column-foryou/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
import sharedConfig from "@lume/tailwindcss";
|
||||
|
||||
const config = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
||||
presets: [sharedConfig],
|
||||
};
|
||||
|
||||
export default config;
|
8
packages/lume-column-foryou/tsconfig.json
Normal file
8
packages/lume-column-foryou/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@lume/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
@ -57,16 +57,12 @@ export function HomeRoute({ colKey }: { colKey: string }) {
|
||||
};
|
||||
}
|
||||
},
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
staleTime: 120 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const renderItem = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
@ -110,7 +106,7 @@ export function HomeRoute({ colKey }: { colKey: string }) {
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex items-center justify-center h-16">
|
||||
{hasNextPage ? (
|
||||
|
@ -13,7 +13,7 @@ export function Timeline({ column }: { column: IColumn }) {
|
||||
const queryClient = useQueryClient();
|
||||
const since = useRef(Math.floor(Date.now() / 1000));
|
||||
|
||||
const refreshTimeline = async (events: NDKEvent[]) => {
|
||||
const refresh = async (events: NDKEvent[]) => {
|
||||
const uniqEvents = new Set(events);
|
||||
await queryClient.setQueryData(
|
||||
[colKey],
|
||||
@ -40,7 +40,7 @@ export function Timeline({ column }: { column: IColumn }) {
|
||||
: ark.account.contacts,
|
||||
since: since.current,
|
||||
}}
|
||||
onClick={refreshTimeline}
|
||||
onClick={refresh}
|
||||
/>
|
||||
<Column.Content>
|
||||
<Column.Route path="/" element={<HomeRoute colKey={colKey} />} />
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
Account,
|
||||
IColumn,
|
||||
Interests,
|
||||
NDKCacheEvent,
|
||||
NDKCacheEventTag,
|
||||
NDKCacheUser,
|
||||
@ -19,6 +20,7 @@ export class LumeStorage {
|
||||
readonly platform: Platform;
|
||||
readonly locale: string;
|
||||
public currentUser: Account;
|
||||
public interests: Interests;
|
||||
public nwc: string;
|
||||
public settings: {
|
||||
autoupdate: boolean;
|
||||
@ -37,6 +39,7 @@ export class LumeStorage {
|
||||
this.#db = db;
|
||||
this.locale = locale;
|
||||
this.platform = platform;
|
||||
this.interests = null;
|
||||
this.nwc = null;
|
||||
this.settings = {
|
||||
autoupdate: false,
|
||||
@ -64,7 +67,18 @@ export class LumeStorage {
|
||||
}
|
||||
|
||||
const account = await this.getActiveAccount();
|
||||
if (account) this.currentUser = account;
|
||||
|
||||
if (account) {
|
||||
this.currentUser = account;
|
||||
|
||||
const interests = await this.getInterests();
|
||||
if (interests) {
|
||||
interests.hashtags = interests.hashtags.map((item: string) =>
|
||||
item.replace("#", "").toLowerCase(),
|
||||
);
|
||||
this.interests = interests;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #keyring_save(key: string, value: string) {
|
||||
@ -412,6 +426,14 @@ export class LumeStorage {
|
||||
return results[0].value;
|
||||
}
|
||||
|
||||
public async getInterests() {
|
||||
const results: { key: string; value: string }[] = await this.#db.select(
|
||||
"SELECT * FROM settings WHERE key = 'interests' ORDER BY id DESC LIMIT 1;",
|
||||
);
|
||||
if (!results.length) return null;
|
||||
return JSON.parse(results[0].value) as Interests;
|
||||
}
|
||||
|
||||
public async clearCache() {
|
||||
await this.#db.execute("DELETE FROM ndk_events;");
|
||||
await this.#db.execute("DELETE FROM ndk_eventtags;");
|
||||
|
6
packages/types/index.d.ts
vendored
6
packages/types/index.d.ts
vendored
@ -115,3 +115,9 @@ export interface NIP05 {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface Interests {
|
||||
hashtags: string[];
|
||||
users: string[];
|
||||
words: string[];
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { ArrowRightIcon, PopperFilledIcon } from "@lume/icons";
|
||||
import { onboardingAtom } from "@lume/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function OnboardingHomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@ -27,7 +27,9 @@ export function OnboardingHomeScreen() {
|
||||
<div className="mt-4 flex flex-col gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/profile")}
|
||||
onClick={() =>
|
||||
onboarding.newUser ? navigate("/profile") : navigate("/interests")
|
||||
}
|
||||
className="inline-flex items-center justify-center gap-2 w-44 font-medium h-11 rounded-xl bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:bg-blue-800"
|
||||
>
|
||||
Profile Settings
|
||||
@ -35,7 +37,7 @@ export function OnboardingHomeScreen() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOnboarding(false)}
|
||||
onClick={() => setOnboarding({ open: false, newUser: false })}
|
||||
className="inline-flex items-center justify-center gap-2 w-44 px-5 font-medium h-11 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-900 text-neutral-700 dark:text-neutral-600"
|
||||
>
|
||||
Skip
|
||||
|
@ -7,7 +7,7 @@ export function OnboardingModal() {
|
||||
const onboarding = useAtomValue(onboardingAtom);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={onboarding}>
|
||||
<Dialog.Root open={onboarding.open}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/10 backdrop-blur-sm dark:bg-white/10" />
|
||||
<Dialog.Content className="fixed inset-0 z-50 flex items-center justify-center min-h-full">
|
||||
|
@ -29,7 +29,7 @@ export function EventRoute() {
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<ThreadNote eventId={id} />
|
||||
<ReplyList eventId={id} />
|
||||
<ReplyList eventId={id} className="mt-3" />
|
||||
</div>
|
||||
</WindowVirtualizer>
|
||||
</div>
|
||||
|
@ -11,7 +11,10 @@ export const editorValueAtom = atom([
|
||||
]);
|
||||
|
||||
// Onboarding
|
||||
export const onboardingAtom = atom(true);
|
||||
export const onboardingAtom = atomWithStorage("onboarding", {
|
||||
open: true,
|
||||
newUser: false,
|
||||
});
|
||||
|
||||
// Activity
|
||||
export const activityAtom = atom(false);
|
||||
|
148
pnpm-lock.yaml
generated
148
pnpm-lock.yaml
generated
@ -69,6 +69,9 @@ importers:
|
||||
'@columns/default':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/lume-column-default
|
||||
'@columns/foryou':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/lume-column-foryou
|
||||
'@columns/group':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/lume-column-group
|
||||
@ -328,6 +331,9 @@ importers:
|
||||
sonner:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1(react-dom@18.2.0)(react@18.2.0)
|
||||
string-strip-html:
|
||||
specifier: ^13.4.5
|
||||
version: 13.4.5
|
||||
tippy.js:
|
||||
specifier: ^6.3.7
|
||||
version: 6.3.7
|
||||
@ -529,6 +535,61 @@ importers:
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3
|
||||
|
||||
packages/lume-column-foryou:
|
||||
dependencies:
|
||||
'@lume/ark':
|
||||
specifier: workspace:^
|
||||
version: link:../ark
|
||||
'@lume/icons':
|
||||
specifier: workspace:^
|
||||
version: link:../icons
|
||||
'@lume/storage':
|
||||
specifier: workspace:^
|
||||
version: link:../storage
|
||||
'@lume/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../ui
|
||||
'@lume/utils':
|
||||
specifier: workspace:^
|
||||
version: link:../utils
|
||||
'@nostr-dev-kit/ndk':
|
||||
specifier: ^2.3.3
|
||||
version: 2.3.3(typescript@5.3.3)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.17.15
|
||||
version: 5.17.15(react@18.2.0)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0
|
||||
react-router-dom:
|
||||
specifier: ^6.21.3
|
||||
version: 6.21.3(react-dom@18.2.0)(react@18.2.0)
|
||||
sonner:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1(react-dom@18.2.0)(react@18.2.0)
|
||||
virtua:
|
||||
specifier: ^0.20.5
|
||||
version: 0.20.5(react-dom@18.2.0)(react@18.2.0)
|
||||
devDependencies:
|
||||
'@lume/tailwindcss':
|
||||
specifier: workspace:^
|
||||
version: link:../tailwindcss
|
||||
'@lume/tsconfig':
|
||||
specifier: workspace:^
|
||||
version: link:../tsconfig
|
||||
'@lume/types':
|
||||
specifier: workspace:^
|
||||
version: link:../types
|
||||
'@types/react':
|
||||
specifier: ^18.2.48
|
||||
version: 18.2.48
|
||||
tailwind:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3
|
||||
|
||||
packages/lume-column-group:
|
||||
dependencies:
|
||||
'@lume/ark':
|
||||
@ -3105,6 +3166,12 @@ packages:
|
||||
resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==}
|
||||
dev: false
|
||||
|
||||
/@types/lodash-es@4.17.12:
|
||||
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
|
||||
dependencies:
|
||||
'@types/lodash': 4.14.202
|
||||
dev: false
|
||||
|
||||
/@types/lodash@4.14.202:
|
||||
resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==}
|
||||
dev: false
|
||||
@ -3457,6 +3524,13 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/codsen-utils@1.6.3:
|
||||
resolution: {integrity: sha512-jsayHP4Z1gKjXB+NsFhEKrM2dAN4XCpbHbhwzzYfFrVL/DYPw9D/ACob6EjbIiV47PSe3OcxJqX/b1V/T7XK3A==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
dependencies:
|
||||
rfdc: 1.3.1
|
||||
dev: false
|
||||
|
||||
/color-convert@1.9.3:
|
||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||
dependencies:
|
||||
@ -4222,6 +4296,10 @@ packages:
|
||||
function-bind: 1.1.2
|
||||
dev: true
|
||||
|
||||
/html-entities@2.4.0:
|
||||
resolution: {integrity: sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==}
|
||||
dev: false
|
||||
|
||||
/http-errors@1.6.3:
|
||||
resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@ -4574,6 +4652,10 @@ packages:
|
||||
resolution: {integrity: sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==}
|
||||
dev: false
|
||||
|
||||
/lodash-es@4.17.21:
|
||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||
dev: false
|
||||
|
||||
/lodash.castarray@4.4.0:
|
||||
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
|
||||
dev: true
|
||||
@ -5080,6 +5162,37 @@ packages:
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: true
|
||||
|
||||
/ranges-apply@7.0.14:
|
||||
resolution: {integrity: sha512-ebPhmznZthJJszHMzGdZIVEHxWxM9uiynCGHChtgbuKO155uYCdrUvwsobX6xeefyqtVgHJcXpQDkTJhX0UFoQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
dependencies:
|
||||
ranges-merge: 9.0.14
|
||||
tiny-invariant: 1.3.1
|
||||
dev: false
|
||||
|
||||
/ranges-merge@9.0.14:
|
||||
resolution: {integrity: sha512-0iT8T14RPellWrLsfezpIq636TyqCK8+1oG7pxULjuJHwomq6POJF63fZ3CeQ7c/Dpjogs5iSOFc2hFv+XTI1Q==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
dependencies:
|
||||
ranges-push: 7.0.14
|
||||
ranges-sort: 6.0.11
|
||||
dev: false
|
||||
|
||||
/ranges-push@7.0.14:
|
||||
resolution: {integrity: sha512-EKmOrxtaFT4u3OiIfkoCoYxEeRkN2UuH1DbxvA7K/ok4Ie8/QK/DKaWbD9PnoXNnWbqnPtDdyMyvVgVyhnmGhA==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
dependencies:
|
||||
codsen-utils: 1.6.3
|
||||
ranges-sort: 6.0.11
|
||||
string-collapse-leading-whitespace: 7.0.7
|
||||
string-trim-spaces-only: 5.0.10
|
||||
dev: false
|
||||
|
||||
/ranges-sort@6.0.11:
|
||||
resolution: {integrity: sha512-fhNEG0vGi7bESitNNqNBAfYPdl2efB+1paFlI8BQDCNkruERKuuhG8LkQClDIVqUJLkrmKuOSPQ3xZHqVnVo3Q==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
dev: false
|
||||
|
||||
/raw-body@2.3.3:
|
||||
resolution: {integrity: sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@ -5293,6 +5406,10 @@ packages:
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/rfdc@1.3.1:
|
||||
resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==}
|
||||
dev: false
|
||||
|
||||
/rollup@3.29.4:
|
||||
resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==}
|
||||
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
||||
@ -5544,6 +5661,37 @@ packages:
|
||||
node-statsd: 0.1.1
|
||||
dev: true
|
||||
|
||||
/string-collapse-leading-whitespace@7.0.7:
|
||||
resolution: {integrity: sha512-jF9eynJoE6ezTCdYI8Qb02/ij/DlU9ItG93Dty4SWfJeLFrotOr+wH9IRiWHTqO3mjCyqBWEiU3uSTIbxYbAEQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
dev: false
|
||||
|
||||
/string-left-right@6.0.16:
|
||||
resolution: {integrity: sha512-cQL1I49o8qS52LgaS8IU6EXd9S2HNYVRtizdDyp6XjKzSkytr1oTM/7laDqjV7J53bw4iOQNepp/cTs9rCyFVw==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
dependencies:
|
||||
codsen-utils: 1.6.3
|
||||
rfdc: 1.3.1
|
||||
dev: false
|
||||
|
||||
/string-strip-html@13.4.5:
|
||||
resolution: {integrity: sha512-uf6o6zzYXccZQ+wsKN58cedBfMlbFqrUXcDjrBpptExgQEHcFU+uw1jAQdrfyOrAyH4GQKu7JcCm/wzPppnf5Q==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
dependencies:
|
||||
'@types/lodash-es': 4.17.12
|
||||
codsen-utils: 1.6.3
|
||||
html-entities: 2.4.0
|
||||
lodash-es: 4.17.21
|
||||
ranges-apply: 7.0.14
|
||||
ranges-push: 7.0.14
|
||||
string-left-right: 6.0.16
|
||||
dev: false
|
||||
|
||||
/string-trim-spaces-only@5.0.10:
|
||||
resolution: {integrity: sha512-MhmjE5jNqb1Ylo+BARPRlsdChGLrnPpAUWrT1VOxo9WhWwKVUU6CbZTfjwKaQPYTGS/wsX/4Zek88FM2rEb5iA==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
dev: false
|
||||
|
||||
/string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
Loading…
x
Reference in New Issue
Block a user