278 lines
7.8 KiB
TypeScript
278 lines
7.8 KiB
TypeScript
import { commands } from "@/commands.gen";
|
|
import { appSettings, cn, replyTime } from "@/commons";
|
|
import { Note } from "@/components/note";
|
|
import { type LumeEvent, LumeWindow } from "@/system";
|
|
import { CaretDown } from "@phosphor-icons/react";
|
|
import { Link, useSearch } from "@tanstack/react-router";
|
|
import { useStore } from "@tanstack/react-store";
|
|
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
|
import { nip19 } from "nostr-tools";
|
|
import {
|
|
type ReactNode,
|
|
memo,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import reactStringReplace from "react-string-replace";
|
|
import { Hashtag } from "./note/mentions/hashtag";
|
|
import { MentionUser } from "./note/mentions/user";
|
|
import { User } from "./user";
|
|
|
|
export const ReplyNote = memo(function ReplyNote({
|
|
event,
|
|
className,
|
|
}: {
|
|
event: LumeEvent;
|
|
className?: string;
|
|
}) {
|
|
const trustedOnly = useStore(appSettings, (state) => state.trusted_only);
|
|
const search = useSearch({ strict: false });
|
|
|
|
const [isTrusted, setIsTrusted] = useState<boolean>(null);
|
|
|
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
|
|
const menuItems = await Promise.all([
|
|
MenuItem.new({
|
|
text: "View Profile",
|
|
action: () => LumeWindow.openProfile(event.pubkey),
|
|
}),
|
|
MenuItem.new({
|
|
text: "Copy Public Key",
|
|
action: async () => {
|
|
const pubkey = await event.pubkeyAsBech32();
|
|
await writeText(pubkey);
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const menu = await Menu.new({
|
|
items: menuItems,
|
|
});
|
|
|
|
await menu.popup().catch((e) => console.error(e));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
async function check() {
|
|
const res = await commands.isTrustedUser(event.pubkey);
|
|
|
|
if (res.status === "ok") {
|
|
setIsTrusted(res.data);
|
|
}
|
|
}
|
|
|
|
if (trustedOnly) {
|
|
check();
|
|
}
|
|
}, []);
|
|
|
|
if (isTrusted !== null && isTrusted === false) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Note.Provider event={event}>
|
|
<User.Provider pubkey={event.pubkey}>
|
|
<Note.Root className={cn("flex gap-2.5", className)}>
|
|
<User.Root className="shrink-0">
|
|
<button type="button" onClick={(e) => showContextMenu(e)}>
|
|
<User.Avatar className="size-8 rounded-full" />
|
|
</button>
|
|
</User.Root>
|
|
<div className="flex-1 flex flex-col gap-1">
|
|
<div>
|
|
<User.Name
|
|
className="mr-2 shrink-0 inline font-medium text-blue-500"
|
|
suffix=":"
|
|
/>
|
|
<Content
|
|
text={event.content}
|
|
className="inline select-text text-balance content-break overflow-hidden"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-between">
|
|
<span className="text-sm text-neutral-500">
|
|
{replyTime(event.created_at)}
|
|
</span>
|
|
<div className="flex items-center justify-end gap-5">
|
|
<Note.Reply smol />
|
|
<Note.Repost smol />
|
|
<Note.Zap smol />
|
|
</div>
|
|
</div>
|
|
{event.replies?.length ? (
|
|
<div className="relative">
|
|
<div className="pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
|
|
{event.replies.slice(0, 2).map((reply) => (
|
|
<ChildReply key={reply.id} event={reply} />
|
|
))}
|
|
{event.replies.length > 2 ? (
|
|
<Link
|
|
to="/columns/replies/$id"
|
|
params={{ id: event.id }}
|
|
search={{ ...search }}
|
|
state={{ events: event.replies }}
|
|
>
|
|
<div className="inline-flex items-center gap-1 text-sm font-semibold text-blue-500">
|
|
<div>All {event.replies.length} replies</div>
|
|
<CaretDown className="size-3" weight="fill" />
|
|
</div>
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</Note.Root>
|
|
</User.Provider>
|
|
</Note.Provider>
|
|
);
|
|
});
|
|
|
|
function ChildReply({ event }: { event: LumeEvent }) {
|
|
const search = useSearch({ strict: false });
|
|
|
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
|
|
const menuItems = await Promise.all([
|
|
MenuItem.new({
|
|
text: "View Profile",
|
|
action: () => LumeWindow.openProfile(event.pubkey),
|
|
}),
|
|
MenuItem.new({
|
|
text: "Copy Public Key",
|
|
action: async () => {
|
|
const pubkey = await event.pubkeyAsBech32();
|
|
await writeText(pubkey);
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const menu = await Menu.new({
|
|
items: menuItems,
|
|
});
|
|
|
|
await menu.popup().catch((e) => console.error(e));
|
|
}, []);
|
|
|
|
return (
|
|
<Note.Provider event={event}>
|
|
<User.Provider pubkey={event.pubkey}>
|
|
<div className="group flex flex-col gap-1">
|
|
<div>
|
|
<User.Root className="inline mr-2">
|
|
<button type="button" onClick={(e) => showContextMenu(e)}>
|
|
<User.Name className="font-medium text-blue-500" suffix=":" />
|
|
</button>
|
|
</User.Root>
|
|
<Content
|
|
text={event.content}
|
|
className="inline select-text text-balance content-break overflow-hidden"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-between">
|
|
<span className="text-sm text-neutral-500">
|
|
{replyTime(event.created_at)}
|
|
</span>
|
|
<div className="invisible group-hover:visible flex items-center justify-end gap-5">
|
|
<Note.Reply smol />
|
|
<Note.Repost smol />
|
|
<Note.Zap smol />
|
|
</div>
|
|
</div>
|
|
{event.replies?.length ? (
|
|
<div className="relative">
|
|
<div className="pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
|
|
{event.replies.slice(0, 2).map((reply) => (
|
|
<ChildReply key={reply.id} event={reply} />
|
|
))}
|
|
{event.replies.length > 2 ? (
|
|
<Link
|
|
to="/columns/replies/$id"
|
|
params={{ id: event.id }}
|
|
search={{ ...search }}
|
|
state={{ events: event.replies }}
|
|
className="block"
|
|
>
|
|
<div className="inline-flex items-center gap-1 text-sm font-semibold text-blue-500">
|
|
<div>All {event.replies.length} replies</div>
|
|
<CaretDown className="size-3" weight="fill" />
|
|
</div>
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</User.Provider>
|
|
</Note.Provider>
|
|
);
|
|
}
|
|
|
|
function Content({ text, className }: { text: string; className?: string }) {
|
|
const content = useMemo(() => {
|
|
let replacedText: ReactNode[] | string = text.trim();
|
|
|
|
const nostr = replacedText
|
|
.split(/\s+/)
|
|
.filter((w) => w.startsWith("nostr:"));
|
|
|
|
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
|
|
<a
|
|
key={match + i}
|
|
href={match}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-blue-600 dark:text-blue-400 !underline"
|
|
>
|
|
{match}
|
|
</a>
|
|
));
|
|
|
|
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
|
|
<Hashtag key={match + i} tag={match} />
|
|
));
|
|
|
|
for (const word of nostr) {
|
|
const bech32 = word.replace("nostr:", "");
|
|
const data = nip19.decode(bech32);
|
|
|
|
switch (data.type) {
|
|
case "npub":
|
|
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
|
<MentionUser key={match + i} pubkey={data.data} />
|
|
));
|
|
break;
|
|
case "nprofile":
|
|
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
|
<MentionUser key={match + i} pubkey={data.data.pubkey} />
|
|
));
|
|
break;
|
|
default:
|
|
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
|
<a
|
|
key={match + i}
|
|
href={`https://njump.me/${bech32}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-blue-600 dark:text-blue-400 !underline"
|
|
>
|
|
{match}
|
|
</a>
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return replacedText;
|
|
}, [text]);
|
|
|
|
return <div className={className}>{content}</div>;
|
|
}
|