wip: update

This commit is contained in:
reya 2024-02-15 12:47:15 +07:00
parent 6171b9bed1
commit cdf29f8a54
14 changed files with 369 additions and 347 deletions

View File

@ -1,12 +1,12 @@
import { useArk } from "@lume/ark"; import { useArk } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon, SearchIcon } from "@lume/icons"; import { ArrowRightCircleIcon, LoaderIcon, SearchIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types"; import { Event, Kind } from "@lume/types";
import { EmptyFeed } from "@lume/ui"; import { EmptyFeed, TextNote } from "@lume/ui";
import { FETCH_LIMIT } from "@lume/utils"; import { FETCH_LIMIT } from "@lume/utils";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { CacheSnapshot, VList, VListHandle } from "virtua"; import { CacheSnapshot, Virtualizer, VListHandle } from "virtua";
export const Route = createLazyFileRoute("/app/home")({ export const Route = createLazyFileRoute("/app/home")({
component: Home, component: Home,
@ -34,21 +34,18 @@ function Home() {
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1); const lastEvent = lastPage.at(-1);
if (!lastEvent) return; if (!lastEvent) return;
return lastEvent.created_at - 1; return lastEvent.created_at;
}, },
select: (data) => data?.pages.flatMap((page) => page), select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false,
}); });
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
switch (event.kind) { switch (event.kind) {
case Kind.Text: case Kind.Text:
return <p key={event.id}>{event.content}</p>; return <TextNote key={event.id} event={event} />;
case Kind.Repost:
return <p key={event.id}>{event.content}</p>;
default: default:
return <p key={event.id}>{event.content}</p>; return <TextNote key={event.id} event={event} />;
} }
}; };
@ -70,25 +67,27 @@ function Home() {
return ( return (
<div className="h-full w-full overflow-hidden rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5"> <div className="h-full w-full overflow-hidden rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
<div className="mx-auto h-full w-full max-w-2xl pt-10"> <div className="h-full w-full overflow-y-auto pt-10">
<VList ref={ref} cache={cache} overscan={2}> <div className="mx-auto w-full max-w-xl">
{isLoading ? ( {isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2 px-3 py-1.5"> <div className="flex h-20 w-full items-center justify-center">
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
</div> </div>
) : !data.length ? ( ) : !data.length ? (
<div className="mt-3 px-3"> <div className="flex flex-col gap-3">
<EmptyFeed /> <EmptyFeed />
<a <a
href="/suggest" href="/suggest"
className="mt-3 inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600" className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
> >
<SearchIcon className="size-5" /> <SearchIcon className="size-5" />
Find accounts to follow Find accounts to follow
</a> </a>
</div> </div>
) : ( ) : (
data.map((item) => renderItem(item)) <Virtualizer ref={ref} cache={cache} overscan={2}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)} )}
<div className="flex h-16 items-center justify-center"> <div className="flex h-16 items-center justify-center">
{hasNextPage ? ( {hasNextPage ? (
@ -109,7 +108,7 @@ function Home() {
</button> </button>
) : null} ) : null}
</div> </div>
</VList> </div>
</div> </div>
</div> </div>
); );

View File

@ -190,4 +190,16 @@ export class Ark {
console.error(String(e)); console.error(String(e));
} }
} }
public async verify_nip05(pubkey: string, nip05: string) {
try {
const cmd: boolean = await invoke("verify_nip05", {
key: pubkey,
nip05,
});
return cmd;
} catch {
return false;
}
}
} }

View File

@ -3,6 +3,10 @@ export * from "./user";
export * from "./note"; export * from "./note";
export * from "./column"; export * from "./column";
// Note Primities
export * from "./note/primitives/text";
export * from "./note/primitives/repost";
// Deprecated // Deprecated
export * from "./routes/event"; export * from "./routes/event";
export * from "./routes/user"; export * from "./routes/user";

View File

@ -1,34 +1,30 @@
import { ReplyIcon } from "@lume/icons"; import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NoteReply() { export function NoteReply() {
const event = useNoteContext(); const event = useNoteContext();
const navigate = useNavigate(); const { t } = useTranslation();
const { t } = useTranslation(); return (
<Tooltip.Provider>
return ( <Tooltip.Root delayDuration={150}>
<Tooltip.Provider> <Tooltip.Trigger asChild>
<Tooltip.Root delayDuration={150}> <button
<Tooltip.Trigger asChild> type="button"
<button className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
type="button" >
onClick={() => navigate(`/events/${event.id}`)} <ReplyIcon className="size-5 group-hover:text-blue-500" />
className="inline-flex items-center justify-center group h-7 w-7 text-neutral-600 dark:text-neutral-400" </button>
> </Tooltip.Trigger>
<ReplyIcon className="size-5 group-hover:text-blue-500" /> <Tooltip.Portal>
</button> <Tooltip.Content className="data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] dark:bg-neutral-50 dark:text-neutral-950">
</Tooltip.Trigger> {t("note.menu.viewThread")}
<Tooltip.Portal> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> </Tooltip.Content>
{t("note.menu.viewThread")} </Tooltip.Portal>
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> </Tooltip.Root>
</Tooltip.Content> </Tooltip.Provider>
</Tooltip.Portal> );
</Tooltip.Root>
</Tooltip.Provider>
);
} }

View File

@ -2,7 +2,6 @@ import { NOSTR_MENTIONS } from "@lume/utils";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { User } from "../user"; import { User } from "../user";
import { Hashtag } from "./mentions/hashtag"; import { Hashtag } from "./mentions/hashtag";
@ -61,15 +60,15 @@ export function NoteChild({
(match, i) => { (match, i) => {
const url = new URL(match); const url = new URL(match);
return ( return (
<Link <a
key={match + i} key={match + i}
to={url.toString()} href={url.toString()}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="break-p font-normal text-blue-500 hover:text-blue-600" className="break-p font-normal text-blue-500 hover:text-blue-600"
> >
{url.toString()} {url.toString()}
</Link> </a>
); );
}, },
); );

View File

@ -1,20 +1,19 @@
import { useStorage } from "@lume/storage"; import { useStorage } from "@lume/storage";
import { Kind } from "@lume/types"; import { Kind } from "@lume/types";
import { import {
AUDIOS, AUDIOS,
IMAGES, IMAGES,
NOSTR_EVENTS, NOSTR_EVENTS,
NOSTR_MENTIONS, NOSTR_MENTIONS,
VIDEOS, VIDEOS,
canPreview, canPreview,
cn, cn,
regionNames, regionNames,
} from "@lume/utils"; } from "@lume/utils";
import { fetch } from "@tauri-apps/plugin-http"; import { fetch } from "@tauri-apps/plugin-http";
import getUrls from "get-urls"; import getUrls from "get-urls";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { ReactNode, useMemo, useState } from "react"; import { ReactNode, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { toast } from "sonner"; import { toast } from "sonner";
import { stripHtml } from "string-strip-html"; import { stripHtml } from "string-strip-html";
@ -27,230 +26,226 @@ import { LinkPreview } from "./preview/link";
import { VideoPreview } from "./preview/video"; import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteContent({ export function NoteContent({ className }: { className?: string }) {
className, const storage = useStorage();
}: { const event = useNoteContext();
className?: string;
}) {
const storage = useStorage();
const event = useNoteContext();
const [content, setContent] = useState(event.content); const [content, setContent] = useState(event.content);
const [translate, setTranslate] = useState({ const [translate, setTranslate] = useState({
translatable: false, translatable: false,
translated: false, translated: false,
}); });
const richContent = useMemo(() => { const richContent = useMemo(() => {
if (event.kind !== Kind.Text) return content; if (event.kind !== Kind.Text) return content;
let parsedContent: string | ReactNode[] = stripHtml( let parsedContent: string | ReactNode[] = stripHtml(
content.replace(/\n{2,}\s*/g, "\n"), content.replace(/\n{2,}\s*/g, "\n"),
).result; ).result;
let linkPreview: string = undefined; let linkPreview: string = undefined;
let images: string[] = []; let images: string[] = [];
let videos: string[] = []; let videos: string[] = [];
let audios: string[] = []; let audios: string[] = [];
let events: string[] = []; let events: string[] = [];
const text = parsedContent; const text = parsedContent;
const words = text.split(/( |\n)/); const words = text.split(/( |\n)/);
const urls = [...getUrls(text)]; const urls = [...getUrls(text)];
if (storage.settings.media && !storage.settings.lowPower) { if (storage.settings.media && !storage.settings.lowPower) {
images = urls.filter((word) => images = urls.filter((word) =>
IMAGES.some((el) => { IMAGES.some((el) => {
const url = new URL(word); const url = new URL(word);
const extension = url.pathname.split(".")[1]; const extension = url.pathname.split(".")[1];
if (extension === el) return true; if (extension === el) return true;
return false; return false;
}), }),
); );
videos = urls.filter((word) => videos = urls.filter((word) =>
VIDEOS.some((el) => { VIDEOS.some((el) => {
const url = new URL(word); const url = new URL(word);
const extension = url.pathname.split(".")[1]; const extension = url.pathname.split(".")[1];
if (extension === el) return true; if (extension === el) return true;
return false; return false;
}), }),
); );
audios = urls.filter((word) => audios = urls.filter((word) =>
AUDIOS.some((el) => { AUDIOS.some((el) => {
const url = new URL(word); const url = new URL(word);
const extension = url.pathname.split(".")[1]; const extension = url.pathname.split(".")[1];
if (extension === el) return true; if (extension === el) return true;
return false; return false;
}), }),
); );
} }
events = words.filter((word) => events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)), NOSTR_EVENTS.some((el) => word.startsWith(el)),
); );
const hashtags = words.filter((word) => word.startsWith("#")); const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) => const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)), NOSTR_MENTIONS.some((el) => word.startsWith(el)),
); );
try { try {
if (images.length) { if (images.length) {
for (const image of images) { for (const image of images) {
parsedContent = reactStringReplace( parsedContent = reactStringReplace(
parsedContent, parsedContent,
image, image,
(match, i) => <ImagePreview key={match + i} url={match} />, (match, i) => <ImagePreview key={match + i} url={match} />,
); );
} }
} }
if (videos.length) { if (videos.length) {
for (const video of videos) { for (const video of videos) {
parsedContent = reactStringReplace( parsedContent = reactStringReplace(
parsedContent, parsedContent,
video, video,
(match, i) => <VideoPreview key={match + i} url={match} />, (match, i) => <VideoPreview key={match + i} url={match} />,
); );
} }
} }
if (audios.length) { if (audios.length) {
for (const audio of audios) { for (const audio of audios) {
parsedContent = reactStringReplace( parsedContent = reactStringReplace(
parsedContent, parsedContent,
audio, audio,
(match, i) => <VideoPreview key={match + i} url={match} />, (match, i) => <VideoPreview key={match + i} url={match} />,
); );
} }
} }
if (hashtags.length) { if (hashtags.length) {
for (const hashtag of hashtags) { for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g"); const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => { parsedContent = reactStringReplace(parsedContent, regex, () => {
if (storage.settings.hashtag) if (storage.settings.hashtag)
return <Hashtag key={nanoid()} tag={hashtag} />; return <Hashtag key={nanoid()} tag={hashtag} />;
return null; return null;
}); });
} }
} }
if (events.length) { if (events.length) {
for (const event of events) { for (const event of events) {
parsedContent = reactStringReplace( parsedContent = reactStringReplace(
parsedContent, parsedContent,
event, event,
(match, i) => <MentionNote key={match + i} eventId={event} />, (match, i) => <MentionNote key={match + i} eventId={event} />,
); );
} }
} }
if (mentions.length) { if (mentions.length) {
for (const mention of mentions) { for (const mention of mentions) {
parsedContent = reactStringReplace( parsedContent = reactStringReplace(
parsedContent, parsedContent,
mention, mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />, (match, i) => <MentionUser key={match + i} pubkey={mention} />,
); );
} }
} }
parsedContent = reactStringReplace( parsedContent = reactStringReplace(
parsedContent, parsedContent,
/(https?:\/\/\S+)/g, /(https?:\/\/\S+)/g,
(match, i) => { (match, i) => {
const url = new URL(match); const url = new URL(match);
if (!linkPreview && canPreview(match)) { if (!linkPreview && canPreview(match)) {
linkPreview = match; linkPreview = match;
return <LinkPreview key={match + i} url={url.toString()} />; return <LinkPreview key={match + i} url={url.toString()} />;
} }
return ( return (
<Link <a
key={match + i} key={match + i}
to={url.toString()} href={url.toString()}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="break-p truncate inline-block w-full font-normal text-blue-500 hover:text-blue-600" className="break-p inline-block w-full truncate font-normal text-blue-500 hover:text-blue-600"
> >
{url.toString()} {url.toString()}
</Link> </a>
); );
}, },
); );
parsedContent = reactStringReplace(parsedContent, "\n", () => { parsedContent = reactStringReplace(parsedContent, "\n", () => {
return <div key={nanoid()} className="h-3" />; return <div key={nanoid()} className="h-3" />;
}); });
if (typeof parsedContent[0] === "string") { if (typeof parsedContent[0] === "string") {
parsedContent[0] = parsedContent[0].trimStart(); parsedContent[0] = parsedContent[0].trimStart();
} }
return parsedContent; return parsedContent;
} catch (e) { } catch (e) {
console.warn(event.id, `[parser] parse failed: ${e}`); console.warn(event.id, `[parser] parse failed: ${e}`);
return parsedContent; return parsedContent;
} }
}, [content]); }, [content]);
const translateContent = async () => { const translateContent = async () => {
try { try {
if (!translate.translatable) return; if (!translate.translatable) return;
const res = await fetch("https://translate.nostr.wine/translate", { const res = await fetch("https://translate.nostr.wine/translate", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
q: event.content, q: event.content,
target: storage.locale.slice(0, 2), target: storage.locale.slice(0, 2),
api_key: storage.settings.translateApiKey, api_key: storage.settings.translateApiKey,
}), }),
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
if (!res.ok) if (!res.ok)
toast.error( toast.error(
"Cannot connect to translate service, please try again later.", "Cannot connect to translate service, please try again later.",
); );
const data = await res.json(); const data = await res.json();
setContent(data.translatedText); setContent(data.translatedText);
setTranslate((state) => ({ ...state, translated: true })); setTranslate((state) => ({ ...state, translated: true }));
} catch (e) { } catch (e) {
console.error("translate api: ", String(e)); console.error("translate api: ", String(e));
} }
}; };
if (event.kind !== Kind.Text) { if (event.kind !== Kind.Text) {
return <NIP89 className={className} />; return <NIP89 className={className} />;
} }
return ( return (
<div className={cn(className)}> <div className={cn(className)}>
<div className="break-p select-text text-balance leading-normal whitespace-pre-line"> <div className="break-p select-text whitespace-pre-line text-balance leading-normal">
{richContent} {richContent}
</div> </div>
{storage.settings.translation && translate.translatable ? ( {storage.settings.translation && translate.translatable ? (
translate.translated ? ( translate.translated ? (
<button <button
type="button" type="button"
onClick={() => setContent(event.content)} onClick={() => setContent(event.content)}
className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none" className="mt-3 border-none text-sm text-blue-500 shadow-none hover:text-blue-600 focus:outline-none"
> >
Show original content Show original content
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
onClick={translateContent} onClick={translateContent}
className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none" className="mt-3 border-none text-sm text-blue-500 shadow-none hover:text-blue-600 focus:outline-none"
> >
Translate to {regionNames.of(storage.locale)} Translate to {regionNames.of(storage.locale)}
</button> </button>
) )
) : null} ) : null}
</div> </div>
); );
} }

View File

@ -2,7 +2,6 @@ import { PinIcon } from "@lume/icons";
import { NOSTR_MENTIONS } from "@lume/utils"; import { NOSTR_MENTIONS } from "@lume/utils";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { User } from "../../user"; import { User } from "../../user";
import { Hashtag } from "./hashtag"; import { Hashtag } from "./hashtag";
@ -64,15 +63,15 @@ export function MentionNote({
(match, i) => { (match, i) => {
const url = new URL(match); const url = new URL(match);
return ( return (
<Link <a
key={match + i} key={match + i}
to={url.toString()} href={url.toString()}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="break-p inline-block w-full truncate font-normal text-blue-500 hover:text-blue-600" className="break-p inline-block w-full truncate font-normal text-blue-500 hover:text-blue-600"
> >
{url.toString()} {url.toString()}
</Link> </a>
); );
}, },
); );
@ -126,12 +125,12 @@ export function MentionNote({
</div> </div>
{openable ? ( {openable ? (
<div className="flex h-10 items-center justify-between px-3"> <div className="flex h-10 items-center justify-between px-3">
<Link <a
to={`/events/${data.id}`} href={`/events/${data.id}`}
className="text-sm text-blue-500 hover:text-blue-600" className="text-sm text-blue-500 hover:text-blue-600"
> >
{t("note.showMore")} {t("note.showMore")}
</Link> </a>
<button <button
type="button" type="button"
className="inline-flex size-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-600 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700" className="inline-flex size-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-600 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"

View File

@ -1,7 +1,6 @@
import { useProfile } from "@lume/ark"; import { useProfile } from "@lume/ark";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export function MentionUser({ pubkey }: { pubkey: string }) { export function MentionUser({ pubkey }: { pubkey: string }) {
const { isLoading, isError, user } = useProfile(pubkey); const { isLoading, isError, user } = useProfile(pubkey);
@ -18,12 +17,12 @@ export function MentionUser({ pubkey }: { pubkey: string }) {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10"> <DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10">
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<Link <a
to={`/users/${pubkey}`} href={`/users/${pubkey}`}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
{t("note.buttons.viewProfile")} {t("note.buttons.viewProfile")}
</Link> </a>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button

View File

@ -2,14 +2,12 @@ import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
import { useArk } from "@lume/ark"; import { useArk } from "@lume/ark";
export function NoteMenu() { export function NoteMenu() {
const ark = useArk(); const ark = useArk();
const event = useNoteContext(); const event = useNoteContext();
const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
@ -55,7 +53,6 @@ export function NoteMenu() {
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button
type="button" type="button"
onClick={() => navigate(`/events/${event.id}`)}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
{t("note.menu.copyLink")} {t("note.menu.copyLink")}
@ -80,12 +77,12 @@ export function NoteMenu() {
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<Link <a
to={`/users/${event.pubkey}`} href={`/users/${event.pubkey}`}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
{t("note.menu.viewAuthor")} {t("note.menu.viewAuthor")}
</Link> </a>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button

View File

@ -3,32 +3,35 @@ import { cn } from "@lume/utils";
import { Note } from ".."; import { Note } from "..";
export function TextNote({ export function TextNote({
event, event,
className, className,
}: { event: Event; className?: string }) { }: {
return ( event: Event;
<Note.Provider event={event}> className?: string;
<Note.Root }) {
className={cn( return (
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950", <Note.Provider event={event}>
className, <Note.Root className={cn("flex flex-col", className)}>
)} <div className="flex h-14 items-center justify-between px-3">
> <Note.User className="flex-1 pr-2" />
<div className="flex items-center justify-between px-3 h-14"> <Note.Menu />
<Note.User className="flex-1 pr-2" /> </div>
<Note.Menu /> <div className="flex gap-3">
</div> <div className="size-10 shrink-0" />
<Note.Thread className="mb-2" /> <div className="flex-1">
<Note.Content className="min-w-0 px-3" /> <Note.Thread className="mb-2" />
<div className="flex items-center justify-between px-3 h-14"> <Note.Content className="min-w-0 px-3" />
<Note.Pin /> <div className="flex h-14 items-center justify-between px-3">
<div className="inline-flex items-center gap-4"> <Note.Pin />
<Note.Reply /> <div className="inline-flex items-center gap-4">
<Note.Repost /> <Note.Reply />
<Note.Zap /> <Note.Repost />
</div> <Note.Zap />
</div> </div>
</Note.Root> </div>
</Note.Provider> </div>
); </div>
</Note.Root>
</Note.Provider>
);
} }

View File

@ -1,7 +1,6 @@
import { PinIcon } from "@lume/icons"; import { PinIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Note } from "."; import { Note } from ".";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
import { useArk } from "@lume/ark"; import { useArk } from "@lume/ark";
@ -28,12 +27,12 @@ export function NoteThread({ className }: { className?: string }) {
<Note.Child eventId={thread.replyEventId} /> <Note.Child eventId={thread.replyEventId} />
) : null} ) : null}
<div className="inline-flex items-center justify-between"> <div className="inline-flex items-center justify-between">
<Link <a
to={`/events/${thread?.rootEventId || thread?.replyEventId}`} href={`/events/${thread?.rootEventId || thread?.replyEventId}`}
className="self-start text-blue-500 hover:text-blue-600" className="self-start text-blue-500 hover:text-blue-600"
> >
{t("note.showThread")} {t("note.showThread")}
</Link> </a>
<button <button
type="button" type="button"
className="inline-flex size-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-600 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700" className="inline-flex size-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-600 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"

View File

@ -9,17 +9,22 @@ export function NoteUser({ className }: { className?: string }) {
return ( return (
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
<HoverCard.Root> <HoverCard.Root>
<User.Root className={cn("flex items-center gap-3", className)}> <User.Root
<HoverCard.Trigger> className={cn("flex items-center justify-between", className)}
<User.Avatar className="size-9 shrink-0 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" /> >
</HoverCard.Trigger> <div className="flex gap-3">
<div className="flex h-6 flex-1 items-start justify-between gap-2"> <HoverCard.Trigger>
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" /> <User.Avatar className="size-11 shrink-0 rounded-xl object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<User.Time </HoverCard.Trigger>
time={event.created_at} <div>
className="text-neutral-500 dark:text-neutral-400" <User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
/> <User.NIP05 className="text-neutral-600 dark:text-neutral-400" />
</div>
</div> </div>
<User.Time
time={event.created_at}
className="text-neutral-500 dark:text-neutral-400"
/>
</User.Root> </User.Root>
<HoverCard.Portal> <HoverCard.Portal>
<HoverCard.Content <HoverCard.Content

View File

@ -2,43 +2,46 @@ import { VerifiedIcon } from "@lume/icons";
import { cn, displayNpub } from "@lume/utils"; import { cn, displayNpub } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
import { useArk } from "@lume/ark";
export function UserNip05({ className }: { className?: string }) { export function UserNip05({ className }: { className?: string }) {
const user = useUserContext(); const ark = useArk();
const user = useUserContext();
const { isLoading, data: verified } = useQuery({ const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.profile.nip05], queryKey: ["nip05", user?.profile.nip05],
queryFn: async () => { queryFn: async () => {
if (!user) return false; if (!user.profile?.nip05) return false;
if (!user.profile.nip05) return false;
return false;
},
enabled: !!user,
});
if (!user.profile) { const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
return ( return verify;
<div },
className={cn( enabled: !!user.profile,
"h-4 w-20 bg-black/20 dark:bg-white/20 rounded animate-pulse", });
className,
)}
/>
);
}
return ( if (!user.profile) {
<div className="inline-flex items-center gap-1"> return (
<p className={cn("text-sm", className)}> <div
{!user?.profile.nip05 className={cn(
? displayNpub(user.pubkey, 16) "h-4 w-20 animate-pulse rounded bg-black/20 dark:bg-white/20",
: user?.profile.nip05?.startsWith("_@") className,
? user?.profile.nip05?.replace("_@", "") )}
: user?.profile.nip05} />
</p> );
{!isLoading && verified ? ( }
<VerifiedIcon className="text-teal-500 size-4" />
) : null} return (
</div> <div className="inline-flex items-center gap-1">
); <p className={cn("text-sm", className)}>
{!user?.profile?.nip05
? displayNpub(user.pubkey, 16)
: user?.profile?.nip05?.startsWith("_@")
? user?.profile?.nip05?.replace("_@", "")
: user?.profile?.nip05}
</p>
{!isLoading && verified ? (
<VerifiedIcon className="size-4 text-teal-500" />
) : null}
</div>
);
} }

View File

@ -126,3 +126,15 @@ pub fn user_to_bech32(key: &str, relays: Vec<String>) -> Result<String, ()> {
Ok(profile.to_bech32().unwrap()) Ok(profile.to_bech32().unwrap())
} }
#[tauri::command(async)]
pub async fn verify_nip05(key: &str, nip05: &str) -> Result<bool, ()> {
let public_key = XOnlyPublicKey::from_str(key).unwrap();
let status = nip05::verify(public_key, nip05, None).await;
if let Ok(_) = status {
Ok(true)
} else {
Ok(false)
}
}