feat: add editor screen

This commit is contained in:
2024-02-23 14:56:24 +07:00
parent 64286aa354
commit 84584a4d1f
39 changed files with 917 additions and 493 deletions

View File

@@ -6,7 +6,6 @@
"dependencies": {
"@getalby/sdk": "^3.3.0",
"@lume/icons": "workspace:^",
"@lume/storage": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",

View File

@@ -7,6 +7,9 @@ import type {
} from "@lume/types";
import { invoke } from "@tauri-apps/api/core";
import { WebviewWindow } from "@tauri-apps/api/webview";
import { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { generateContentTags } from "@lume/utils";
export class Ark {
public account: Account;
@@ -147,17 +150,27 @@ export class Ark {
}
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
} catch (e) {
} catch {
return [];
}
}
public async publish(content: string) {
try {
const cmd: string = await invoke("publish", { content });
const g = await generateContentTags(content);
const eventContent = g.content;
const eventTags = g.tags;
const cmd: string = await invoke("publish", {
content: eventContent,
tags: eventTags,
});
return cmd;
} catch (e) {
console.error(String(e));
return false;
}
}
@@ -341,6 +354,61 @@ export class Ark {
}
}
public async upload(filePath?: string) {
try {
const allowExts = [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
];
let selected =
filePath ||
(
await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
})
).path;
if (!selected) return null;
const file = await readFile(selected);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (!res.ok) return null;
const json = await res.json();
const content = json.data[0];
return content.url as string;
} catch (e) {
console.error(String(e));
return null;
}
}
public open_thread(id: string) {
return new WebviewWindow(`event-${id}`, {
title: "Thread",
@@ -364,4 +432,17 @@ export class Ark {
titleBarStyle: "overlay",
});
}
public open_editor() {
return new WebviewWindow("editor", {
title: "Editor",
url: "/editor",
minWidth: 500,
width: 600,
height: 400,
hiddenTitle: true,
titleBarStyle: "overlay",
fileDropEnabled: true,
});
}
}

View File

@@ -6,7 +6,6 @@
"dependencies": {
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/storage": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@tanstack/react-query": "^5.20.5",

View File

@@ -1,25 +0,0 @@
{
"name": "@lume/storage",
"version": "0.0.0",
"main": "./src/index.ts",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@tauri-apps/plugin-store": "2.0.0-beta.0",
"react": "^18.2.0",
"scheduler": "^0.23.0",
"use-context-selector": "^1.4.1",
"virtua": "^0.27.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@lume/tsconfig": "workspace:*",
"@lume/types": "workspace:*",
"@lume/utils": "workspace:^",
"@types/react": "^18.2.55",
"typescript": "^5.3.3"
}
}

View File

@@ -1 +0,0 @@
export * from "./provider";

View File

@@ -1,118 +0,0 @@
import { LumeColumn } from "@lume/types";
import { locale, platform } from "@tauri-apps/plugin-os";
import { Store } from "@tauri-apps/plugin-store";
import {
MutableRefObject,
PropsWithChildren,
useCallback,
useRef,
useState,
} from "react";
import { createContext, useContextSelector } from "use-context-selector";
import { type VListHandle } from "virtua";
import { LumeStorage } from "./storage";
const platformName = await platform();
const osLocale = (await locale()).slice(0, 2);
const store = new Store("lume.dat");
const storage = new LumeStorage(store, platformName, osLocale);
await storage.init();
type StorageContext = {
storage: LumeStorage;
column: {
columns: LumeColumn[];
vlistRef: MutableRefObject<VListHandle>;
create: (column: LumeColumn) => void;
remove: (id: number) => void;
move: (id: number, position: "left" | "right") => void;
update: (id: number, title: string, content: string) => void;
};
};
const StorageContext = createContext<StorageContext>(null);
export const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const vlistRef = useRef<VListHandle>(null);
const [columns, setColumns] = useState<LumeColumn[]>([
{
id: 1,
title: "Newsfeed",
content: "",
},
{
id: 2,
title: "For You",
content: "",
},
]);
const create = useCallback((column: LumeColumn) => {
setColumns((prev) => [...prev, column]);
vlistRef?.current.scrollToIndex(columns.length);
}, []);
const remove = useCallback((id: number) => {
setColumns((prev) => prev.filter((t) => t.id !== id));
}, []);
const update = useCallback(
(id: number, title: string, content: string) => {
const newCols = columns.map((col) => {
if (col.id === id) {
return { ...col, title, content };
}
return col;
});
setColumns(newCols);
},
[columns],
);
const move = useCallback(
(id: number, position: "left" | "right") => {
const newCols = [...columns];
const col = newCols.find((el) => el.id === id);
const colIndex = newCols.findIndex((el) => el.id === id);
newCols.splice(colIndex, 1);
if (position === "left") newCols.splice(colIndex - 1, 0, col);
if (position === "right") newCols.splice(colIndex + 1, 0, col);
setColumns(newCols);
},
[columns],
);
return (
<StorageContext.Provider
value={{
storage,
column: { columns, vlistRef, create, remove, move, update },
}}
>
{children}
</StorageContext.Provider>
);
};
export const useStorage = () => {
const context = useContextSelector(StorageContext, (state) => state.storage);
if (context === undefined) {
throw new Error("Storage Provider is required");
}
return context;
};
export const useColumn = () => {
const context = useContextSelector(StorageContext, (state) => state.column);
if (context === undefined) {
throw new Error("Storage Provider is required");
}
return context;
};

View File

@@ -1,55 +0,0 @@
import { Settings } from "@lume/types";
import { Platform } from "@tauri-apps/plugin-os";
import { Store } from "@tauri-apps/plugin-store";
export class LumeStorage {
#store: Store;
readonly platform: Platform;
readonly locale: string;
public settings: Settings;
constructor(store: Store, platform: Platform, locale: string) {
this.#store = store;
this.locale = locale;
this.platform = platform;
this.settings = {
autoupdate: false,
nsecbunker: false,
media: true,
hashtag: true,
lowPower: false,
translation: false,
translateApiKey: "",
instantZap: false,
defaultZapAmount: 21,
nwc: "",
};
}
public async init() {
this.loadSettings();
}
public async loadSettings() {
const data = await this.#store.get("settings");
if (!data) return;
const settings = JSON.parse(data as string) as Settings;
if (Object.keys(settings).length) {
for (const [key, value] of Object.entries(settings)) {
this.settings[key] = value;
}
}
}
public async createSetting(key: string, value: string | number | boolean) {
this.settings[key] = value;
const settings: Settings = JSON.parse(await this.#store.get("settings"));
const newSettings = { ...settings, key: value };
await this.#store.set("settings", newSettings);
await this.#store.save();
}
}

View File

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

View File

@@ -54,6 +54,11 @@ export interface Metadata {
lud16?: string;
}
export interface Contact {
pubkey: string;
profile: Metadata;
}
export interface Account {
npub: string;
contacts?: string[];

View File

@@ -7,7 +7,6 @@
"@getalby/sdk": "^3.3.0",
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/storage": "workspace:^",
"@lume/utils": "workspace:^",
"@nostr-dev-kit/ndk": "^2.4.1",
"@radix-ui/react-accordion": "^1.1.2",

View File

@@ -1,89 +1,80 @@
import {
ChevronDownIcon,
MoveLeftIcon,
MoveRightIcon,
RefreshIcon,
TrashIcon,
ChevronDownIcon,
MoveLeftIcon,
MoveRightIcon,
RefreshIcon,
TrashIcon,
} from "@lume/icons";
import { useColumn } from "@lume/storage";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useColumnContext } from "./provider";
export function ColumnHeader({
queryKey,
}: {
queryKey?: string[];
}) {
const { t } = useTranslation();
const { move, remove } = useColumn();
export function ColumnHeader({ queryKey }: { queryKey?: string[] }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const column = useColumnContext();
const queryClient = useQueryClient();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
return (
<DropdownMenu.Root>
<div className="flex items-center justify-center gap-2 px-3 w-full border-b h-11 shrink-0 border-neutral-100 dark:border-neutral-900">
<DropdownMenu.Trigger asChild>
<div className="inline-flex items-center gap-1.5">
<div className="text-[13px] font-medium">{column.title}</div>
<ChevronDownIcon className="size-5" />
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
className="flex w-[200px] p-2 flex-col overflow-hidden rounded-2xl bg-white/50 dark:bg-black/50 ring-1 ring-black/10 dark:ring-white/10 backdrop-blur-2xl focus:outline-none"
>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 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"
>
<RefreshIcon className="size-4" />
{t("global.refresh")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "left")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 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"
>
<MoveLeftIcon className="size-4" />
{t("global.moveLeft")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "right")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 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"
>
<MoveRightIcon className="size-4" />
{t("global.moveRight")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => remove(column.id)}
className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="size-4" />
{t("global.delete")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</div>
</DropdownMenu.Root>
);
return (
<DropdownMenu.Root>
<div className="flex h-11 w-full shrink-0 items-center justify-center gap-2 border-b border-neutral-100 px-3 dark:border-neutral-900">
<DropdownMenu.Trigger asChild>
<div className="inline-flex items-center gap-1.5">
<div className="text-[13px] font-medium">{column.title}</div>
<ChevronDownIcon className="size-5" />
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
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>
<button
type="button"
onClick={refresh}
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"
>
<RefreshIcon className="size-4" />
{t("global.refresh")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "left")}
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"
>
<MoveLeftIcon className="size-4" />
{t("global.moveLeft")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "right")}
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"
>
<MoveRightIcon className="size-4" />
{t("global.moveRight")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-black/10 dark:bg-white/10" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => remove(column.id)}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-red-500 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="size-4" />
{t("global.delete")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</div>
</DropdownMenu.Root>
);
}

View File

@@ -1,6 +1,4 @@
import { useArk } from "@lume/ark";
import { LoaderIcon, TrashIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { cn, editorValueAtom } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import { useAtom } from "jotai";

View File

@@ -1,4 +1,3 @@
import { NDKCacheUserProfile } from "@lume/types";
import { ReactNode } from "react";
import ReactDOM from "react-dom";
import { BaseEditor, Transforms } from "slate";

View File

@@ -21,12 +21,10 @@ export function NoteChild({
const richContent = useMemo(() => {
if (!data) return "";
let parsedContent: string | ReactNode[] = data.content.replace(
/\n+/g,
"\n",
);
let parsedContent: string | ReactNode[] =
data.content.substring(0, 160) + "...";
const text = parsedContent as string;
const text = data.content;
const words = text.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
@@ -104,7 +102,7 @@ export function NoteChild({
<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="content-break mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
<div className="content-break mt-6 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{richContent}
</div>
</div>

View File

@@ -103,9 +103,9 @@ export function MentionNote({
}
return (
<div className="my-1.5 flex w-full cursor-default flex-col rounded-xl bg-neutral-100 pt-1 ring-1 ring-black/5 dark:bg-neutral-900 dark:ring-white/5">
<div className="my-1 flex w-full cursor-default flex-col rounded-xl bg-neutral-100 px-3 pt-1 ring-1 ring-black/5 dark:bg-neutral-900 dark:ring-white/5">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-10 items-center gap-2 px-3">
<User.Root className="flex h-10 items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
<div className="inline-flex flex-1 items-center gap-2">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
@@ -117,11 +117,11 @@ export function MentionNote({
</div>
</User.Root>
</User.Provider>
<div className="line-clamp-4 select-text whitespace-normal text-balance px-3 leading-normal">
<div className="line-clamp-4 select-text whitespace-normal text-balance leading-normal">
{richContent}
</div>
{openable ? (
<div className="flex h-10 items-center justify-between px-3">
<div className="flex h-10 items-center justify-between">
<button
type="button"
onClick={() => ark.open_thread(data.id)}

View File

@@ -37,7 +37,7 @@ export function ImagePreview({ url }: { url: string }) {
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div
onClick={open}
className="group relative my-1.5 rounded-xl ring-1 ring-black/5 dark:ring-white/5"
className="group relative my-1 rounded-xl ring-1 ring-black/5 dark:ring-white/5"
>
<img
src={url}

View File

@@ -54,7 +54,7 @@ export function LinkPreview({ url }: { url: string }) {
href={url}
target="_blank"
rel="noreferrer"
className="my-1.5 flex w-full flex-col overflow-hidden rounded-xl bg-neutral-100 ring-1 ring-black/5 dark:bg-neutral-900 dark:ring-white/5"
className="my-1 flex w-full flex-col overflow-hidden rounded-xl bg-neutral-100 ring-1 ring-black/5 dark:bg-neutral-900 dark:ring-white/5"
>
{isImage(data.image) ? (
<img

View File

@@ -9,7 +9,7 @@ import {
export function VideoPreview({ url }: { url: string }) {
return (
<div className="my-1.5 w-full overflow-hidden rounded-xl ring-1 ring-black/5 dark:ring-white/5">
<div className="my-1 w-full overflow-hidden rounded-xl ring-1 ring-black/5 dark:ring-white/5">
<MediaController>
<video
slot="media"

View File

@@ -1,55 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ReplyForm } from "./editor/replyForm";
import { Reply } from "./note/primitives/reply";
import { EventWithReplies } from "@lume/types";
export function ReplyList({
eventId,
className,
}: {
eventId: string;
className?: string;
}) {
const ark = useArk();
const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => {
async function getReplies() {
const events = await ark.get_event_thread(eventId);
setData(events);
}
getReplies();
}, [eventId]);
return (
<div
className={cn(
"flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900",
className,
)}
>
{!data ? (
<div className="mt-4 flex h-16 items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : data.length === 0 ? (
<div className="mt-4 flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
{t("note.reply.empty")}
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { useNavigate, useParams } from "react-router-dom";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "../replyList";
import { ThreadNote } from "../note/primitives/thread";
export function EventRoute() {

View File

@@ -1,6 +1,8 @@
export * from "./src/constants";
export * from "./src/delay";
export * from "./src/formater";
export * from "./src/editor";
export * from "./src/nip01";
export * from "./src/nip94";
export * from "./src/notification";
export * from "./src/hooks/useNetworkStatus";

View File

@@ -13,12 +13,16 @@
"dayjs": "^1.11.10",
"jotai": "^2.6.4",
"nostr-tools": "^2.1.9",
"react": "^18.2.0"
"react": "^18.2.0",
"react-dom": "^18.2.0",
"slate": "^0.101.5",
"slate-react": "^0.101.6"
},
"devDependencies": {
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"tailwind-merge": "^2.2.1",
"typescript": "^5.3.3"
}

View File

@@ -0,0 +1,97 @@
import ReactDOM from "react-dom";
import { ReactNode } from "react";
import { BaseEditor, Transforms } from "slate";
import { ReactEditor } from "slate-react";
import { Contact } from "@lume/types";
export const Portal = ({ children }: { children?: ReactNode }) => {
return typeof document === "object"
? ReactDOM.createPortal(children, document.body)
: null;
};
export const isImagePath = (path: string) => {
for (const suffix of ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]) {
if (path.endsWith(suffix)) return true;
}
return false;
};
export const isImageUrl = (url: string) => {
try {
if (!url) return false;
const ext = new URL(url).pathname.split(".").pop();
return ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"].includes(ext);
} catch {
return false;
}
};
export const insertImage = (editor: ReactEditor | BaseEditor, url: string) => {
const text = { text: "" };
const image = [
{
type: "image",
url,
children: [text],
},
];
const extraText = [
{
type: "paragraph",
children: [text],
},
];
// @ts-ignore, idk
ReactEditor.focus(editor);
Transforms.insertNodes(editor, image);
Transforms.insertNodes(editor, extraText);
};
export const insertMention = (
editor: ReactEditor | BaseEditor,
contact: Contact,
) => {
const text = { text: "" };
const mention = {
type: "mention",
npub: `nostr:${contact.pubkey}`,
name: contact.profile.name || contact.profile.display_name || "anon",
children: [text],
};
const extraText = [
{
type: "paragraph",
children: [text],
},
];
// @ts-ignore, idk
ReactEditor.focus(editor);
Transforms.insertNodes(editor, mention);
Transforms.insertNodes(editor, extraText);
};
export const insertNostrEvent = (
editor: ReactEditor | BaseEditor,
eventId: string,
) => {
const text = { text: "" };
const event = [
{
type: "event",
eventId: `nostr:${eventId}`,
children: [text],
},
];
const extraText = [
{
type: "paragraph",
children: [text],
},
];
Transforms.insertNodes(editor, event);
Transforms.insertNodes(editor, extraText);
};

View File

@@ -0,0 +1,96 @@
import { nip19 } from "nostr-tools";
import { EventPointer, ProfilePointer } from "nostr-tools/lib/types/nip19";
// Borrow from NDK
// https://github.com/nostr-dev-kit/ndk/blob/master/ndk/src/events/content-tagger.ts
export async function generateContentTags(content: string) {
let promises: Promise<void>[] = [];
let tags: string[][] = [];
const tagRegex = /(@|nostr:)(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]+/g;
const hashtagRegex = /#(\w+)/g;
const addTagIfNew = (t: string[]) => {
if (!tags.find((t2) => t2[0] === t[0] && t2[1] === t[1])) {
tags.push(t);
}
};
content = content.replace(tagRegex, (tag) => {
try {
const entity = tag.split(/(@|nostr:)/)[2];
const { type, data } = nip19.decode(entity);
let t: string[] | undefined;
switch (type) {
case "npub":
t = ["p", data as string];
break;
case "nprofile":
t = ["p", (data as ProfilePointer).pubkey as string];
break;
case "note":
promises.push(
new Promise(async (resolve) => {
addTagIfNew(["e", data, "", "mention"]);
resolve();
}),
);
break;
case "nevent":
promises.push(
new Promise(async (resolve) => {
let { id, relays, author } = data as EventPointer;
// If the nevent doesn't have a relay specified, try to get one
if (!relays || relays.length === 0) {
relays = [""];
}
addTagIfNew(["e", id, relays[0], "mention"]);
if (author) addTagIfNew(["p", author]);
resolve();
}),
);
break;
case "naddr":
promises.push(
new Promise(async (resolve) => {
const id = [data.kind, data.pubkey, data.identifier].join(":");
let relays = data.relays ?? [];
// If the naddr doesn't have a relay specified, try to get one
if (relays.length === 0) {
relays = [""];
}
addTagIfNew(["a", id, relays[0], "mention"]);
addTagIfNew(["p", data.pubkey]);
resolve();
}),
);
break;
default:
return tag;
}
if (t) addTagIfNew(t);
return `nostr:${entity}`;
} catch (error) {
return tag;
}
});
await Promise.all(promises);
content = content.replace(hashtagRegex, (tag, word) => {
const t: string[] = ["t", word];
if (!tags.find((t2) => t2[0] === t[0] && t2[1] === t[1])) {
tags.push(t);
}
return tag; // keep the original tag in the content
});
return { content, tags };
}