feat: polish note component

This commit is contained in:
2024-01-12 13:56:20 +07:00
parent e0d4c53098
commit a9d10ff93b
16 changed files with 707 additions and 555 deletions

View File

@@ -29,8 +29,6 @@
"@tauri-apps/plugin-sql": "2.0.0-alpha.5", "@tauri-apps/plugin-sql": "2.0.0-alpha.5",
"@tauri-apps/plugin-updater": "2.0.0-alpha.5", "@tauri-apps/plugin-updater": "2.0.0-alpha.5",
"@tauri-apps/plugin-upload": "2.0.0-alpha.5", "@tauri-apps/plugin-upload": "2.0.0-alpha.5",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@vidstack/react": "^1.9.8", "@vidstack/react": "^1.9.8",
"get-urls": "^12.1.0", "get-urls": "^12.1.0",
"jotai": "^2.6.1", "jotai": "^2.6.1",

View File

@@ -267,10 +267,16 @@ export class Ark {
return event; return event;
} }
public getEventThread({ tags }: { tags: NDKTag[] }) { public getEventThread({
content,
tags,
}: { content: string; tags: NDKTag[] }) {
let rootEventId: string = null; let rootEventId: string = null;
let replyEventId: string = null; let replyEventId: string = null;
if (content.includes("nostr:note1") || content.includes("nostr:nevent1"))
return null;
const events = tags.filter((el) => el[0] === "e"); const events = tags.filter((el) => el[0] === "e");
if (!events.length) return null; if (!events.length) return null;

View File

@@ -1,9 +1,97 @@
import { useEvent } from '../../hooks/useEvent'; import { NOSTR_MENTIONS } from "@lume/utils";
import { NoteChildUser } from './childUser'; import { nip19 } from "nostr-tools";
import { ReactNode, useMemo } from "react";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace";
import { useEvent } from "../../hooks/useEvent";
import { NoteChildUser } from "./childUser";
import { Hashtag } from "./mentions/hashtag";
import { MentionUser } from "./mentions/user";
export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boolean }) { export function NoteChild({
eventId,
isRoot,
}: { eventId: string; isRoot?: boolean }) {
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => {
if (!data) return "";
let parsedContent: string | ReactNode[] = data.content.replace(
/\n+/g,
"\n",
);
const text = parsedContent;
const words = text.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
if (hashtags.length) {
for (const hashtag of hashtags) {
parsedContent = reactStringReplace(
parsedContent,
hashtag,
(match, i) => {
return <Hashtag key={match + i} tag={hashtag} />;
},
);
}
}
if (mentions.length) {
for (const mention of mentions) {
const address = mention
.replace("nostr:", "")
.replace("@", "")
.replace(/[^a-zA-Z0-9]/g, "");
const decoded = nip19.decode(address);
if (decoded.type === "npub") {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={decoded.data} />,
);
}
if (decoded.type === "nprofile" || decoded.type === "naddr") {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => (
<MentionUser key={match + i} pubkey={decoded.data.pubkey} />
),
);
}
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/g,
(match, i) => {
const url = new URL(match);
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-all font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
},
);
return parsedContent;
}, [data]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="relative flex gap-3"> <div className="relative flex gap-3">
@@ -29,10 +117,13 @@ export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boole
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800"> <div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800" /> <div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800" />
<div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100"> <div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{data.content} {richContent}
</div> </div>
</div> </div>
<NoteChildUser pubkey={data.pubkey} subtext={isRoot ? 'posted' : 'replied'} /> <NoteChildUser
pubkey={data.pubkey}
subtext={isRoot ? "posted" : "replied"}
/>
</div> </div>
); );
} }

View File

@@ -1,4 +1,12 @@
import { cn } from "@lume/utils"; import {
AUDIOS,
IMAGES,
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
cn,
regionNames,
} from "@lume/utils";
import { NDKKind } from "@nostr-dev-kit/ndk"; import { NDKKind } from "@nostr-dev-kit/ndk";
import { fetch } from "@tauri-apps/plugin-http"; import { fetch } from "@tauri-apps/plugin-http";
import getUrls from "get-urls"; import getUrls from "get-urls";
@@ -19,55 +27,12 @@ import {
} from "../.."; } from "../..";
import { NIP89 } from "./nip89"; import { NIP89 } from "./nip89";
const NOSTR_MENTIONS = [
"@npub1",
"nostr:npub1",
"nostr:nprofile1",
"nostr:naddr1",
"npub1",
"nprofile1",
"naddr1",
"Nostr:npub1",
"Nostr:nprofile1",
"Nostr:naddre1",
];
const NOSTR_EVENTS = [
"@nevent1",
"@note1",
"@nostr:note1",
"@nostr:nevent1",
"nostr:note1",
"note1",
"nostr:nevent1",
"nevent1",
"Nostr:note1",
"Nostr:nevent1",
];
// const BITCOINS = ['lnbc', 'bc1p', 'bc1q'];
const IMAGES = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
const VIDEOS = [
"mp4",
"mov",
"webm",
"wmv",
"flv",
"mts",
"avi",
"ogv",
"mkv",
"m3u8",
];
const AUDIOS = ["mp3", "ogg", "wav"];
export function NoteContent({ export function NoteContent({
className, className,
isTranslatable = false,
}: { }: {
className?: string; className?: string;
isTranslatable?: boolean;
}) { }) {
const storage = useStorage(); const storage = useStorage();
const event = useNoteContext(); const event = useNoteContext();
@@ -79,7 +44,7 @@ export function NoteContent({
if (event.kind !== NDKKind.Text) return content; if (event.kind !== NDKKind.Text) return content;
let parsedContent: string | ReactNode[] = content.replace(/\n+/g, "\n"); let parsedContent: string | ReactNode[] = content.replace(/\n+/g, "\n");
let linkPreview: string; let linkPreview: string = undefined;
let images: string[] = []; let images: string[] = [];
let videos: string[] = []; let videos: string[] = [];
let audios: string[] = []; let audios: string[] = [];
@@ -299,7 +264,7 @@ export function NoteContent({
<div className="break-p select-text whitespace-pre-line text-balance leading-normal"> <div className="break-p select-text whitespace-pre-line text-balance leading-normal">
{richContent} {richContent}
</div> </div>
{storage.settings.translation ? ( {isTranslatable && storage.settings.translation ? (
translated ? ( translated ? (
<button <button
type="button" type="button"
@@ -307,7 +272,7 @@ export function NoteContent({
setTranslated(false); setTranslated(false);
setContent(event.content); setContent(event.content);
}} }}
className="mt-2 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none" className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none"
> >
Show original content Show original content
</button> </button>
@@ -315,9 +280,9 @@ export function NoteContent({
<button <button
type="button" type="button"
onClick={translate} onClick={translate}
className="mt-2 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none" className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none"
> >
Translate to Vietnamese Translate to {regionNames.of(storage.locale)}
</button> </button>
) )
) : null} ) : null}

View File

@@ -1,5 +1,4 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons"; import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { getImageMeta } from "@lume/utils";
import { downloadDir } from "@tauri-apps/api/path"; import { downloadDir } from "@tauri-apps/api/path";
import { Window } from "@tauri-apps/api/window"; import { Window } from "@tauri-apps/api/window";
import { download } from "@tauri-apps/plugin-upload"; import { download } from "@tauri-apps/plugin-upload";
@@ -24,12 +23,9 @@ export function ImagePreview({ url }: { url: string }) {
const open = async () => { const open = async () => {
const name = new URL(url).pathname.split("/").pop(); const name = new URL(url).pathname.split("/").pop();
const image = await getImageMeta(url);
return new Window("image-viewer", { return new Window("image-viewer", {
url, url,
title: name, title: name,
width: image.width,
height: image.height,
}); });
}; };

View File

@@ -9,7 +9,6 @@ export function VideoPreview({ url }: { url: string }) {
<MediaPlayer <MediaPlayer
src={url} src={url}
className="w-full my-1 overflow-hidden rounded-lg" className="w-full my-1 overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible" load="visible"
> >
<MediaProvider /> <MediaProvider />

View File

@@ -1,14 +1,10 @@
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { Note } from ".."; import { Note } from "..";
import { useArk } from "../../../provider";
export function TextNote({ export function TextNote({
event, event,
className, className,
}: { event: NDKEvent; className?: string }) { }: { event: NDKEvent; className?: string }) {
const ark = useArk();
const thread = ark.getEventThread({ tags: event.tags });
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root className={className}> <Note.Root className={className}>
@@ -16,7 +12,7 @@ export function TextNote({
<Note.User className="flex-1 pr-1" /> <Note.User className="flex-1 pr-1" />
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.Thread thread={thread} className="mb-2" /> <Note.Thread className="mb-2" />
<Note.Content className="min-w-0 px-3" /> <Note.Content className="min-w-0 px-3" />
<div className="flex items-center justify-between px-3 h-14"> <div className="flex items-center justify-between px-3 h-14">
<Note.Pin /> <Note.Pin />

View File

@@ -1,32 +1,9 @@
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { Note } from ".."; import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent"; import { useEvent } from "../../../hooks/useEvent";
import { useArk } from "../../../provider";
export function ThreadNote({ eventId }: { eventId: string }) { export function ThreadNote({ eventId }: { eventId: string }) {
const ark = useArk();
const { isLoading, data } = useEvent(eventId); const { isLoading, data } = useEvent(eventId);
const renderEventKind = (event: NDKEvent) => {
const thread = ark.getEventThread({ tags: data.tags });
switch (event.kind) {
case NDKKind.Text:
return (
<>
<Note.Thread thread={thread} className="mb-2" />
<Note.Content className="min-w-0 px-3" />
</>
);
default:
return (
<>
<Note.Thread thread={thread} className="mb-2" />
<Note.Content className="min-w-0 px-3" />
</>
);
}
};
if (isLoading) { if (isLoading) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
@@ -38,7 +15,8 @@ export function ThreadNote({ eventId }: { eventId: string }) {
<Note.User className="flex-1 pr-1" /> <Note.User className="flex-1 pr-1" />
<Note.Menu /> <Note.Menu />
</div> </div>
{renderEventKind(data)} <Note.Thread className="mb-2" />
<Note.Content className="min-w-0 px-3" isTranslatable />
<div className="flex items-center justify-between px-3 h-14"> <div className="flex items-center justify-between px-3 h-14">
<Note.Pin /> <Note.Pin />
<div className="inline-flex items-center gap-10"> <div className="inline-flex items-center gap-10">

View File

@@ -2,16 +2,22 @@ import { PinIcon } from "@lume/icons";
import { COL_TYPES } from "@lume/utils"; import { COL_TYPES } from "@lume/utils";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Note } from "."; import { Note, useNoteContext } from ".";
import { useArk } from "../..";
import { useColumnContext } from "../column"; import { useColumnContext } from "../column";
export function NoteThread({ export function NoteThread({
thread,
className, className,
}: { }: {
thread: { rootEventId: string; replyEventId: string };
className?: string; className?: string;
}) { }) {
const ark = useArk();
const event = useNoteContext();
const thread = ark.getEventThread({
content: event.content,
tags: event.tags,
});
const { addColumn } = useColumnContext(); const { addColumn } = useColumnContext();
if (!thread) return null; if (!thread) return null;

View File

@@ -1,12 +1,7 @@
import { LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { NDKCacheAdapterTauri } from "@lume/ndk-cache-tauri"; import { NDKCacheAdapterTauri } from "@lume/ndk-cache-tauri";
import { LumeStorage } from "@lume/storage"; import { LumeStorage } from "@lume/storage";
import { import { QUOTES, delay, sendNativeNotification } from "@lume/utils";
FETCH_LIMIT,
QUOTES,
delay,
sendNativeNotification,
} from "@lume/utils";
import NDK, { import NDK, {
NDKEvent, NDKEvent,
NDKKind, NDKKind,
@@ -18,7 +13,7 @@ import NDK, {
import { ndkAdapter } from "@nostr-fetch/adapter-ndk"; import { ndkAdapter } from "@nostr-fetch/adapter-ndk";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http"; import { fetch } from "@tauri-apps/plugin-http";
import { platform } from "@tauri-apps/plugin-os"; import { locale, platform } from "@tauri-apps/plugin-os";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
import Database from "@tauri-apps/plugin-sql"; import Database from "@tauri-apps/plugin-sql";
import { check } from "@tauri-apps/plugin-updater"; import { check } from "@tauri-apps/plugin-updater";
@@ -101,9 +96,10 @@ const LumeProvider = ({ children }: PropsWithChildren<object>) => {
async function init() { async function init() {
const platformName = await platform(); const platformName = await platform();
const osLocale = await locale();
const sqliteAdapter = await Database.load("sqlite:lume_v3.db"); const sqliteAdapter = await Database.load("sqlite:lume_v3.db");
const storage = new LumeStorage(sqliteAdapter, platformName); const storage = new LumeStorage(sqliteAdapter, platformName, osLocale);
await storage.init(); await storage.init();
// check for new update // check for new update
@@ -145,9 +141,9 @@ const LumeProvider = ({ children }: PropsWithChildren<object>) => {
explicitRelayUrls, explicitRelayUrls,
outboxRelayUrls, outboxRelayUrls,
blacklistRelayUrls, blacklistRelayUrls,
enableOutboxModel: !storage.settings.lowPowerMode, enableOutboxModel: !storage.settings.lowPower,
autoConnectUserRelays: !storage.settings.lowPowerMode, autoConnectUserRelays: !storage.settings.lowPower,
autoFetchUserMutelist: !storage.settings.lowPowerMode, autoFetchUserMutelist: !storage.settings.lowPower,
// clientName: 'Lume', // clientName: 'Lume',
// clientNip89: '', // clientNip89: '',
}); });

View File

@@ -12,6 +12,7 @@ export function HomeRoute({ colKey }: { colKey: string }) {
const storage = useStorage(); const storage = useStorage();
const ref = useRef<VListHandle>(); const ref = useRef<VListHandle>();
const cacheKey = `${colKey}-vlist`; const cacheKey = `${colKey}-vlist`;
const queryClient = useQueryClient();
const [offset, cache] = useMemo(() => { const [offset, cache] = useMemo(() => {
const serialized = sessionStorage.getItem(cacheKey); const serialized = sessionStorage.getItem(cacheKey);
@@ -48,7 +49,6 @@ export function HomeRoute({ colKey }: { colKey: string }) {
return lastEvent.created_at - 1; return lastEvent.created_at - 1;
}, },
initialData: () => { initialData: () => {
const queryClient = useQueryClient();
const queryCacheData = queryClient.getQueryState([colKey]) const queryCacheData = queryClient.getQueryState([colKey])
?.data as NDKEvent[]; ?.data as NDKEvent[];
if (queryCacheData) { if (queryCacheData) {

View File

@@ -17,6 +17,7 @@ export class LumeStorage {
#db: Database; #db: Database;
#depot: Child; #depot: Child;
readonly platform: Platform; readonly platform: Platform;
readonly locale: string;
public account: Account; public account: Account;
public settings: { public settings: {
autoupdate: boolean; autoupdate: boolean;
@@ -30,8 +31,9 @@ export class LumeStorage {
translateApiKey: string; translateApiKey: string;
}; };
constructor(db: Database, platform: Platform) { constructor(db: Database, platform: Platform, locale: string) {
this.#db = db; this.#db = db;
this.locale = locale;
this.platform = platform; this.platform = platform;
this.settings = { this.settings = {
autoupdate: false, autoupdate: false,

View File

@@ -5,7 +5,12 @@ import {
useProfile, useProfile,
useStorage, useStorage,
} from "@lume/ark"; } from "@lume/ark";
import { ArrowLeftIcon, ArrowRightCircleIcon, LoaderIcon } from "@lume/icons"; import {
ArrowLeftIcon,
ArrowRightCircleIcon,
ArrowRightIcon,
LoaderIcon,
} from "@lume/icons";
import { FETCH_LIMIT, displayNpub } from "@lume/utils"; import { FETCH_LIMIT, displayNpub } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
@@ -104,14 +109,20 @@ export function UserRoute() {
return ( return (
<div className="pb-5 overflow-y-auto"> <div className="pb-5 overflow-y-auto">
<WindowVirtualizer> <WindowVirtualizer>
<div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center px-3 border-neutral-100 dark:border-neutral-900 mb-3"> <div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center justify-start gap-2 px-3 border-neutral-100 dark:border-neutral-900 mb-3">
<button <button
type="button" type="button"
className="inline-flex items-center gap-2.5 text-sm font-medium" className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<ArrowLeftIcon className="size-4" /> <ArrowLeftIcon className="size-5" />
Back </button>
<button
type="button"
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
onClick={() => navigate(1)}
>
<ArrowRightIcon className="size-5" />
</button> </button>
</div> </div>
<div className="px-3"> <div className="px-3">

View File

@@ -1,5 +1,50 @@
export const FETCH_LIMIT = 20; export const FETCH_LIMIT = 20;
export const NOSTR_MENTIONS = [
"@npub1",
"nostr:npub1",
"nostr:nprofile1",
"nostr:naddr1",
"npub1",
"nprofile1",
"naddr1",
"Nostr:npub1",
"Nostr:nprofile1",
"Nostr:naddre1",
];
export const NOSTR_EVENTS = [
"@nevent1",
"@note1",
"@nostr:note1",
"@nostr:nevent1",
"nostr:note1",
"note1",
"nostr:nevent1",
"nevent1",
"Nostr:note1",
"Nostr:nevent1",
];
// const BITCOINS = ['lnbc', 'bc1p', 'bc1q'];
export const IMAGES = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
export const VIDEOS = [
"mp4",
"mov",
"webm",
"wmv",
"flv",
"mts",
"avi",
"ogv",
"mkv",
"m3u8",
];
export const AUDIOS = ["mp3", "ogg", "wav"];
export const HASHTAGS = [ export const HASHTAGS = [
{ hashtag: "#food" }, { hashtag: "#food" },
{ hashtag: "#gaming" }, { hashtag: "#gaming" },

View File

@@ -65,3 +65,6 @@ export function displayNpub(pubkey: string, len: number) {
// convert number to K, M, B, T, etc. // convert number to K, M, B, T, etc.
export const compactNumber = Intl.NumberFormat("en", { notation: "compact" }); export const compactNumber = Intl.NumberFormat("en", { notation: "compact" });
// country name
export const regionNames = new Intl.DisplayNames(["en"], { type: "language" });

886
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff