feat: polish note component

This commit is contained in:
reya 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-updater": "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",
"get-urls": "^12.1.0",
"jotai": "^2.6.1",

View File

@ -267,10 +267,16 @@ export class Ark {
return event;
}
public getEventThread({ tags }: { tags: NDKTag[] }) {
public getEventThread({
content,
tags,
}: { content: string; tags: NDKTag[] }) {
let rootEventId: 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");
if (!events.length) return null;

View File

@ -1,38 +1,129 @@
import { useEvent } from '../../hooks/useEvent';
import { NoteChildUser } from './childUser';
import { NOSTR_MENTIONS } from "@lume/utils";
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 }) {
const { isLoading, isError, data } = useEvent(eventId);
export function NoteChild({
eventId,
isRoot,
}: { eventId: string; isRoot?: boolean }) {
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
const richContent = useMemo(() => {
if (!data) return "";
if (isError) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event
</div>
</div>
);
}
let parsedContent: string | ReactNode[] = data.content.replace(
/\n+/g,
"\n",
);
return (
<div className="relative flex gap-3">
<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="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{data.content}
</div>
</div>
<NoteChildUser pubkey={data.pubkey} subtext={isRoot ? 'posted' : 'replied'} />
</div>
);
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) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
if (isError) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event
</div>
</div>
);
}
return (
<div className="relative flex gap-3">
<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="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{richContent}
</div>
</div>
<NoteChildUser
pubkey={data.pubkey}
subtext={isRoot ? "posted" : "replied"}
/>
</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 { fetch } from "@tauri-apps/plugin-http";
import getUrls from "get-urls";
@ -19,55 +27,12 @@ import {
} from "../..";
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({
className,
isTranslatable = false,
}: {
className?: string;
isTranslatable?: boolean;
}) {
const storage = useStorage();
const event = useNoteContext();
@ -79,7 +44,7 @@ export function NoteContent({
if (event.kind !== NDKKind.Text) return content;
let parsedContent: string | ReactNode[] = content.replace(/\n+/g, "\n");
let linkPreview: string;
let linkPreview: string = undefined;
let images: string[] = [];
let videos: string[] = [];
let audios: string[] = [];
@ -299,7 +264,7 @@ export function NoteContent({
<div className="break-p select-text whitespace-pre-line text-balance leading-normal">
{richContent}
</div>
{storage.settings.translation ? (
{isTranslatable && storage.settings.translation ? (
translated ? (
<button
type="button"
@ -307,7 +272,7 @@ export function NoteContent({
setTranslated(false);
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
</button>
@ -315,9 +280,9 @@ export function NoteContent({
<button
type="button"
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>
)
) : null}

View File

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

View File

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

View File

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

View File

@ -1,32 +1,9 @@
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent";
import { useArk } from "../../../provider";
export function ThreadNote({ eventId }: { eventId: string }) {
const ark = useArk();
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) {
return <div>Loading...</div>;
}
@ -38,7 +15,8 @@ export function ThreadNote({ eventId }: { eventId: string }) {
<Note.User className="flex-1 pr-1" />
<Note.Menu />
</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">
<Note.Pin />
<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 { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { Note } from ".";
import { Note, useNoteContext } from ".";
import { useArk } from "../..";
import { useColumnContext } from "../column";
export function NoteThread({
thread,
className,
}: {
thread: { rootEventId: string; replyEventId: string };
className?: string;
}) {
const ark = useArk();
const event = useNoteContext();
const thread = ark.getEventThread({
content: event.content,
tags: event.tags,
});
const { addColumn } = useColumnContext();
if (!thread) return null;

View File

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

View File

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

View File

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

View File

@ -5,7 +5,12 @@ import {
useProfile,
useStorage,
} 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 { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query";
@ -104,14 +109,20 @@ export function UserRoute() {
return (
<div className="pb-5 overflow-y-auto">
<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
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)}
>
<ArrowLeftIcon className="size-4" />
Back
<ArrowLeftIcon className="size-5" />
</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>
</div>
<div className="px-3">

View File

@ -1,5 +1,50 @@
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 = [
{ hashtag: "#food" },
{ hashtag: "#gaming" },

View File

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

File diff suppressed because it is too large Load Diff