feat: add for you column

This commit is contained in:
2024-01-20 09:06:00 +07:00
parent a3460418f6
commit b726ae3c7c
25 changed files with 511 additions and 91 deletions

View File

@@ -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"
},

View File

@@ -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 () => {

View File

@@ -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 ? (

View File

@@ -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>
);
});
}

View File

@@ -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>
);
});
}

View File

@@ -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}

View File

@@ -107,3 +107,4 @@ export * from "./src/popperFilled";
export * from "./src/composeFilled";
export * from "./src/settingsFilled";
export * from "./src/bellFilled";
export * from "./src/foryou";

View 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>
);
}

View 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"
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,8 @@
import sharedConfig from "@lume/tailwindcss";
const config = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
presets: [sharedConfig],
};
export default config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@lume/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -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 ? (

View File

@@ -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} />} />

View File

@@ -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;");

View File

@@ -115,3 +115,9 @@ export interface NIP05 {
};
};
}
export interface Interests {
hashtags: string[];
users: string[];
words: string[];
}

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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);