chore: clean up

This commit is contained in:
reya
2024-01-03 11:03:56 +07:00
parent 698f5a5d6d
commit 9f27d68533
29 changed files with 322 additions and 1944 deletions

View File

@@ -1,6 +1,5 @@
{
"name": "lume",
"description": "the communication app",
"private": true,
"version": "3.0.0",
"scripts": {
@@ -22,7 +21,6 @@
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@nostr-dev-kit/ndk": "^2.3.2",
"@nostr-fetch/adapter-ndk": "^0.14.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
@@ -32,7 +30,6 @@
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.0",
"@tauri-apps/api": "2.0.0-alpha.13",
@@ -48,25 +45,8 @@
"@tauri-apps/plugin-sql": "2.0.0-alpha.5",
"@tauri-apps/plugin-updater": "2.0.0-alpha.5",
"@tauri-apps/plugin-upload": "2.0.0-alpha.5",
"@tiptap/extension-character-count": "^2.1.13",
"@tiptap/extension-document": "^2.1.13",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-paragraph": "^2.1.13",
"@tiptap/extension-placeholder": "^2.1.13",
"@tiptap/extension-text": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",
"@tiptap/suggestion": "^2.1.13",
"@vidstack/react": "^1.9.8",
"clsx": "^2.1.0",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.16",
"html-to-text": "^9.0.5",
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.1.0",
"markdown-to-jsx": "^7.4.0",
"minidenticons": "^4.2.0",
"nanoid": "^5.0.4",
"nostr-fetch": "^0.14.1",
@@ -76,20 +56,15 @@
"react-currency-input-field": "^3.6.14",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.2",
"react-hotkeys-hook": "^4.4.1",
"react-router-dom": "^6.21.1",
"react-string-replace": "^1.1.1",
"smol-toml": "^1.1.3",
"sonner": "^1.3.1",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.8",
"virtua": "^0.18.1"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@types/html-to-text": "^9.0.4",
"@types/node": "^20.10.6",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
@@ -99,8 +74,9 @@
"encoding": "^0.1.13",
"postcss": "^8.4.32",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^4.5.1",
"vite": "^5.0.10",
"vite-tsconfig-paths": "^4.2.3"
}
}

View File

@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 217 KiB

View File

@@ -14,7 +14,7 @@
}
.prose :where(iframe):not(:where([class~='not-prose'] *)) {
@apply mx-auto aspect-video h-auto w-full;
@apply w-full h-auto mx-auto aspect-video;
}
}
@@ -42,11 +42,3 @@ input::-ms-clear {
.border {
background-clip: padding-box;
}
.ProseMirror p.is-empty::before {
@apply pointer-events-none float-left h-0 text-neutral-600 content-[attr(data-placeholder)] dark:text-neutral-400;
}
.ProseMirror img.ProseMirror-selectednode {
@apply outline-blue-500;
}

View File

@@ -1,12 +1,6 @@
import { useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import {
AppLayout,
AuthLayout,
ComposerLayout,
HomeLayout,
SettingsLayout,
} from "@lume/ui";
import { AppLayout, AuthLayout, HomeLayout, SettingsLayout } from "@lume/ui";
import { fetch } from "@tauri-apps/plugin-http";
import {
RouterProvider,
@@ -70,44 +64,6 @@ export default function App() {
return { Component: RelayScreen };
},
},
{
path: "new",
element: <ComposerLayout />,
children: [
{
index: true,
async lazy() {
const { NewPostScreen } = await import("./routes/new/post");
return { Component: NewPostScreen };
},
},
{
path: "article",
async lazy() {
const { NewArticleScreen } = await import(
"./routes/new/article"
);
return { Component: NewArticleScreen };
},
},
{
path: "file",
async lazy() {
const { NewFileScreen } = await import("./routes/new/file");
return { Component: NewFileScreen };
},
},
{
path: "privkey",
async lazy() {
const { NewPrivkeyScreen } = await import(
"./routes/new/privkey"
);
return { Component: NewPrivkeyScreen };
},
},
],
},
{
path: "settings",
element: <SettingsLayout />,
@@ -304,8 +260,8 @@ export default function App() {
<RouterProvider
router={router}
fallbackElement={
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin" />
<div className="flex items-center justify-center w-full h-full">
<LoaderIcon className="w-6 h-6 animate-spin" />
</div>
}
future={{ v7_startTransition: true }}

View File

@@ -84,7 +84,7 @@ export function HomeScreen() {
{columns.map((column) => renderItem(column))}
</VList>
<div className="absolute bottom-3 right-3">
<div className="flex items-center gap-1 p-1 bg-black/50 backdrop-blur-xl rounded-xl">
<div className="flex items-center gap-1 p-1 bg-black/50 dark:bg-white/30 backdrop-blur-xl rounded-xl">
<button
type="button"
onClick={() => {

View File

@@ -1,314 +0,0 @@
import { useArk } from "@lume/ark";
import {
BoldIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ItalicIcon,
LoaderIcon,
ThreadsIcon,
} from "@lume/icons";
import { NDKKind, NDKTag } from "@nostr-dev-kit/ndk";
import CharacterCount from "@tiptap/extension-character-count";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, FloatingMenu, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { twMerge } from "tailwind-merge";
import { Markdown } from "tiptap-markdown";
import {
ArticleCoverUploader,
MediaUploader,
MentionPopup,
} from "./components";
export function NewArticleScreen() {
const ark = useArk();
const [height, setHeight] = useState(0);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const [summary, setSummary] = useState({ open: false, content: "" });
const [cover, setCover] = useState("");
const navigate = useNavigate();
const containerRef = useRef(null);
const ident = useMemo(() => String(Date.now()), []);
const editor = useEditor({
extensions: [
StarterKit.configure(),
Placeholder.configure({ placeholder: "Type something..." }),
Image.configure({
HTMLAttributes: {
class:
"rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700",
},
}),
CharacterCount.configure(),
Markdown.configure({
html: false,
tightLists: true,
linkify: true,
transformPastedText: true,
}),
],
content: JSON.parse(localStorage.getItem("editor-post") || "{}"),
editorProps: {
attributes: {
class:
"outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500",
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem("editor-article", jsonContent);
},
});
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setLoading(true);
// get markdown content
const content = editor.storage.markdown.getMarkdown();
// define tags
const tags: NDKTag[] = [
["d", ident],
["title", title],
["image", cover],
["summary", summary.content],
["published_at", String(Math.floor(Date.now() / 1000))],
];
// add hashtag to tags if present
const hashtags = content
.split(/\s/gm)
.filter((s: string) => s.startsWith("#"));
if (hashtags) {
for (const tag of hashtags) {
tags.push(["t", tag.replace("#", "")]);
}
}
// publish
const publish = await ark.createEvent({
content,
tags,
kind: NDKKind.Article,
});
if (publish) {
toast.success(
`Broadcasted to ${publish.seens.length} relays successfully.`,
);
// update state
setLoading(false);
// reset editor
editor.commands.clearContent();
localStorage.setItem("editor-article", "{}");
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return (
<div className="flex flex-1 flex-col justify-between">
<div className="flex-1 overflow-y-auto">
<div
className="flex flex-col gap-4"
ref={containerRef}
style={{ height: `${height}px` }}
>
{cover ? (
<img
src={cover}
alt="post cover"
className="h-72 w-full rounded-lg object-cover"
/>
) : null}
<div className="group flex justify-between gap-2">
<input
name="title"
className="h-9 flex-1 border-none bg-transparent px-0 text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 focus:border-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="Untitled"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div
className={twMerge(
"inline-flex shrink-0 gap-2 group-hover:inline-flex",
title.length > 0 ? "" : "hidden",
)}
>
<ArticleCoverUploader setCover={setCover} />
<button
type="button"
onClick={() =>
setSummary((prev) => ({ ...prev, open: !prev.open }))
}
className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
<ThreadsIcon className="h-4 w-4" />
Add summary
</button>
</div>
</div>
{summary.open ? (
<div className="flex gap-3">
<div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
<div className="flex-1">
<textarea
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="A brief summary of your article"
value={summary.content}
onChange={(e) =>
setSummary((prev) => ({ ...prev, content: e.target.value }))
}
/>
</div>
</div>
) : null}
<div>
{editor && (
<FloatingMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900"
>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("heading", { level: 1 })
? "bg-white shadow dark:bg-black"
: "",
)}
>
<Heading1Icon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("heading", { level: 2 })
? "bg-white shadow dark:bg-black"
: "",
)}
>
<Heading2Icon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("heading", { level: 3 })
? "bg-white shadow dark:bg-black"
: "",
)}
>
<Heading3Icon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("bold")
? "bg-white shadow dark:bg-black"
: "",
)}
>
<BoldIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
editor.isActive("italic")
? "bg-white shadow dark:bg-black"
: "",
)}
>
<ItalicIcon className="h-5 w-5" />
</button>
</FloatingMenu>
)}
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</div>
</div>
</div>
<div>
<div className="mb-3 flex h-12 w-full items-center rounded-lg bg-yellow-100 px-3 text-yellow-700">
<p className="text-sm">
Article editor is still in beta. If you need a stable and more
reliable feature, you can use <b>Habla (habla.news)</b> instead.
</p>
</div>
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-3">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} characters
</span>
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
-
</span>
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
<b>Identifier:</b>
{ident}
</span>
</div>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={() => submit()}
disabled={editor?.isEmpty}
className="inline-flex h-9 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading === true ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
"Publish article"
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,86 +0,0 @@
import { ImageIcon, LoaderIcon } from "@lume/icons";
import { message, open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { useState } from "react";
export function ArticleCoverUploader({ setCover }) {
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: "Media",
extensions: [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readFile(selected.path);
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) {
const json = await res.json();
const content = json.data[0];
setCover(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
return (
<button
type="button"
onClick={uploadToNostrBuild}
className="inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ImageIcon className="h-4 w-4" />
Add cover
</>
)}
</button>
);
}

View File

@@ -1,5 +0,0 @@
export * from './articleCoverUploader';
export * from './mediaUploader';
export * from './mentionPopup';
export * from './mentionPopupItem';
export * from './mentionList';

View File

@@ -1,46 +0,0 @@
import { useArk } from "@lume/ark";
import { MediaIcon } from "@lume/icons";
import { message } from "@tauri-apps/plugin-dialog";
import { Editor } from "@tiptap/react";
import { useState } from "react";
export function MediaUploader({ editor }: { editor: Editor }) {
const ark = useArk();
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload({
fileExts: ["mp4", "mp3", "webm", "mkv", "avi", "mov"],
});
if (image) {
editor.commands.setImage({ src: image });
editor.commands.createParagraphNear();
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
return (
<button
type="button"
onClick={() => uploadToNostrBuild()}
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<MediaIcon className="h-5 w-5" />
{loading ? "Uploading..." : "Add media"}
</button>
);
}

View File

@@ -1,115 +0,0 @@
import { NDKCacheUserProfile } from "@lume/types";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import {
Ref,
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { twMerge } from "tailwind-merge";
type MentionListRef = {
onKeyDown: (props: { event: Event }) => boolean;
};
const List = (
props: {
items: NDKCacheUserProfile[];
command: (arg0: { id: string }) => void;
},
ref: Ref<unknown>,
) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item.pubkey });
}
};
const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[200px] flex-col overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 shadow-lg shadow-neutral-500/20 dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-neutral-300/50">
{props.items.length ? (
props.items.map((item, index) => (
<button
type="button"
key={item.pubkey}
onClick={() => selectItem(index)}
className={twMerge(
"inline-flex h-11 items-center gap-2 rounded-md px-2",
index === selectedIndex
? "bg-neutral-100 dark:bg-neutral-900"
: "",
)}
>
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={item.image}
alt={item.name}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md"
/>
<Avatar.Fallback delayMs={150}>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(item.name, 90, 50),
)}`}
alt={item.name}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[150px] truncate text-sm font-medium">
{item.name}
</h5>
</button>
))
) : (
<div className="text-center text-sm font-medium">No result</div>
)}
</div>
);
};
export const MentionList = forwardRef<MentionListRef>(List);

View File

@@ -1,51 +0,0 @@
import { useStorage } from "@lume/ark";
import { MentionIcon } from "@lume/icons";
import * as Popover from "@radix-ui/react-popover";
import { Editor } from "@tiptap/react";
import { nip19 } from "nostr-tools";
import { MentionPopupItem } from "./mentionPopupItem";
export function MentionPopup({ editor }: { editor: Editor }) {
const storage = useStorage();
const insertMention = (pubkey: string) => {
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<MentionIcon className="h-5 w-5" />
Mention
</button>
</Popover.Trigger>
<Popover.Content
side="top"
sideOffset={5}
className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
>
<div className="flex flex-col gap-1 py-1">
{storage.account.contacts.length ? (
storage.account.contacts.map((item) => (
<button
key={item}
type="button"
onClick={() => insertMention(item)}
>
<MentionPopupItem pubkey={item} />
</button>
))
) : (
<div className="flex h-16 items-center justify-center">
Contact list is empty
</div>
)}
</div>
</Popover.Content>
</Popover.Root>
);
}

View File

@@ -1,57 +0,0 @@
import { useProfile } from "@lume/ark";
import { displayNpub } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
export function MentionPopupItem({ pubkey }: { pubkey: string }) {
const { isLoading, user } = useProfile(pubkey);
const svgURI = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(pubkey, 90, 50),
)}`,
[pubkey],
);
if (isLoading) {
return (
<div className="flex items-center gap-2.5 px-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
<span className="h-3 w-1/3 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
</div>
</div>
);
}
return (
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
<Avatar.Root className="shirnk-0 h-8 w-8">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col items-start gap-px">
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-neutral-900 dark:text-neutral-100">
{user?.display_name || user?.displayName || user?.name}
</h5>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
{displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@@ -1,184 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { message, open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function NewFileScreen() {
const ark = useArk();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [isPublish, setIsPublish] = useState(false);
const [metadata, setMetadata] = useState<string[][] | null>(null);
const [caption, setCaption] = useState("");
const uploadFile = async () => {
try {
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: "Media",
extensions: [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readFile(selected.path);
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) {
const json = await res.json();
const data = json.data[0];
setMetadata([
["url", data.url],
["m", data.mime ?? "application/octet-stream"],
["x", data.sha256 ?? ""],
["size", data.size.toString() ?? "0"],
["dim", `${data.dimensions.width}x${data.dimensions.height}` ?? "0"],
["blurhash", data.blurhash ?? ""],
["thumb", data.thumbnail ?? ""],
]);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, {
title: "Lume",
type: "error",
});
}
};
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setIsPublish(true);
const publish = await ark.createEvent({
kind: 1063,
tags: metadata,
content: caption,
});
if (publish) {
toast.success(
`Broadcasted to ${publish.seens.length} relays successfully.`,
);
setMetadata(null);
setIsPublish(false);
}
} catch (e) {
setIsPublish(false);
toast.error(e);
}
};
return (
<div className="h-full">
<div className="flex h-96 gap-4 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<button
type="button"
onClick={uploadFile}
className="flex h-full flex-1 flex-col items-center justify-center rounded-lg border border-dashed border-neutral-200 bg-neutral-50 p-2 hover:border-blue-500 hover:text-blue-500 dark:border-neutral-800 dark:bg-neutral-950"
>
{loading ? (
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
) : !metadata ? (
<div className="flex flex-col text-center">
<h5 className="text-lg font-semibold">
Click or drag a file to this area to upload
</h5>
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
Supports: jpg, png, webp, gif, mov, mp4 or mp3
</p>
</div>
) : (
<div>
<img
src={metadata[0][1]}
alt={metadata[1][1]}
className="aspect-square h-full w-full rounded-lg object-cover shadow-lg"
/>
</div>
)}
</button>
{metadata ? (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="flex flex-col gap-2 py-2">
{metadata.map((item, index) => (
<div key={item[0] + index} className="flex min-w-0 gap-2">
<h5 className="w-24 shrink-0 truncate font-semibold capitalize text-neutral-600 dark:text-neutral-400">
{item[0]}
</h5>
<p className="w-72 truncate">{item[1]}</p>
</div>
))}
</div>
<div className="flex flex-col gap-2">
<input
name="caption"
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Caption (Optional)..."
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-900 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submit}
disabled={!metadata}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{isPublish ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
"Share"
)}
</button>
</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -1,173 +0,0 @@
import { MentionNote, useArk, useSuggestion, useWidget } from "@lume/ark";
import { CancelIcon, LoaderIcon } from "@lume/icons";
import { COL_TYPES } from "@lume/utils";
import { NDKKind } from "@nostr-dev-kit/ndk";
import CharacterCount from "@tiptap/extension-character-count";
import Image from "@tiptap/extension-image";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { convert } from "html-to-text";
import { nip19 } from "nostr-tools";
import { useLayoutEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { MediaUploader, MentionPopup } from "./components";
export function NewPostScreen() {
const ark = useArk();
const { addWidget } = useWidget();
const { suggestion } = useSuggestion();
const [loading, setLoading] = useState(false);
const [height, setHeight] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const containerRef = useRef(null);
const editor = useEditor({
extensions: [
StarterKit.configure(),
Placeholder.configure({ placeholder: "Sharing some thoughts..." }),
Image.configure({
HTMLAttributes: {
class:
"rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700",
},
}),
CharacterCount.configure(),
Mention.configure({
suggestion,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
renderLabel({ options, node }) {
const npub = nip19.npubEncode(node.attrs.id);
return `nostr:${npub}`;
},
}),
],
content: JSON.parse(localStorage.getItem("editor-post") || "{}"),
editorProps: {
attributes: {
class:
"outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500",
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem("editor-post", jsonContent);
},
});
const submit = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
setLoading(true);
// get plaintext content
const html = editor.getHTML();
const serializedContent = convert(html, {
selectors: [
{ selector: "a", options: { linkBrackets: false } },
{ selector: "img", options: { linkBrackets: false } },
],
});
// add reply to tags if present
const replyTo = searchParams.get("replyTo");
const rootReplyTo = searchParams.get("rootReplyTo");
// publish event
const publish = await ark.createEvent({
kind: NDKKind.Text,
tags: [],
content: serializedContent,
replyTo,
rootReplyTo,
});
if (publish) {
toast.success(
`Broadcasted to ${publish.seens.length} relays successfully.`,
);
// update state
setLoading(false);
setSearchParams({});
// open new widget with this event id
if (!replyTo) {
addWidget.mutate({
title: "Thread",
content: publish.id,
kind: COL_TYPES.thread,
});
}
// reset editor
editor.commands.clearContent();
localStorage.setItem("editor-post", "{}");
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return (
<div className="flex h-[500px] flex-1 flex-col gap-4">
<div className="flex-1 overflow-y-auto">
<div ref={containerRef} style={{ height: `${height}px` }}>
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{searchParams.get("replyTo") && (
<div className="relative max-w-lg">
<MentionNote eventId={searchParams.get("replyTo")} />
<button
type="button"
onClick={() => setSearchParams({})}
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-200 px-2 dark:bg-neutral-800"
>
<CancelIcon className="h-5 w-5" />
</button>
</div>
)}
</div>
</div>
<div className="inline-flex h-16 w-full items-center justify-between border-t border-neutral-100 bg-neutral-50 dark:border-neutral-900 dark:bg-neutral-950">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} characters
</span>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={() => submit()}
disabled={editor?.isEmpty}
className="inline-flex h-9 w-20 items-center justify-center rounded-lg bg-blue-500 px-2 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading === true ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
"Post"
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,81 +0,0 @@
import { useArk, useStorage } from "@lume/ark";
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { getPublicKey, nip19 } from "nostr-tools";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function NewPrivkeyScreen() {
const ark = useArk();
const storage = useStorage();
const navigate = useNavigate();
const [nsec, setNsec] = useState("");
const submit = async (isSave?: boolean) => {
try {
if (!nsec.startsWith("nsec1"))
return toast.info("You must enter a private key starts with nsec");
const decoded = nip19.decode(nsec);
if (decoded.type !== "nsec")
return toast.info("You must enter a valid nsec");
const privkey = decoded.data;
const pubkey = getPublicKey(privkey);
if (pubkey !== storage.account.pubkey)
return toast.info(
"Your nsec is not match your current public key, please make sure you enter right nsec",
);
const signer = new NDKPrivateKeySigner(privkey);
ark.updateNostrSigner({ signer });
if (isSave) await storage.createPrivkey(storage.account.pubkey, privkey);
navigate(-1);
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mb-16 flex flex-col gap-3">
<h1 className="text-center font-semibold text-neutral-900 dark:text-neutral-100">
You need to provide private key to sign nostr event.
</h1>
<input
name="privkey"
placeholder="nsec..."
type="password"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<div className="mt-2 flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Submit
</button>
<button
type="button"
onClick={() => submit(true)}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Submit and Save
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { useArk, useStorage } from "@lume/ark";
import { CancelIcon, RefreshIcon } from "@lume/icons";
import { useRelay } from "@lume/utils";
import { NDKKind } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query";
import { RelayForm } from "./relayForm";
@@ -9,7 +8,6 @@ export function UserRelayList() {
const ark = useArk();
const storage = useStorage();
const { removeRelay } = useRelay();
const { status, data, refetch } = useQuery({
queryKey: ["relays", storage.account.pubkey],
queryFn: async () => {
@@ -32,21 +30,21 @@ export function UserRelayList() {
return (
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<div className="inline-flex items-center justify-between w-full h-16 px-3 border-b border-neutral-100 dark:border-neutral-900">
<h3 className="font-semibold">Connected relays</h3>
<button
type="button"
onClick={() => refetch()}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
className="inline-flex items-center justify-center w-6 h-6 rounded-md shrink-0 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<RefreshIcon className="h-4 w-4" />
<RefreshIcon className="w-4 h-4" />
</button>
</div>
<div className="mt-3 flex flex-col gap-2 px-3">
<div className="flex flex-col gap-2 px-3 mt-3">
{status === "pending" ? (
<p>Loading...</p>
) : !data.length ? (
<div className="flex h-20 w-full items-center justify-center rounded-xl bg-neutral-50 dark:bg-neutral-950">
<div className="flex items-center justify-center w-full h-20 rounded-xl bg-neutral-50 dark:bg-neutral-950">
<p className="text-sm font-medium">
You not have personal relay list yet
</p>
@@ -55,18 +53,18 @@ export function UserRelayList() {
data.map((item) => (
<div
key={item[1]}
className="group flex h-11 items-center justify-between rounded-lg bg-neutral-100 px-3 dark:bg-neutral-900"
className="flex items-center justify-between px-3 rounded-lg group h-11 bg-neutral-100 dark:bg-neutral-900"
>
<div className="inline-flex items-baseline gap-2">
{currentRelays.has(item[1]) ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500" />
<span className="relative flex w-2 h-2">
<span className="absolute inline-flex w-full h-full bg-green-400 rounded-full opacity-75 animate-ping" />
<span className="relative inline-flex w-2 h-2 bg-teal-500 rounded-full" />
</span>
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
<span className="relative flex w-2 h-2">
<span className="absolute inline-flex w-full h-full bg-red-400 rounded-full opacity-75 animate-ping" />
<span className="relative inline-flex w-2 h-2 bg-red-500 rounded-full" />
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -75,16 +73,15 @@ export function UserRelayList() {
</div>
<div className="inline-flex items-center gap-2">
{item[2]?.length ? (
<div className="inline-flex h-6 w-max items-center justify-center rounded bg-neutral-200 px-2 text-xs font-medium capitalize dark:bg-neutral-800">
<div className="inline-flex items-center justify-center h-6 px-2 text-xs font-medium capitalize rounded w-max bg-neutral-200 dark:bg-neutral-800">
{item[2]}
</div>
) : null}
<button
type="button"
onClick={() => removeRelay.mutate(item[1])}
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
className="items-center justify-center hidden w-6 h-6 rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
<CancelIcon className="w-4 h-4 text-neutral-900 dark:text-neutral-100" />
</button>
</div>
</div>

View File

@@ -44,7 +44,6 @@ export function ColumnHeader({
return (
<div className="flex items-center justify-between w-full px-3 border-b h-11 shrink-0 border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-4">
<div className="w-1 h-5 bg-blue-500 rounded-full shrink-0" />
<div className="inline-flex items-center flex-1 gap-2 text-neutral-800 dark:text-neutral-200">
{icon ? icon : <ThreadIcon className="size-4" />}
<div className="text-sm font-medium">{title}</div>
@@ -61,7 +60,7 @@ export function ColumnHeader({
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] flex-col overflow-hidden rounded-xl border border-neutral-100 bg-white p-2 shadow-lg shadow-neutral-200/50 focus:outline-none dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-neutral-900/50">
<DropdownMenu.Content className="flex w-[200px] p-2 flex-col overflow-hidden rounded-xl border border-neutral-100 bg-neutral-50 dark:bg-neutral-950 focus:outline-none dark:border-neutral-900">
<DropdownMenu.Item asChild>
<button
type="button"

View File

@@ -34,13 +34,13 @@ export function ColumnLiveWidget({
if (!events.length) return null;
return (
<div className="absolute left-0 top-11 z-50 flex h-11 w-full items-center justify-center">
<div className="absolute left-0 z-50 flex items-center justify-center w-full top-11 h-11">
<button
type="button"
onClick={update}
className="inline-flex h-9 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-2.5 text-sm font-semibold text-white hover:bg-blue-600"
className="inline-flex items-center justify-center h-8 gap-1 pl-2 pr-2.5 text-sm font-semibold rounded-full w-max bg-neutral-950 dark:bg-neutral-50 hover:bg-neutral-900 dark:hover:bg-neutral-100 text-neutral-50 dark:text-neutral-950"
>
<ChevronUpIcon className="h-4 w-4" />
<ChevronUpIcon className="w-4 h-4" />
{events.length} {events.length === 1 ? "new event" : "new events"}
</button>
</div>

View File

@@ -1,4 +1,4 @@
import { HorizontalDotsIcon } from "@lume/icons";
import { HorizontalDotsIcon, ShareIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools";
@@ -40,9 +40,9 @@ export function NoteMenu() {
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center"
className="inline-flex items-center justify-center w-6 h-6"
>
<HorizontalDotsIcon className="h-4 w-4 text-neutral-800 hover:text-blue-500 dark:text-neutral-200" />
<HorizontalDotsIcon className="w-4 h-4 text-neutral-800 hover:text-blue-500 dark:text-neutral-200" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
@@ -51,7 +51,7 @@ export function NoteMenu() {
<button
type="button"
onClick={() => copyLink()}
className="inline-flex h-10 items-center px-4 text-sm text-white hover:bg-neutral-900 rounded-lg focus:outline-none"
className="inline-flex items-center h-10 px-4 text-sm text-white rounded-lg hover:bg-neutral-900 focus:outline-none"
>
Copy shareable link
</button>
@@ -60,7 +60,7 @@ export function NoteMenu() {
<button
type="button"
onClick={() => copyID()}
className="inline-flex h-10 items-center px-4 text-sm text-white hover:bg-neutral-900 rounded-lg focus:outline-none"
className="inline-flex items-center h-10 px-4 text-sm text-white rounded-lg hover:bg-neutral-900 focus:outline-none"
>
Copy note ID
</button>
@@ -69,7 +69,7 @@ export function NoteMenu() {
<button
type="button"
onClick={() => copyRaw()}
className="inline-flex h-10 items-center px-4 text-sm text-white hover:bg-neutral-900 rounded-lg focus:outline-none"
className="inline-flex items-center h-10 px-4 text-sm text-white rounded-lg hover:bg-neutral-900 focus:outline-none"
>
Copy raw event
</button>
@@ -77,7 +77,7 @@ export function NoteMenu() {
<DropdownMenu.Item asChild>
<Link
to={`/users/${event.pubkey}`}
className="inline-flex h-10 items-center px-4 text-sm text-white hover:bg-neutral-900 rounded-lg focus:outline-none"
className="inline-flex items-center h-10 px-4 text-sm text-white rounded-lg hover:bg-neutral-900 focus:outline-none"
>
View profile
</Link>

View File

@@ -1,6 +1,9 @@
import { PinIcon } from "@lume/icons";
import { COL_TYPES } from "@lume/utils";
import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { Note } from ".";
import { useColumnContext } from "../column";
export function NoteThread({
thread,
@@ -9,23 +12,40 @@ export function NoteThread({
thread: { rootEventId: string; replyEventId: string };
className?: string;
}) {
const { addColumn } = useColumnContext();
if (!thread) return null;
return (
<div className={twMerge("w-full px-3", className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="flex flex-col w-full gap-3 p-3 rounded-lg h-min bg-neutral-100 dark:bg-neutral-900">
{thread.rootEventId ? (
<Note.Child eventId={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? (
<Note.Child eventId={thread.replyEventId} />
) : null}
<Link
to={`/events/${thread?.rootEventId || thread?.replyEventId}`}
className="self-start text-blue-500 hover:text-blue-600"
>
Show thread
</Link>
<div className="inline-flex items-center justify-between">
<Link
to={`/events/${thread?.rootEventId || thread?.replyEventId}`}
className="self-start text-blue-500 hover:text-blue-600"
>
Show thread
</Link>
<button
type="button"
onClick={async () =>
await addColumn({
kind: COL_TYPES.thread,
title: "Thread",
content: thread?.rootEventId || thread?.replyEventId,
})
}
className="inline-flex items-center justify-center rounded-md text-neutral-600 dark:text-neutral-400 size-6 bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<PinIcon className="size-4" />
</button>
</div>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import { LoaderIcon } from "@lume/icons";
import { NDKCacheAdapterTauri } from "@lume/ndk-cache-tauri";
import { LumeStorage } from "@lume/storage";
import { QUOTES, delay } from "@lume/utils";
import { QUOTES, delay, sendNativeNotification } from "@lume/utils";
import NDK, {
NDKNip46Signer,
NDKPrivateKeySigner,
@@ -20,7 +20,6 @@ import {
normalizeRelayUrlSet,
} from "nostr-fetch";
import { PropsWithChildren, useEffect, useState } from "react";
import { toast } from "sonner";
import { createContext, useContextSelector } from "use-context-selector";
import { Ark } from "./ark";
@@ -161,7 +160,9 @@ const LumeProvider = ({ children }: PropsWithChildren<object>) => {
const signIn = NDKRelayAuthPolicies.signIn({ ndk, signer });
const event = await signIn(relay, challenge);
if (event) {
toast.success(`You've sign in sucessfully to relay: ${relay.url}`);
sendNativeNotification(
`You've sign in sucessfully to relay: ${relay.url}`,
);
return event;
}
};

View File

@@ -1,21 +1,24 @@
import { SVGProps } from 'react';
import { SVGProps } from "react";
export function ShareIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.5"
d="M22.085 11.628l-8.501-7.63a.5.5 0 00-.834.373V8.5C4.25 8.5 2 11.75 2 20.25c1.5-3 2.25-4.75 10.75-4.75v4.129a.5.5 0 00.834.372l8.501-7.63a.5.5 0 000-.744z"
></path>
</svg>
);
export function ShareIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15.41 6.51c-2.583.773-4.925 2.033-6.82 3.98m6.82 7c-2.583-.773-4.925-2.033-6.82-3.98M21 5a3 3 0 11-6 0 3 3 0 016 0zM9 12a3 3 0 11-6 0 3 3 0 016 0zm12 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}

View File

@@ -7,8 +7,10 @@
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/utils": "workspace:^",
"@nostr-dev-kit/ndk": "^2.3.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@tanstack/react-query": "^5.17.0",
@@ -16,6 +18,7 @@
"@tauri-apps/plugin-http": "2.0.0-alpha.6",
"@tauri-apps/plugin-os": "2.0.0-alpha.6",
"minidenticons": "^4.2.0",
"nostr-tools": "1.17",
"react": "^18.2.0",
"react-router-dom": "^6.21.1",
"sonner": "^1.3.1"

View File

@@ -7,7 +7,6 @@ export * from "./user";
export * from "./titlebar";
export * from "./layouts/app";
export * from "./layouts/auth";
export * from "./layouts/composer";
export * from "./layouts/home";
export * from "./layouts/settings";
export * from "./mentions";

View File

@@ -1,52 +0,0 @@
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
export function ComposerLayout() {
const location = useLocation();
return (
<div className="container mx-auto h-full px-8 pt-8">
<div className="mb-8 flex h-10 shrink-0 items-center gap-3">
{location.pathname !== '/new/privkey' ? (
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
<NavLink
to="/new/"
end
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Post
</NavLink>
<NavLink
to="/new/article"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Article
</NavLink>
<NavLink
to="/new/file"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-28 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
File Sharing
</NavLink>
</div>
) : null}
</div>
<Outlet />
</div>
);
}

View File

@@ -5,19 +5,17 @@ import {
HomeIcon,
NwcFilledIcon,
NwcIcon,
PlusIcon,
RelayFilledIcon,
RelayIcon,
SearchIcon,
} from "@lume/icons";
import { cn } from "@lume/utils";
import { Link, NavLink } from "react-router-dom";
import { NavLink } from "react-router-dom";
import { ActiveAccount } from "./account/active";
export function Navigation() {
return (
<div className="flex h-full w-20 shrink-0 flex-col justify-between px-4 py-3">
<div className="flex flex-1 flex-col gap-5">
<div className="flex flex-col justify-between w-20 h-full px-4 py-3 shrink-0">
<div className="flex flex-col flex-1 gap-5">
<NavLink
to="/"
preventScrollReset={true}
@@ -34,7 +32,7 @@ export function Navigation() {
)}
>
{isActive ? (
<HomeFilledIcon className="size-6 text-black dark:text-white" />
<HomeFilledIcon className="text-black size-6 dark:text-white" />
) : (
<HomeIcon className="size-6" />
)}
@@ -102,7 +100,7 @@ export function Navigation() {
)}
>
{isActive ? (
<DepotFilledIcon className="size-6 text-black dark:text-white" />
<DepotFilledIcon className="text-black size-6 dark:text-white" />
) : (
<DepotIcon className="size-6" />
)}
@@ -136,7 +134,7 @@ export function Navigation() {
)}
>
{isActive ? (
<NwcFilledIcon className="size-6 text-black dark:text-white" />
<NwcFilledIcon className="text-black size-6 dark:text-white" />
) : (
<NwcIcon className="size-6" />
)}
@@ -155,19 +153,7 @@ export function Navigation() {
)}
</NavLink>
</div>
<div className="flex shrink-0 flex-col gap-3 p-1">
<Link
to="/new/"
className="flex aspect-square h-auto w-full items-center justify-center rounded-xl bg-black/10 text-black hover:bg-blue-500 hover:text-white dark:bg-white/10 dark:text-white dark:hover:bg-blue-500"
>
<PlusIcon className="h-5 w-5" />
</Link>
<Link
to="/nwc"
className="flex aspect-square h-auto w-full items-center justify-center rounded-xl bg-black/10 hover:bg-blue-500 hover:text-white dark:bg-white/10 dark:hover:bg-blue-500"
>
<SearchIcon className="h-5 w-5" />
</Link>
<div className="flex flex-col gap-3 p-1 shrink-0">
<ActiveAccount />
</div>
</div>

841
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff