feat: add editor screen
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./provider";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "@lume/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
5
packages/types/index.d.ts
vendored
5
packages/types/index.d.ts
vendored
@@ -54,6 +54,11 @@ export interface Metadata {
|
||||
lud16?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
pubkey: string;
|
||||
profile: Metadata;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
npub: string;
|
||||
contacts?: string[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NDKCacheUserProfile } from "@lume/types";
|
||||
import { ReactNode } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { BaseEditor, Transforms } from "slate";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
97
packages/utils/src/editor.ts
Normal file
97
packages/utils/src/editor.ts
Normal 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);
|
||||
};
|
||||
96
packages/utils/src/nip01.ts
Normal file
96
packages/utils/src/nip01.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user