mirror of
https://github.com/luminous-devs/lume.git
synced 2024-09-30 00:41:00 +00:00
chore: clean up
This commit is contained in:
parent
2c8dd71792
commit
63db8b1423
@ -1,5 +1,10 @@
|
|||||||
import { useArk } from "@lume/ark";
|
import { useArk } from "@lume/ark";
|
||||||
import { ArrowRightCircleIcon, LoaderIcon, SearchIcon } from "@lume/icons";
|
import {
|
||||||
|
ArrowRightCircleIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
LoaderIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from "@lume/icons";
|
||||||
import { Event, Kind } from "@lume/types";
|
import { Event, Kind } from "@lume/types";
|
||||||
import { EmptyFeed } from "@lume/ui";
|
import { EmptyFeed } from "@lume/ui";
|
||||||
import { FETCH_LIMIT } from "@lume/utils";
|
import { FETCH_LIMIT } from "@lume/utils";
|
||||||
@ -19,7 +24,7 @@ function LocalTimeline() {
|
|||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: ["newsfeed", account],
|
queryKey: ["local_newsfeed", account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await ark.get_events(
|
const events = await ark.get_events(
|
||||||
@ -61,10 +66,10 @@ function LocalTimeline() {
|
|||||||
<EmptyFeed />
|
<EmptyFeed />
|
||||||
<a
|
<a
|
||||||
href="/suggest"
|
href="/suggest"
|
||||||
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
|
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-blue-500 font-medium text-white hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
<SearchIcon className="size-5" />
|
|
||||||
Find accounts to follow
|
Find accounts to follow
|
||||||
|
<ArrowRightIcon className="size-5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useArk } from "@lume/ark";
|
import { useArk } from "@lume/ark";
|
||||||
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
||||||
import { User } from "@lume/ui";
|
import { User } from "@lume/ui";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@ -89,14 +90,14 @@ function Screen() {
|
|||||||
</User.Provider>
|
</User.Provider>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button type="button">
|
<Link to="/landing">
|
||||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
||||||
<PlusIcon className="size-5" />
|
<PlusIcon className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-medium leading-tight">Add</p>
|
<p className="text-lg font-medium leading-tight">Add</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,46 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import { Dispatch, SetStateAction, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function AvatarUploadButton({
|
|
||||||
setPicture,
|
|
||||||
}: {
|
|
||||||
setPicture: Dispatch<SetStateAction<string>>;
|
|
||||||
}) {
|
|
||||||
const ark = useArk();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const uploadAvatar = async () => {
|
|
||||||
try {
|
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const image = await ark.upload({ fileExts: [] });
|
|
||||||
if (image) {
|
|
||||||
setPicture(image);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => uploadAvatar()}
|
|
||||||
className="inline-flex items-center justify-center rounded-lg border border-blue-200 bg-blue-100 w-32 px-2 py-1.5 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("user.avatarButton")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { AddMediaIcon, LoaderIcon } from "@lume/icons";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useSlateStatic } from "slate-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { insertImage } from "./utils";
|
|
||||||
|
|
||||||
export function EditorAddMedia() {
|
|
||||||
const ark = useArk();
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const uploadToNostrBuild = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const image = await ark.upload({
|
|
||||||
fileExts: ["mp4", "mp3", "webm", "mkv", "avi", "mov"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (image) {
|
|
||||||
insertImage(editor, image);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(`Upload failed, error: ${e}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => uploadToNostrBuild()}
|
|
||||||
className="inline-flex items-center justify-center text-sm font-medium rounded-lg size-9 bg-neutral-100 text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<AddMediaIcon className="size-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
import { editorAtom } from "@lume/utils";
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { EditorForm } from "./form";
|
|
||||||
|
|
||||||
export function Editor() {
|
|
||||||
const isEditorOpen = useAtomValue(editorAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence initial={false} mode="wait">
|
|
||||||
{isEditorOpen ? (
|
|
||||||
<motion.div
|
|
||||||
key={isEditorOpen ? "editor-open" : "editor-close"}
|
|
||||||
layout
|
|
||||||
initial={{ scale: 0.9, opacity: 0, translateX: -20 }}
|
|
||||||
animate={{
|
|
||||||
scale: [0.95, 1],
|
|
||||||
opacity: [0.5, 1],
|
|
||||||
translateX: [-10, 0],
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
scale: [0.95, 0.9],
|
|
||||||
opacity: [0.5, 0],
|
|
||||||
translateX: [-10, -20],
|
|
||||||
}}
|
|
||||||
className="h-full w-[350px] px-1 pb-1 shrink-0"
|
|
||||||
>
|
|
||||||
<EditorForm />
|
|
||||||
</motion.div>
|
|
||||||
) : null}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,377 +0,0 @@
|
|||||||
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
|
||||||
import { cn, editorValueAtom } from "@lume/utils";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Descendant,
|
|
||||||
Editor,
|
|
||||||
Node,
|
|
||||||
Range,
|
|
||||||
Transforms,
|
|
||||||
createEditor,
|
|
||||||
} from "slate";
|
|
||||||
import {
|
|
||||||
Editable,
|
|
||||||
ReactEditor,
|
|
||||||
Slate,
|
|
||||||
useFocused,
|
|
||||||
useSelected,
|
|
||||||
useSlateStatic,
|
|
||||||
withReact,
|
|
||||||
} from "slate-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { EditorAddMedia } from "./addMedia";
|
|
||||||
import {
|
|
||||||
Portal,
|
|
||||||
insertImage,
|
|
||||||
insertMention,
|
|
||||||
insertNostrEvent,
|
|
||||||
isImageUrl,
|
|
||||||
} from "./utils";
|
|
||||||
import { MentionNote } from "../note/mentions/note";
|
|
||||||
|
|
||||||
const withNostrEvent = (editor: ReactEditor) => {
|
|
||||||
const { insertData, isVoid } = editor;
|
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "event" ? true : isVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.insertData = (data) => {
|
|
||||||
const text = data.getData("text/plain");
|
|
||||||
|
|
||||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
|
||||||
insertNostrEvent(editor, text);
|
|
||||||
} else {
|
|
||||||
insertData(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withMentions = (editor: ReactEditor) => {
|
|
||||||
const { isInline, isVoid, markableVoid } = editor;
|
|
||||||
|
|
||||||
editor.isInline = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "mention" ? true : isInline(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "mention" ? true : isVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.markableVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "mention" || markableVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withImages = (editor: ReactEditor) => {
|
|
||||||
const { insertData, isVoid } = editor;
|
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "image" ? true : isVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.insertData = (data) => {
|
|
||||||
const text = data.getData("text/plain");
|
|
||||||
|
|
||||||
if (isImageUrl(text)) {
|
|
||||||
insertImage(editor, text);
|
|
||||||
} else {
|
|
||||||
insertData(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Image = ({ attributes, children, element }) => {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
const selected = useSelected();
|
|
||||||
const focused = useFocused();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...attributes}>
|
|
||||||
{children}
|
|
||||||
<div contentEditable={false} className="relative my-2">
|
|
||||||
<img
|
|
||||||
src={element.url}
|
|
||||||
alt={element.url}
|
|
||||||
className={cn(
|
|
||||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
|
||||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
|
||||||
)}
|
|
||||||
contentEditable={false}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Mention = ({ attributes, element }) => {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
{...attributes}
|
|
||||||
type="button"
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
|
||||||
>{`@${element.name}`}</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Event = ({ attributes, element, children }) => {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...attributes}>
|
|
||||||
{children}
|
|
||||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
|
||||||
<div
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="user-select-none relative my-2"
|
|
||||||
>
|
|
||||||
<MentionNote
|
|
||||||
eventId={element.eventId.replace("nostr:", "")}
|
|
||||||
openable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Element = (props) => {
|
|
||||||
const { attributes, children, element } = props;
|
|
||||||
|
|
||||||
switch (element.type) {
|
|
||||||
case "image":
|
|
||||||
return <Image {...props} />;
|
|
||||||
case "mention":
|
|
||||||
return <Mention {...props} />;
|
|
||||||
case "event":
|
|
||||||
return <Event {...props} />;
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<p {...attributes} className="text-lg">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EditorForm() {
|
|
||||||
const ref = useRef<HTMLDivElement | null>();
|
|
||||||
|
|
||||||
const [editorValue, setEditorValue] = useAtom(editorValueAtom);
|
|
||||||
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
|
|
||||||
const [target, setTarget] = useState<Range | undefined>();
|
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [editor] = useState(() =>
|
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const filters = contacts
|
|
||||||
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
|
|
||||||
?.slice(0, 10);
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
|
||||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const serialize = (nodes: Descendant[]) => {
|
|
||||||
return nodes
|
|
||||||
.map((n) => {
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
if (n.type === "image") return n.url;
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
if (n.type === "event") return n.eventId;
|
|
||||||
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
if (n.children.length) {
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
return n.children
|
|
||||||
.map((n) => {
|
|
||||||
if (n.type === "mention") return n.npub;
|
|
||||||
return Node.string(n).trim();
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Node.string(n);
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const content = serialize(editor.children);
|
|
||||||
const publish = await invoke("publish", { content });
|
|
||||||
|
|
||||||
if (publish) {
|
|
||||||
console.log(publish);
|
|
||||||
toast.success(t("editor.successMessage"));
|
|
||||||
|
|
||||||
return reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadContacts() {
|
|
||||||
const res = await storage.getAllCacheUsers();
|
|
||||||
if (res) setContacts(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadContacts();
|
|
||||||
}, []);
|
|
||||||
*/
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (target && filters.length > 0) {
|
|
||||||
const el = ref.current;
|
|
||||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
|
||||||
const rect = domRange.getBoundingClientRect();
|
|
||||||
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
|
|
||||||
el.style.left = `${rect.left + window.pageXOffset}px`;
|
|
||||||
}
|
|
||||||
}, [filters.length, editor, index, search, target]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col justify-between overflow-hidden rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/10">
|
|
||||||
<Slate
|
|
||||||
editor={editor}
|
|
||||||
initialValue={editorValue}
|
|
||||||
onChange={() => {
|
|
||||||
const { selection } = editor;
|
|
||||||
|
|
||||||
if (selection && Range.isCollapsed(selection)) {
|
|
||||||
const [start] = Range.edges(selection);
|
|
||||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
|
||||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
|
||||||
const beforeRange = before && Editor.range(editor, before, start);
|
|
||||||
const beforeText =
|
|
||||||
beforeRange && Editor.string(editor, beforeRange);
|
|
||||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
|
||||||
const after = Editor.after(editor, start);
|
|
||||||
const afterRange = Editor.range(editor, start, after);
|
|
||||||
const afterText = Editor.string(editor, afterRange);
|
|
||||||
const afterMatch = afterText.match(/^(\s|$)/);
|
|
||||||
|
|
||||||
if (beforeMatch && afterMatch) {
|
|
||||||
setTarget(beforeRange);
|
|
||||||
setSearch(beforeMatch[1]);
|
|
||||||
setIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTarget(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex h-16 shrink-0 items-center justify-between border-b border-neutral-100 bg-neutral-50 pl-7 pr-3 dark:border-neutral-900 dark:bg-neutral-950">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">{t("editor.title")}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<EditorAddMedia />
|
|
||||||
</div>
|
|
||||||
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
className="inline-flex h-9 w-20 items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.post")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-full overflow-y-auto px-7 py-6">
|
|
||||||
<Editable
|
|
||||||
key={JSON.stringify(editorValue)}
|
|
||||||
autoFocus={true}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="none"
|
|
||||||
spellCheck={false}
|
|
||||||
renderElement={(props) => <Element {...props} />}
|
|
||||||
placeholder={t("editor.placeholder")}
|
|
||||||
className="focus:outline-none"
|
|
||||||
/>
|
|
||||||
{target && filters.length > 0 && (
|
|
||||||
<Portal>
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
|
|
||||||
>
|
|
||||||
{filters.map((contact, i) => (
|
|
||||||
<button
|
|
||||||
key={contact.npub}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
Transforms.select(editor, target);
|
|
||||||
insertMention(editor, contact);
|
|
||||||
setTarget(null);
|
|
||||||
}}
|
|
||||||
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={contact.npub}>
|
|
||||||
<User.Root className="flex w-full items-center gap-2.5">
|
|
||||||
<User.Avatar className="size-8 shrink-0 rounded-lg object-cover" />
|
|
||||||
<div className="flex w-full flex-col items-start">
|
|
||||||
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Slate>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,403 +0,0 @@
|
|||||||
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
|
||||||
import { useStorage } from "@lume/storage";
|
|
||||||
import { NDKCacheUserProfile } from "@lume/types";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
|
||||||
import { Portal } from "@radix-ui/react-dropdown-menu";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Descendant,
|
|
||||||
Editor,
|
|
||||||
Node,
|
|
||||||
Range,
|
|
||||||
Transforms,
|
|
||||||
createEditor,
|
|
||||||
} from "slate";
|
|
||||||
import {
|
|
||||||
Editable,
|
|
||||||
ReactEditor,
|
|
||||||
Slate,
|
|
||||||
useFocused,
|
|
||||||
useSelected,
|
|
||||||
useSlateStatic,
|
|
||||||
withReact,
|
|
||||||
} from "slate-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { EditorAddMedia } from "./addMedia";
|
|
||||||
import {
|
|
||||||
insertImage,
|
|
||||||
insertMention,
|
|
||||||
insertNostrEvent,
|
|
||||||
isImageUrl,
|
|
||||||
} from "./utils";
|
|
||||||
import { MentionNote } from "../note/mentions/note";
|
|
||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { User } from "../user";
|
|
||||||
|
|
||||||
const withNostrEvent = (editor: ReactEditor) => {
|
|
||||||
const { insertData, isVoid } = editor;
|
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "event" ? true : isVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.insertData = (data) => {
|
|
||||||
const text = data.getData("text/plain");
|
|
||||||
|
|
||||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
|
||||||
insertNostrEvent(editor, text);
|
|
||||||
} else {
|
|
||||||
insertData(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withMentions = (editor: ReactEditor) => {
|
|
||||||
const { isInline, isVoid, markableVoid } = editor;
|
|
||||||
|
|
||||||
editor.isInline = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "mention" ? true : isInline(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "mention" ? true : isVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.markableVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "mention" || markableVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withImages = (editor: ReactEditor) => {
|
|
||||||
const { insertData, isVoid } = editor;
|
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
|
||||||
// @ts-expect-error, wtf
|
|
||||||
return element.type === "image" ? true : isVoid(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.insertData = (data) => {
|
|
||||||
const text = data.getData("text/plain");
|
|
||||||
|
|
||||||
if (isImageUrl(text)) {
|
|
||||||
insertImage(editor, text);
|
|
||||||
} else {
|
|
||||||
insertData(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Image = ({ attributes, children, element }) => {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
const selected = useSelected();
|
|
||||||
const focused = useFocused();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...attributes}>
|
|
||||||
{children}
|
|
||||||
<div contentEditable={false} className="relative">
|
|
||||||
<img
|
|
||||||
src={element.url}
|
|
||||||
alt={element.url}
|
|
||||||
className={cn(
|
|
||||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
|
||||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
|
||||||
)}
|
|
||||||
contentEditable={false}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Mention = ({ attributes, element }) => {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
{...attributes}
|
|
||||||
type="button"
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
|
||||||
>{`@${element.name}`}</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Event = ({ attributes, element, children }) => {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...attributes}>
|
|
||||||
{children}
|
|
||||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
|
||||||
<div
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="user-select-none relative"
|
|
||||||
>
|
|
||||||
<MentionNote
|
|
||||||
eventId={element.eventId.replace("nostr:", "")}
|
|
||||||
openable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Element = (props) => {
|
|
||||||
const { attributes, children, element } = props;
|
|
||||||
|
|
||||||
switch (element.type) {
|
|
||||||
case "image":
|
|
||||||
return <Image {...props} />;
|
|
||||||
case "mention":
|
|
||||||
return <Mention {...props} />;
|
|
||||||
case "event":
|
|
||||||
return <Event {...props} />;
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<p {...attributes} className="text-lg">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ReplyForm({
|
|
||||||
eventId,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
eventId: string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const ark = useArk();
|
|
||||||
const storage = useStorage();
|
|
||||||
const ref = useRef<HTMLDivElement | null>();
|
|
||||||
|
|
||||||
const [editorValue, setEditorValue] = useState([
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
|
|
||||||
const [target, setTarget] = useState<Range | undefined>();
|
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [editor] = useState(() =>
|
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const filters = contacts
|
|
||||||
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
|
|
||||||
?.slice(0, 10);
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
|
||||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const serialize = (nodes: Descendant[]) => {
|
|
||||||
return nodes
|
|
||||||
.map((n) => {
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
if (n.type === "image") return n.url;
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
if (n.type === "event") return n.eventId;
|
|
||||||
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
if (n.children.length) {
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
return n.children
|
|
||||||
.map((n) => {
|
|
||||||
if (n.type === "mention") return n.npub;
|
|
||||||
return Node.string(n).trim();
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Node.string(n);
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const event = new NDKEvent(ark.ndk);
|
|
||||||
event.kind = NDKKind.Text;
|
|
||||||
event.content = serialize(editor.children);
|
|
||||||
|
|
||||||
const rootEvent = await ark.getEventById(eventId);
|
|
||||||
event.tag(rootEvent, "root");
|
|
||||||
|
|
||||||
const publish = await event.publish();
|
|
||||||
|
|
||||||
if (publish) {
|
|
||||||
toast.success(
|
|
||||||
`Event has been published successfully to ${publish.size} relays.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
return reset();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadContacts() {
|
|
||||||
const res = await storage.getAllCacheUsers();
|
|
||||||
if (res) setContacts(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadContacts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (target && filters.length > 0) {
|
|
||||||
const el = ref.current;
|
|
||||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
|
||||||
const rect = domRange.getBoundingClientRect();
|
|
||||||
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
|
|
||||||
el.style.left = `${rect.left + window.pageXOffset}px`;
|
|
||||||
}
|
|
||||||
}, [filters.length, editor, index, search, target]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex gap-3", className)}>
|
|
||||||
<User.Provider pubkey={ark.account.npub}>
|
|
||||||
<User.Root>
|
|
||||||
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Slate
|
|
||||||
editor={editor}
|
|
||||||
initialValue={editorValue}
|
|
||||||
onChange={() => {
|
|
||||||
const { selection } = editor;
|
|
||||||
|
|
||||||
if (selection && Range.isCollapsed(selection)) {
|
|
||||||
const [start] = Range.edges(selection);
|
|
||||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
|
||||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
|
||||||
const beforeRange = before && Editor.range(editor, before, start);
|
|
||||||
const beforeText =
|
|
||||||
beforeRange && Editor.string(editor, beforeRange);
|
|
||||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
|
||||||
const after = Editor.after(editor, start);
|
|
||||||
const afterRange = Editor.range(editor, start, after);
|
|
||||||
const afterText = Editor.string(editor, afterRange);
|
|
||||||
const afterMatch = afterText.match(/^(\s|$)/);
|
|
||||||
|
|
||||||
if (beforeMatch && afterMatch) {
|
|
||||||
setTarget(beforeRange);
|
|
||||||
setSearch(beforeMatch[1]);
|
|
||||||
setIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTarget(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="overflow-y-auto rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
|
|
||||||
<Editable
|
|
||||||
key={JSON.stringify(editorValue)}
|
|
||||||
autoFocus={false}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="none"
|
|
||||||
spellCheck={false}
|
|
||||||
renderElement={(props) => <Element {...props} />}
|
|
||||||
placeholder={t("editor.replyPlaceholder")}
|
|
||||||
className="h-28 focus:outline-none"
|
|
||||||
/>
|
|
||||||
{target && filters.length > 0 && (
|
|
||||||
<Portal>
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-lg border border-neutral-50 bg-white p-1 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
|
|
||||||
>
|
|
||||||
{filters.map((contact, i) => (
|
|
||||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
|
||||||
<div
|
|
||||||
key={contact.npub}
|
|
||||||
onClick={() => {
|
|
||||||
Transforms.select(editor, target);
|
|
||||||
insertMention(editor, contact);
|
|
||||||
setTarget(null);
|
|
||||||
}}
|
|
||||||
className="rounded-md px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={contact.npub}>
|
|
||||||
<User.Root className="flex items-center gap-2.5">
|
|
||||||
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
|
|
||||||
<div className="flex w-full flex-col items-start">
|
|
||||||
<User.Name className="max-w-[15rem] truncate font-semibold" />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex shrink-0 items-center justify-between">
|
|
||||||
<div />
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<EditorAddMedia />
|
|
||||||
</div>
|
|
||||||
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
className="inline-flex h-9 w-20 items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.post")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Slate>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,89 +0,0 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
import ReactDOM from "react-dom";
|
|
||||||
import { BaseEditor, Transforms } from "slate";
|
|
||||||
import { ReactEditor } from "slate-react";
|
|
||||||
|
|
||||||
export const Portal = ({ children }: { children?: ReactNode }) => {
|
|
||||||
return typeof document === "object"
|
|
||||||
? ReactDOM.createPortal(children, document.body)
|
|
||||||
: null;
|
|
||||||
};
|
|
||||||
|
|
||||||
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: NDKCacheUserProfile,
|
|
||||||
) => {
|
|
||||||
const text = { text: "" };
|
|
||||||
const mention = {
|
|
||||||
type: "mention",
|
|
||||||
npub: `nostr:${contact.npub}`,
|
|
||||||
name: contact.name || contact.displayName || "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);
|
|
||||||
};
|
|
@ -2,12 +2,4 @@
|
|||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./note";
|
export * from "./note";
|
||||||
export * from "./column";
|
export * from "./column";
|
||||||
|
|
||||||
// Deprecated
|
|
||||||
export * from "./routes/event";
|
|
||||||
export * from "./routes/user";
|
|
||||||
export * from "./routes/suggest";
|
|
||||||
export * from "./mentions";
|
|
||||||
export * from "./emptyFeed";
|
export * from "./emptyFeed";
|
||||||
export * from "./translateRegisterModal";
|
|
||||||
export * from "./account/active";
|
|
||||||
|
@ -1,157 +0,0 @@
|
|||||||
import { ArrowLeftIcon, EditInterestIcon, LoaderIcon } from "@lume/icons";
|
|
||||||
import { useStorage } from "@lume/storage";
|
|
||||||
import { TOPICS, cn } from "@lume/utils";
|
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { ReactNode, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function InterestModal({
|
|
||||||
queryKey,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: { queryKey: string[]; className?: string; children?: ReactNode }) {
|
|
||||||
const storage = useStorage();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [hashtags, setHashtags] = useState(storage.interests?.hashtags || []);
|
|
||||||
|
|
||||||
const toggleHashtag = (item: string) => {
|
|
||||||
const arr = hashtags.includes(item)
|
|
||||||
? hashtags.filter((i) => i !== item)
|
|
||||||
: [...hashtags, item];
|
|
||||||
setHashtags(arr);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAll = (item: string[]) => {
|
|
||||||
const sets = new Set([...hashtags, ...item]);
|
|
||||||
setHashtags([...sets]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const save = await storage.createSetting(
|
|
||||||
"interests",
|
|
||||||
JSON.stringify({ hashtags }),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (save) {
|
|
||||||
storage.interests = { hashtags, users: [], words: [] };
|
|
||||||
await queryClient.refetchQueries({ queryKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
setOpen(false);
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
||||||
<Dialog.Trigger
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-3 px-3 rounded-lg h-9 focus:outline-none",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<EditInterestIcon className="size-4" />
|
|
||||||
{t("interests.edit")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/10 backdrop-blur-sm dark:bg-white/10" />
|
|
||||||
<Dialog.Content className="fixed inset-0 z-50 flex items-center justify-center min-h-full">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="h-20 absolute top-0 left-0 w-full"
|
|
||||||
/>
|
|
||||||
<div className="relative w-full max-w-xl xl:max-w-2xl bg-white h-[600px] xl:h-[700px] rounded-xl dark:bg-black overflow-hidden">
|
|
||||||
<div className="w-full h-full flex flex-col">
|
|
||||||
<div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h3 className="font-semibold">{t("interests.edit")}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex-1 min-h-0 flex flex-col justify-between">
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-8 py-8">
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
{TOPICS.map((topic) => (
|
|
||||||
<div key={topic.title} className="flex flex-col gap-4">
|
|
||||||
<div className="w-full flex items-center justify-between">
|
|
||||||
<div className="inline-flex items-center gap-2.5">
|
|
||||||
<img
|
|
||||||
src={topic.icon}
|
|
||||||
alt={topic.title}
|
|
||||||
className="size-8 object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
{topic.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleAll(topic.content)}
|
|
||||||
className="text-sm font-medium text-blue-500"
|
|
||||||
>
|
|
||||||
{t("interests.followAll")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{topic.content.map((hashtag) => (
|
|
||||||
<button
|
|
||||||
key={hashtag}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleHashtag(hashtag)}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center rounded-full bg-neutral-100 dark:bg-neutral-900 border border-transparent px-2 py-1 text-sm font-medium",
|
|
||||||
hashtags.includes(hashtag)
|
|
||||||
? "border-blue-500 text-blue-500"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hashtag}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-16 shrink-0 w-full flex items-center px-8 justify-center gap-2 border-t border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
|
|
||||||
<Dialog.Close className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200">
|
|
||||||
<ArrowLeftIcon className="size-4" />
|
|
||||||
{t("global.cancel")}
|
|
||||||
</Dialog.Close>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
className="inline-flex h-9 flex-1 shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.save")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,120 +0,0 @@
|
|||||||
import * as Avatar from "@radix-ui/react-avatar";
|
|
||||||
import { minidenticon } from "minidenticons";
|
|
||||||
import {
|
|
||||||
Ref,
|
|
||||||
forwardRef,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
import { NDKCacheUserProfile } from "@lume/types";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
type MentionListRef = {
|
|
||||||
onKeyDown: (props: { event: Event }) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const List = (
|
|
||||||
props: {
|
|
||||||
items: NDKCacheUserProfile[];
|
|
||||||
command: (arg0: { id: string }) => void;
|
|
||||||
},
|
|
||||||
ref: Ref<unknown>,
|
|
||||||
) => {
|
|
||||||
const [t] = useTranslation();
|
|
||||||
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={cn(
|
|
||||||
"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">
|
|
||||||
{t("global.noResult")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MentionList = forwardRef<MentionListRef>(List);
|
|
@ -81,8 +81,8 @@ export function NoteChild({
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex gap-3">
|
<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="relative flex-1 rounded-xl bg-neutral-100 p-3 text-sm dark:bg-neutral-900">
|
||||||
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -91,7 +91,7 @@ export function NoteChild({
|
|||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex gap-3">
|
<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="relative flex-1 rounded-xl bg-neutral-100 p-3 text-sm dark:bg-neutral-900">
|
||||||
{t("note.error")}
|
{t("note.error")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -100,8 +100,8 @@ export function NoteChild({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex gap-3">
|
<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="relative flex-1 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||||
<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="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-100 dark:bg-neutral-900" />
|
||||||
<div className="content-break mt-6 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}
|
{richContent}
|
||||||
</div>
|
</div>
|
||||||
@ -109,7 +109,7 @@ export function NoteChild({
|
|||||||
<User.Provider pubkey={data.pubkey}>
|
<User.Provider pubkey={data.pubkey}>
|
||||||
<User.Root>
|
<User.Root>
|
||||||
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
|
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
|
||||||
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
|
<div className="absolute left-3 top-3 inline-flex items-center gap-1.5 font-semibold leading-tight">
|
||||||
<User.Name className="max-w-[10rem] truncate" />
|
<User.Name className="max-w-[10rem] truncate" />
|
||||||
<div className="font-normal text-neutral-700 dark:text-neutral-300">
|
<div className="font-normal text-neutral-700 dark:text-neutral-300">
|
||||||
{isRoot ? t("note.posted") : t("note.replied")}:
|
{isRoot ? t("note.posted") : t("note.replied")}:
|
||||||
|
@ -84,7 +84,7 @@ export function MentionNote({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
className="my-1 flex w-full cursor-default items-center justify-between rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900"
|
className="my-1 flex w-full cursor-default items-center justify-between rounded-2xl border border-black/10 p-3 dark:border-white/10"
|
||||||
>
|
>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
@ -95,7 +95,7 @@ export function MentionNote({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
className="my-1 w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900"
|
className="my-1 w-full cursor-default rounded-2xl border border-black/10 p-3 dark:border-white/10"
|
||||||
>
|
>
|
||||||
{t("note.error")}
|
{t("note.error")}
|
||||||
</div>
|
</div>
|
||||||
@ -103,7 +103,7 @@ export function MentionNote({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<div className="my-1 flex w-full cursor-default flex-col rounded-2xl border border-black/10 px-3 pt-1 dark:border-white/10">
|
||||||
<User.Provider pubkey={data.pubkey}>
|
<User.Provider pubkey={data.pubkey}>
|
||||||
<User.Root className="flex h-10 items-center gap-2">
|
<User.Root className="flex h-10 items-center gap-2">
|
||||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
||||||
|
@ -37,7 +37,7 @@ export function ImagePreview({ url }: { url: string }) {
|
|||||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||||
<div
|
<div
|
||||||
onClick={open}
|
onClick={open}
|
||||||
className="group relative my-1 rounded-xl ring-1 ring-black/5 dark:ring-white/5"
|
className="group relative my-1 rounded-2xl border border-black/10 dark:border-white/10"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
@ -46,12 +46,12 @@ export function ImagePreview({ url }: { url: string }) {
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
style={{ contentVisibility: "auto" }}
|
style={{ contentVisibility: "auto" }}
|
||||||
onError={fallback}
|
onError={fallback}
|
||||||
className="h-auto w-full rounded-xl object-cover"
|
className="h-auto w-full rounded-2xl object-cover"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => downloadImage(e)}
|
onClick={(e) => downloadImage(e)}
|
||||||
className="absolute right-2 top-2 z-10 hidden size-10 items-center justify-center rounded-lg bg-white/20 text-black/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
|
className="absolute right-2 top-2 z-10 hidden size-10 items-center justify-center rounded-lg bg-white/10 text-black/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
|
||||||
>
|
>
|
||||||
{downloaded ? (
|
{downloaded ? (
|
||||||
<CheckCircleIcon className="size-5" />
|
<CheckCircleIcon className="size-5" />
|
||||||
|
@ -10,7 +10,7 @@ export function LinkPreview({ url }: { url: string }) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div 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">
|
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
|
||||||
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||||
<div className="flex flex-col gap-2 px-3 py-3">
|
<div className="flex flex-col gap-2 px-3 py-3">
|
||||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||||
@ -54,7 +54,7 @@ export function LinkPreview({ url }: { url: string }) {
|
|||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
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"
|
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
|
||||||
>
|
>
|
||||||
{isImage(data.image) ? (
|
{isImage(data.image) ? (
|
||||||
<img
|
<img
|
||||||
@ -68,7 +68,7 @@ export function LinkPreview({ url }: { url: string }) {
|
|||||||
<div className="flex flex-col items-start p-3">
|
<div className="flex flex-col items-start p-3">
|
||||||
<div className="flex flex-col items-start text-left">
|
<div className="flex flex-col items-start text-left">
|
||||||
{data.title ? (
|
{data.title ? (
|
||||||
<div className="content-break text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{data.title}
|
{data.title}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -9,14 +9,14 @@ import {
|
|||||||
|
|
||||||
export function VideoPreview({ url }: { url: string }) {
|
export function VideoPreview({ url }: { url: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-1 w-full overflow-hidden rounded-xl ring-1 ring-black/5 dark:ring-white/5">
|
<div className="my-1 w-full overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||||
<MediaController>
|
<MediaController>
|
||||||
<video
|
<video
|
||||||
slot="media"
|
slot="media"
|
||||||
src={url}
|
src={url}
|
||||||
preload="auto"
|
preload="auto"
|
||||||
muted
|
muted
|
||||||
className="h-auto w-full rounded-xl"
|
className="h-auto w-full"
|
||||||
/>
|
/>
|
||||||
<MediaControlBar>
|
<MediaControlBar>
|
||||||
<MediaPlayButton />
|
<MediaPlayButton />
|
||||||
|
@ -18,7 +18,7 @@ export function NoteThread({ className }: { className?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", className)}>
|
<div className={cn("w-full", className)}>
|
||||||
<div className="flex h-min w-full flex-col gap-3 rounded-xl bg-neutral-100 p-3 ring-1 ring-black/5 dark:bg-neutral-900 dark:ring-white/5">
|
<div className="flex h-min w-full flex-col gap-3 rounded-2xl border border-black/10 p-3 dark:border-white/10">
|
||||||
{thread.rootEventId ? (
|
{thread.rootEventId ? (
|
||||||
<Note.Child eventId={thread.rootEventId} isRoot />
|
<Note.Child eventId={thread.rootEventId} isRoot />
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -1,66 +0,0 @@
|
|||||||
import { CheckIcon, LoaderIcon } from "@lume/icons";
|
|
||||||
import { useStorage } from "@lume/storage";
|
|
||||||
import { onboardingAtom } from "@lume/utils";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export function OnboardingFinishScreen() {
|
|
||||||
const storage = useStorage();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const setOnboarding = useSetAtom(onboardingAtom);
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const finish = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (storage.interests) {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["foryou-9998"] });
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
setOnboarding({ open: false, newUser: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -20 }}
|
|
||||||
className="w-full h-full flex flex-col gap-2 items-center justify-center"
|
|
||||||
>
|
|
||||||
<CheckIcon className="size-12 text-teal-500" />
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg font-medium">{t("onboarding.finish.title")}</p>
|
|
||||||
<p className="leading-tight text-neutral-600 dark:text-neutral-400">
|
|
||||||
{t("onboarding.finish.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex flex-col gap-2 items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={finish}
|
|
||||||
className="inline-flex items-center justify-center gap-2 w-44 font-medium h-11 rounded-xl bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:bg-blue-800"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.close")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href="https://github.com/lumehq/lume/issues"
|
|
||||||
target="_blank"
|
|
||||||
className="inline-flex items-center justify-center gap-2 w-44 px-5 font-medium h-11 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-900 text-neutral-700 dark:text-neutral-600"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t("onboarding.finish.report")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
import { ArrowRightIcon, PopperFilledIcon } from "@lume/icons";
|
|
||||||
import { onboardingAtom } from "@lume/utils";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
export function OnboardingHomeScreen() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -20 }}
|
|
||||||
className="w-full h-full flex flex-col gap-2 items-center justify-center"
|
|
||||||
>
|
|
||||||
<PopperFilledIcon className="size-12 text-blue-500" />
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg font-medium">{t("onboarding.home.title")}</p>
|
|
||||||
<p className="leading-tight text-neutral-600 dark:text-neutral-400">
|
|
||||||
{t("onboarding.home.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex flex-col gap-2 items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
onboarding.newUser ? navigate("/profile") : navigate("/interests")
|
|
||||||
}
|
|
||||||
className="inline-flex items-center justify-center gap-2 w-44 font-medium h-11 rounded-xl bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:bg-blue-800"
|
|
||||||
>
|
|
||||||
{t("onboarding.home.profileSettings")}
|
|
||||||
<ArrowRightIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOnboarding({ open: false, newUser: false })}
|
|
||||||
className="inline-flex items-center justify-center gap-2 w-44 px-5 font-medium h-11 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-900 text-neutral-700 dark:text-neutral-600"
|
|
||||||
>
|
|
||||||
{t("global.skip")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,127 +0,0 @@
|
|||||||
import { ArrowLeftIcon, LoaderIcon } from "@lume/icons";
|
|
||||||
import { useStorage } from "@lume/storage";
|
|
||||||
import { TOPICS, cn } from "@lume/utils";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function OnboardingInterestScreen() {
|
|
||||||
const storage = useStorage();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [hashtags, setHashtags] = useState([]);
|
|
||||||
|
|
||||||
const toggleHashtag = (item: string) => {
|
|
||||||
const arr = hashtags.includes(item)
|
|
||||||
? hashtags.filter((i) => i !== item)
|
|
||||||
: [...hashtags, item];
|
|
||||||
setHashtags(arr);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAll = (item: string[]) => {
|
|
||||||
const sets = new Set([...hashtags, ...item]);
|
|
||||||
setHashtags([...sets]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (!hashtags.length) return navigate("/finish");
|
|
||||||
|
|
||||||
const save = await storage.createSetting(
|
|
||||||
"interests",
|
|
||||||
JSON.stringify({ hashtags }),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (save) {
|
|
||||||
storage.interests = { hashtags, users: [], words: [] };
|
|
||||||
return navigate("/finish");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex flex-col">
|
|
||||||
<div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h3 className="font-semibold">{t("interests.title")}</h3>
|
|
||||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
||||||
{t("interests.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex-1 min-h-0 flex flex-col justify-between">
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-8 py-8">
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
{TOPICS.map((topic) => (
|
|
||||||
<div key={topic.title} className="flex flex-col gap-4">
|
|
||||||
<div className="w-full flex items-center justify-between">
|
|
||||||
<div className="inline-flex items-center gap-2.5">
|
|
||||||
<img
|
|
||||||
src={topic.icon}
|
|
||||||
alt={topic.title}
|
|
||||||
className="size-8 object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
<h3 className="text-lg font-semibold">{topic.title}</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleAll(topic.content)}
|
|
||||||
className="text-sm font-medium text-blue-500"
|
|
||||||
>
|
|
||||||
{t("interests.followAll")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{topic.content.map((hashtag) => (
|
|
||||||
<button
|
|
||||||
key={hashtag}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleHashtag(hashtag)}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center rounded-full bg-neutral-100 dark:bg-neutral-900 border border-transparent px-2 py-1 text-sm font-medium",
|
|
||||||
hashtags.includes(hashtag)
|
|
||||||
? "border-blue-500 text-blue-500"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hashtag}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-16 shrink-0 w-full flex items-center px-8 justify-center gap-2 border-t border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-4" />
|
|
||||||
{t("global.back")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
className="inline-flex h-9 flex-1 shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.continue")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
import { onboardingAtom } from "@lume/utils";
|
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { OnboardingRouter } from "./router";
|
|
||||||
|
|
||||||
export function OnboardingModal() {
|
|
||||||
const onboarding = useAtomValue(onboardingAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root open={onboarding.open}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/10 backdrop-blur-sm dark:bg-white/10" />
|
|
||||||
<Dialog.Content className="fixed inset-0 z-50 flex items-center justify-center min-h-full">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="h-20 absolute top-0 left-0 w-full"
|
|
||||||
/>
|
|
||||||
<div className="relative w-full max-w-xl xl:max-w-2xl bg-white h-[600px] xl:h-[700px] rounded-xl dark:bg-black overflow-hidden">
|
|
||||||
<OnboardingRouter />
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,171 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { ArrowLeftIcon, LoaderIcon } from "@lume/icons";
|
|
||||||
import { useStorage } from "@lume/storage";
|
|
||||||
import { NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { minidenticon } from "minidenticons";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AvatarUploadButton } from "../avatarUploadButton";
|
|
||||||
|
|
||||||
export function OnboardingProfileScreen() {
|
|
||||||
const [picture, setPicture] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const ark = useArk();
|
|
||||||
const storage = useStorage();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { register, handleSubmit } = useForm();
|
|
||||||
|
|
||||||
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
|
|
||||||
minidenticon(ark.account.pubkey, 90, 50),
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
const onSubmit = async (data: { name: string; about: string }) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (!data.name.length && !data.about.length) {
|
|
||||||
setLoading(false);
|
|
||||||
navigate("/interests");
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevProfile = await ark.getUserProfile();
|
|
||||||
const newProfile: NDKUserProfile = {
|
|
||||||
...data,
|
|
||||||
nip05: prevProfile?.nip05 || "",
|
|
||||||
bio: data.about,
|
|
||||||
image: picture,
|
|
||||||
picture: picture,
|
|
||||||
};
|
|
||||||
|
|
||||||
const publish = await ark.createEvent({
|
|
||||||
content: JSON.stringify(newProfile),
|
|
||||||
kind: NDKKind.Metadata,
|
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (publish) {
|
|
||||||
// invalid cache
|
|
||||||
await storage.clearProfileCache(ark.account.pubkey);
|
|
||||||
await queryClient.setQueryData(
|
|
||||||
["user", ark.account.pubkey],
|
|
||||||
() => newProfile,
|
|
||||||
);
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
navigate("/interests");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex flex-col gap-4">
|
|
||||||
<div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h3 className="font-semibold">{t("onboarding.profile.title")}</h3>
|
|
||||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
||||||
{t("onboarding.profile.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="w-full flex-1 mb-0 flex flex-col justify-between"
|
|
||||||
>
|
|
||||||
<input type={"hidden"} {...register("picture")} value={picture} />
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -20 }}
|
|
||||||
className="flex flex-col px-8 gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">{t("user.avatar")}</span>
|
|
||||||
<div className="flex h-36 w-full flex-col items-center justify-center gap-3 rounded-lg bg-neutral-100 dark:bg-neutral-950">
|
|
||||||
{picture.length ? (
|
|
||||||
<img
|
|
||||||
src={picture}
|
|
||||||
alt="user's avatar"
|
|
||||||
className="size-16 rounded-xl object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={svgURI}
|
|
||||||
alt="user's avatar"
|
|
||||||
className="size-16 rounded-xl bg-black dark:bg-white"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<AvatarUploadButton setPicture={setPicture} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label htmlFor="name" className="font-medium">
|
|
||||||
{t("user.name")} *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={"text"}
|
|
||||||
{...register("name", { required: true, minLength: 1 })}
|
|
||||||
placeholder="e.g. Alice"
|
|
||||||
spellCheck={false}
|
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label htmlFor="about" className="font-medium">
|
|
||||||
{t("user.bio")}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
{...register("about")}
|
|
||||||
placeholder="e.g. Artist, anime-lover, and k-pop fan"
|
|
||||||
spellCheck={false}
|
|
||||||
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label htmlFor="website" className="font-medium">
|
|
||||||
{t("user.website")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
{...register("website")}
|
|
||||||
placeholder="e.g. https://alice.me"
|
|
||||||
spellCheck={false}
|
|
||||||
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-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
<div className="h-16 w-full flex items-center px-8 justify-center gap-2 border-t border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-4" />
|
|
||||||
{t("global.back")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="inline-flex h-9 flex-1 shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.continue")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
import { AnimatePresence } from "framer-motion";
|
|
||||||
import {
|
|
||||||
MemoryRouter,
|
|
||||||
Route,
|
|
||||||
Routes,
|
|
||||||
UNSAFE_LocationContext,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { OnboardingFinishScreen } from "./finish";
|
|
||||||
import { OnboardingHomeScreen } from "./home";
|
|
||||||
import { OnboardingInterestScreen } from "./interest";
|
|
||||||
import { OnboardingProfileScreen } from "./profile";
|
|
||||||
|
|
||||||
export function OnboardingRouter() {
|
|
||||||
return (
|
|
||||||
<UNSAFE_LocationContext.Provider value={null}>
|
|
||||||
<MemoryRouter future={{ v7_startTransition: true }}>
|
|
||||||
<AnimatePresence>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<OnboardingHomeScreen />} />
|
|
||||||
<Route path="/profile" element={<OnboardingProfileScreen />} />
|
|
||||||
<Route path="/interests" element={<OnboardingInterestScreen />} />
|
|
||||||
<Route path="/finish" element={<OnboardingFinishScreen />} />
|
|
||||||
</Routes>
|
|
||||||
</AnimatePresence>
|
|
||||||
</MemoryRouter>
|
|
||||||
</UNSAFE_LocationContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { WindowVirtualizer } from "virtua";
|
|
||||||
import { ThreadNote } from "../note/primitives/thread";
|
|
||||||
|
|
||||||
export function EventRoute() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto pb-5">
|
|
||||||
<WindowVirtualizer>
|
|
||||||
<div className="relative z-50 mb-3 flex h-11 items-center justify-start gap-2 border-b border-neutral-100 bg-neutral-50 px-3 dark:border-neutral-900 dark:bg-neutral-950">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
|
||||||
onClick={() => navigate(1)}
|
|
||||||
>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-3">
|
|
||||||
<ThreadNote eventId={id} />
|
|
||||||
<ReplyList eventId={id} className="mt-3" />
|
|
||||||
</div>
|
|
||||||
</WindowVirtualizer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,127 +0,0 @@
|
|||||||
import { ArrowLeftIcon, ArrowRightIcon, LoaderIcon } from "@lume/icons";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { WindowVirtualizer } from "virtua";
|
|
||||||
import { User } from "../user";
|
|
||||||
|
|
||||||
const POPULAR_USERS = [
|
|
||||||
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
|
|
||||||
"npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
|
|
||||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
|
||||||
"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
|
||||||
"npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
|
|
||||||
"npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
|
|
||||||
"npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc",
|
|
||||||
"npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza",
|
|
||||||
"npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424",
|
|
||||||
"npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac",
|
|
||||||
"npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv",
|
|
||||||
"npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
|
|
||||||
];
|
|
||||||
|
|
||||||
const LUME_USERS = [
|
|
||||||
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SuggestRoute({ queryKey }: { queryKey: string }) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isLoading, isError, data } = useQuery({
|
|
||||||
queryKey: ["trending-users"],
|
|
||||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
|
||||||
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to fetch trending users from nostr.band API.");
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
await queryClient.refetchQueries({ queryKey: [queryKey] });
|
|
||||||
return navigate("/", { replace: true });
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto pb-5">
|
|
||||||
<WindowVirtualizer>
|
|
||||||
<div className="mb-3 flex h-11 items-center justify-start gap-2 border-b border-neutral-100 bg-neutral-50 px-3 dark:border-neutral-900 dark:bg-neutral-950">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
|
||||||
onClick={() => navigate(1)}
|
|
||||||
>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="relative px-3">
|
|
||||||
<div className="flex h-16 items-center">
|
|
||||||
<h3 className="text-xl font-semibold">{t("suggestion.title")}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-44 w-full items-center justify-center">
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : isError ? (
|
|
||||||
<div className="flex h-44 w-full items-center justify-center">
|
|
||||||
{t("suggestion.error")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data?.profiles.map((item: { pubkey: string }) => (
|
|
||||||
<div
|
|
||||||
key={item.pubkey}
|
|
||||||
className="h-max w-full overflow-hidden py-5"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={item.pubkey}>
|
|
||||||
<User.Root>
|
|
||||||
<div className="flex h-full w-full flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<User.Avatar className="size-10 shrink-0 rounded-lg" />
|
|
||||||
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
|
|
||||||
</div>
|
|
||||||
<User.Button
|
|
||||||
target={item.pubkey}
|
|
||||||
className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="sticky bottom-0 z-10 flex w-full items-center justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
className="inline-flex h-11 w-44 transform items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white shadow-xl shadow-neutral-500/50 hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:cursor-not-allowed dark:shadow-none"
|
|
||||||
>
|
|
||||||
{t("suggestion.button")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</WindowVirtualizer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,148 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import {
|
|
||||||
ArrowLeftIcon,
|
|
||||||
ArrowRightCircleIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
LoaderIcon,
|
|
||||||
} from "@lume/icons";
|
|
||||||
import { FETCH_LIMIT } from "@lume/utils";
|
|
||||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { WindowVirtualizer } from "virtua";
|
|
||||||
import { User } from "../user";
|
|
||||||
|
|
||||||
export function UserRoute() {
|
|
||||||
const ark = useArk();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { id } = useParams();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
|
||||||
useInfiniteQuery({
|
|
||||||
queryKey: ["user-posts", id],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({
|
|
||||||
signal,
|
|
||||||
pageParam,
|
|
||||||
}: {
|
|
||||||
signal: AbortSignal;
|
|
||||||
pageParam: number;
|
|
||||||
}) => {
|
|
||||||
const events = await ark.getInfiniteEvents({
|
|
||||||
filter: {
|
|
||||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
|
||||||
authors: [id],
|
|
||||||
},
|
|
||||||
limit: FETCH_LIMIT,
|
|
||||||
pageParam,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const lastEvent = lastPage.at(-1);
|
|
||||||
if (!lastEvent) return;
|
|
||||||
return lastEvent.created_at - 1;
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allEvents = useMemo(
|
|
||||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderItem = (event: NDKEvent) => {
|
|
||||||
switch (event.kind) {
|
|
||||||
case NDKKind.Text:
|
|
||||||
return <TextNote key={event.id} event={event} className="mt-3" />;
|
|
||||||
case NDKKind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} className="mt-3" />;
|
|
||||||
default:
|
|
||||||
return <TextNote key={event.id} event={event} className="mt-3" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto pb-5">
|
|
||||||
<WindowVirtualizer>
|
|
||||||
<div className="mb-3 flex h-11 items-center justify-start gap-2 border-b border-neutral-100 bg-neutral-50 px-3 dark:border-neutral-900 dark:bg-neutral-950">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
|
|
||||||
onClick={() => navigate(1)}
|
|
||||||
>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-3">
|
|
||||||
<User.Provider pubkey={id}>
|
|
||||||
<User.Root className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<User.Avatar className="h-12 w-12 shrink-0 rounded-lg object-cover" />
|
|
||||||
<User.Button
|
|
||||||
target={id}
|
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 text-sm font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<User.Name className="text-lg font-semibold" />
|
|
||||||
<User.NIP05
|
|
||||||
pubkey={id}
|
|
||||||
className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<User.About className="text-neutral-900 dark:text-neutral-100" />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="mt-2 border-t border-neutral-100 pt-2 dark:border-neutral-900">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
|
||||||
{t("user.latestPosts")}
|
|
||||||
</h3>
|
|
||||||
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
allEvents.map((item) => renderItem(item))
|
|
||||||
)}
|
|
||||||
<div className="flex h-16 items-center justify-center px-3 pb-3">
|
|
||||||
{hasNextPage ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={!hasNextPage || isFetchingNextPage}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
|
||||||
{t("global.loadMore")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</WindowVirtualizer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,145 +0,0 @@
|
|||||||
import { useArk } from "@lume/ark";
|
|
||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
|
||||||
import { fetch } from "@tauri-apps/plugin-http";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { renderSVG } from "uqr";
|
|
||||||
|
|
||||||
export function TranslateRegisterModal({ setAPIKey }) {
|
|
||||||
const ark = useArk();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [invoice, setInvoice] = useState<{ api_key: string; bolt11: string }>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const createInvoice = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const res = await fetch("https://translate.nostr.wine/api/create", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
pubkey: ark.account.pubkey,
|
|
||||||
amount: 2500,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res) {
|
|
||||||
const data = await res.json();
|
|
||||||
setInvoice(data);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const finish = () => {
|
|
||||||
if (!invoice) return;
|
|
||||||
|
|
||||||
setAPIKey(invoice.api_key);
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-2 w-full h-11 rounded-lg bg-neutral-900 font-medium inline-flex items-center justify-center"
|
|
||||||
>
|
|
||||||
Register
|
|
||||||
</button>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 z-50 backdrop-blur-sm bg-white/10" />
|
|
||||||
<Dialog.Content className="fixed inset-0 z-50 flex items-center justify-center min-h-full">
|
|
||||||
<div className="flex flex-col justify-between w-full max-w-lg h-[500px] rounded-xl bg-black text-neutral-50 overflow-hidden">
|
|
||||||
<div className="h-12 shrink-0 px-8 border-b border-neutral-950 flex font-medium w-full items-center justify-center">
|
|
||||||
Register Translate Service
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 flex flex-col justify-between px-8 py-8">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<p className="text-sm text-neutral-500">
|
|
||||||
Translation Service is provided by{" "}
|
|
||||||
<span className="text-blue-500">nostr.wine</span>. Prices
|
|
||||||
start at 2,500 sats for 50,000 characters of translated text.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-neutral-500">
|
|
||||||
You can learn more about nostr.wine{" "}
|
|
||||||
<a
|
|
||||||
href="https://nostr.wine/"
|
|
||||||
target="_blank"
|
|
||||||
className="text-blue-500 hover:text-blue-600"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!invoice ? (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<img
|
|
||||||
src="/translate.jpg"
|
|
||||||
srcSet="/translate@2x.jpg 2x"
|
|
||||||
alt="translate"
|
|
||||||
className="w-full h-auto object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={createInvoice}
|
|
||||||
className="w-full h-10 shrink-0 rounded-lg bg-blue-500 hover:bg-blue-600 text-white inline-flex items-center justify-center font-medium"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Create Invoice"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h3 className="font-semibold">API Key</h3>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readOnly
|
|
||||||
value={invoice.api_key}
|
|
||||||
className="w-full border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-11 rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-950"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full rounded-lg h-56 gap-3 flex flex-col items-center justify-center bg-neutral-950">
|
|
||||||
<img
|
|
||||||
src={`data:image/svg+xml;utf8,${renderSVG(
|
|
||||||
invoice.bolt11,
|
|
||||||
)}`}
|
|
||||||
alt={invoice.api_key}
|
|
||||||
className="bg-white w-36 h-36"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-neutral-400">
|
|
||||||
Scan and Pay with Lightning Wallet
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={finish}
|
|
||||||
className="w-full h-10 shrink-0 rounded-lg bg-blue-500 hover:bg-blue-600 text-white inline-flex items-center justify-center font-medium"
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
import { tutorialAtom } from "@lume/utils";
|
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
|
|
||||||
export function TutorialFinishScreen() {
|
|
||||||
const tutorial = useSetAtom(tutorialAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-5 h-full flex flex-col justify-between">
|
|
||||||
<div className="h-full min-h-0 flex flex-col gap-2">
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold">Great Job!</span> You have completed
|
|
||||||
this section. Feel free to explore other menus in the interface, such
|
|
||||||
as Activity and Relay Explorer.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you want to see this tutorial again, don't hesitate to press the ?
|
|
||||||
icon in Bottom bar
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you want to seek help from Lume Devs, you can publish a post with{" "}
|
|
||||||
<span className="text-blue-500">#lumesos</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-16 w-full shrink-0 flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => tutorial(false)}
|
|
||||||
className="inline-flex items-center justify-center w-20 font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
Finish
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
export function TutorialManageColumnScreen() {
|
|
||||||
return (
|
|
||||||
<div className="px-5 h-full flex flex-col justify-between">
|
|
||||||
<div className="h-full min-h-0 flex flex-col gap-2">
|
|
||||||
<p>
|
|
||||||
Once a new column is created, you can click on the title in its header
|
|
||||||
to find options to <span className="font-semibold">customize</span> it
|
|
||||||
</p>
|
|
||||||
<img
|
|
||||||
src="/tutorial-3.gif"
|
|
||||||
alt="tutorial-3"
|
|
||||||
className="w-full h-auto rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="h-16 w-full shrink-0 flex items-center justify-end">
|
|
||||||
<Link
|
|
||||||
to="/finish"
|
|
||||||
className="inline-flex items-center justify-center w-20 font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,38 +0,0 @@
|
|||||||
import { CancelIcon, HelpIcon } from "@lume/icons";
|
|
||||||
import { tutorialAtom } from "@lume/utils";
|
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { TutorialRouter } from "./router";
|
|
||||||
|
|
||||||
export function TutorialModal() {
|
|
||||||
const [tutorial, setTutorial] = useAtom(tutorialAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover.Root open={tutorial}>
|
|
||||||
<Popover.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTutorial((state) => !state)}
|
|
||||||
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/50 size-10"
|
|
||||||
>
|
|
||||||
<HelpIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Portal>
|
|
||||||
<Popover.Content className="relative right-4 bottom-8">
|
|
||||||
<div className="flex flex-col w-full max-w-xs bg-white h-[480px] rounded-xl dark:bg-neutral-950 dark:border dark:border-neutral-900 overflow-hidden shadow-[0_8px_30px_rgb(0,0,0,0.12)]">
|
|
||||||
<div className="pt-5 mb-3 shrink-0 flex px-5 items-center justify-between text-neutral-500 dark:text-neutral-400">
|
|
||||||
<h3 className="text-sm font-medium">Tutorial</h3>
|
|
||||||
<Popover.Close onClick={() => setTutorial(false)}>
|
|
||||||
<CancelIcon className="size-4" />
|
|
||||||
</Popover.Close>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 flex-1 h-full">
|
|
||||||
<TutorialRouter />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover.Root>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
export function TutorialNewColumnScreen() {
|
|
||||||
return (
|
|
||||||
<div className="px-5 h-full flex flex-col justify-between">
|
|
||||||
<div className="h-full min-h-0 flex flex-col gap-2">
|
|
||||||
<p>Lume is column based, each column is its own experience.</p>
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold">To create a new column</span>, you can
|
|
||||||
click on the "Plus" icon at bottom right corner of this window.
|
|
||||||
</p>
|
|
||||||
<p>Click to "Plus" icon</p>
|
|
||||||
<img
|
|
||||||
src="/tutorial-2.gif"
|
|
||||||
alt="tutorial-2"
|
|
||||||
className="w-full h-auto rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="h-16 w-full shrink-0 flex items-center justify-end">
|
|
||||||
<Link
|
|
||||||
to="/manage-column"
|
|
||||||
className="inline-flex items-center justify-center w-20 font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
import { AnimatePresence } from "framer-motion";
|
|
||||||
import {
|
|
||||||
MemoryRouter,
|
|
||||||
Route,
|
|
||||||
Routes,
|
|
||||||
UNSAFE_LocationContext,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { TutorialFinishScreen } from "./finish";
|
|
||||||
import { TutorialManageColumnScreen } from "./manageColumn";
|
|
||||||
import { TutorialNewColumnScreen } from "./newColumn";
|
|
||||||
import { TutorialWelcomeScreen } from "./welcome";
|
|
||||||
|
|
||||||
export function TutorialRouter() {
|
|
||||||
return (
|
|
||||||
<UNSAFE_LocationContext.Provider value={null}>
|
|
||||||
<MemoryRouter future={{ v7_startTransition: true }}>
|
|
||||||
<AnimatePresence>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<TutorialWelcomeScreen />} />
|
|
||||||
<Route path="/new-column" element={<TutorialNewColumnScreen />} />
|
|
||||||
<Route
|
|
||||||
path="/manage-column"
|
|
||||||
element={<TutorialManageColumnScreen />}
|
|
||||||
/>
|
|
||||||
<Route path="/finish" element={<TutorialFinishScreen />} />
|
|
||||||
</Routes>
|
|
||||||
</AnimatePresence>
|
|
||||||
</MemoryRouter>
|
|
||||||
</UNSAFE_LocationContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
export function TutorialWelcomeScreen() {
|
|
||||||
return (
|
|
||||||
<div className="px-5 h-full flex flex-col justify-between">
|
|
||||||
<div className="h-full min-h-0 flex flex-col gap-2">
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold">Welcome to your Home Screen.</span>{" "}
|
|
||||||
This is your personalized screen, which you can customize to you
|
|
||||||
liking
|
|
||||||
</p>
|
|
||||||
<p>Feel free to make adjustments as needed.</p>
|
|
||||||
<p>Let's take a few minutes to explore the features together.</p>
|
|
||||||
<img
|
|
||||||
src="/tutorial-1.gif"
|
|
||||||
alt="tutorial-1"
|
|
||||||
className="w-full h-auto rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="h-16 w-full shrink-0 flex items-center justify-end">
|
|
||||||
<Link
|
|
||||||
to="/new-column"
|
|
||||||
className="inline-flex items-center justify-center w-20 font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
import { activityUnreadAtom, compactNumber } from "@lume/utils";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
|
|
||||||
export function UnreadActivity() {
|
|
||||||
const total = useAtomValue(activityUnreadAtom);
|
|
||||||
|
|
||||||
if (total <= 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute -right-0.5 -top-0.5 inline-flex size-5 items-center justify-center rounded-full bg-teal-500 text-[9px] font-medium text-white">
|
|
||||||
{compactNumber.format(total)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -35,6 +35,7 @@
|
|||||||
"webview:allow-create-webview",
|
"webview:allow-create-webview",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"fs:allow-read-file",
|
"fs:allow-read-file",
|
||||||
|
"shell:allow-open",
|
||||||
{
|
{
|
||||||
"identifier": "http:default",
|
"identifier": "http:default",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
@ -1 +1 @@
|
|||||||
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","remote":null,"local":true,"windows":["main","splash","editor","settings","event-*","user-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:allow-check","updater:default","window:allow-start-dragging","store:allow-get","clipboard-manager:allow-write","clipboard-manager:allow-read","webview:allow-create-webview-window","webview:allow-create-webview","dialog:allow-open","fs:allow-read-file",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}}
|
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","remote":null,"local":true,"windows":["main","splash","editor","settings","event-*","user-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:allow-check","updater:default","window:allow-start-dragging","store:allow-get","clipboard-manager:allow-write","clipboard-manager:allow-read","webview:allow-create-webview-window","webview:allow-create-webview","dialog:allow-open","fs:allow-read-file","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}}
|
@ -12,11 +12,9 @@ use keyring::Entry;
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use tauri_plugin_autostart::MacosLauncher;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
pub struct Nostr {
|
pub struct Nostr {
|
||||||
client: Client,
|
client: Client,
|
||||||
contact_list: Mutex<Option<Vec<PublicKey>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@ -68,7 +66,6 @@ fn main() {
|
|||||||
// Update global state
|
// Update global state
|
||||||
handle.manage(Nostr {
|
handle.manage(Nostr {
|
||||||
client: client.into(),
|
client: client.into(),
|
||||||
contact_list: Mutex::new(None),
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -50,9 +50,15 @@ pub async fn get_local_events(
|
|||||||
None => Timestamp::now(),
|
None => Timestamp::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let contact_list = state.contact_list.lock().await;
|
let contact_list = client
|
||||||
|
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(authors) = contact_list {
|
||||||
|
if authors.len() == 0 {
|
||||||
|
return Err("Get text event failed".into());
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(authors) = contact_list.clone() {
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||||
.authors(authors)
|
.authors(authors)
|
||||||
@ -68,7 +74,7 @@ pub async fn get_local_events(
|
|||||||
Err("Get text event failed".into())
|
Err("Get text event failed".into())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err("Contact list not found".into())
|
Err("Get contact list failed".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@ use keyring::Entry;
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use std::io::{BufReader, Read};
|
use std::io::{BufReader, Read};
|
||||||
use std::iter;
|
use std::iter;
|
||||||
use std::time::Duration;
|
|
||||||
use std::{fs::File, io::Write, str::FromStr};
|
use std::{fs::File, io::Write, str::FromStr};
|
||||||
use tauri::{Manager, State};
|
use tauri::{Manager, State};
|
||||||
|
|
||||||
@ -42,15 +41,6 @@ pub async fn save_key(
|
|||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
client.set_signer(Some(signer)).await;
|
client.set_signer(Some(signer)).await;
|
||||||
|
|
||||||
// Update contact list
|
|
||||||
let mut contact_list = state.contact_list.lock().await;
|
|
||||||
if let Ok(list) = client
|
|
||||||
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
*contact_list = Some(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
let keyring_entry = Entry::new("Lume Secret Storage", "AppKey").unwrap();
|
let keyring_entry = Entry::new("Lume Secret Storage", "AppKey").unwrap();
|
||||||
let secret_key = keyring_entry.get_password().unwrap();
|
let secret_key = keyring_entry.get_password().unwrap();
|
||||||
let app_key = age::x25519::Identity::from_str(&secret_key).unwrap();
|
let app_key = age::x25519::Identity::from_str(&secret_key).unwrap();
|
||||||
@ -154,15 +144,6 @@ pub async fn load_selected_account(
|
|||||||
// Update signer
|
// Update signer
|
||||||
client.set_signer(Some(signer)).await;
|
client.set_signer(Some(signer)).await;
|
||||||
|
|
||||||
// Update contact list
|
|
||||||
let mut contact_list = state.contact_list.lock().await;
|
|
||||||
if let Ok(list) = client
|
|
||||||
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
*contact_list = Some(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
} else {
|
} else {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
|
Loading…
Reference in New Issue
Block a user