chore: clean up

This commit is contained in:
reya 2024-02-25 15:52:47 +07:00
parent 2c8dd71792
commit 63db8b1423
39 changed files with 88 additions and 2501 deletions

View File

@ -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>
) : ( ) : (

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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);
};

View File

@ -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";

View File

@ -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>
);
}

View File

@ -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);

View File

@ -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")}:

View File

@ -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" />

View File

@ -5,60 +5,60 @@ import { download } from "@tauri-apps/plugin-upload";
import { SyntheticEvent, useState } from "react"; import { SyntheticEvent, useState } from "react";
export function ImagePreview({ url }: { url: string }) { export function ImagePreview({ url }: { url: string }) {
const [downloaded, setDownloaded] = useState(false); const [downloaded, setDownloaded] = useState(false);
const downloadImage = async (e: { stopPropagation: () => void }) => { const downloadImage = async (e: { stopPropagation: () => void }) => {
try { try {
e.stopPropagation(); e.stopPropagation();
const downloadDirPath = await downloadDir(); const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf("/") + 1); const filename = url.substring(url.lastIndexOf("/") + 1);
await download(url, `${downloadDirPath}/${filename}`); await download(url, `${downloadDirPath}/${filename}`);
setDownloaded(true); setDownloaded(true);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
}; };
const open = async () => { const open = async () => {
const name = new URL(url).pathname.split("/").pop(); const name = new URL(url).pathname.split("/").pop();
return new WebviewWindow("image-viewer", { return new WebviewWindow("image-viewer", {
url, url,
title: name, title: name,
}); });
}; };
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => { const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
event.currentTarget.src = "/fallback-image.jpg"; event.currentTarget.src = "/fallback-image.jpg";
}; };
return ( return (
// 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}
alt={url} alt={url}
loading="lazy" loading="lazy"
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" />
) : ( ) : (
<DownloadIcon className="size-5" /> <DownloadIcon className="size-5" />
)} )}
</button> </button>
</div> </div>
); );
} }

View File

@ -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}

View File

@ -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 />

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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": [

View File

@ -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"]}}

View File

@ -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),
}) })
}); });

View File

@ -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())
} }
} }

View File

@ -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)