mirror of
https://github.com/luminous-devs/lume.git
synced 2024-10-01 09:21:07 +00:00
feat: add search
This commit is contained in:
parent
207e063d56
commit
8aac349a2e
@ -1,12 +1,11 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import { StrictMode } from "react";
|
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
|
import { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { routeTree } from "./router.gen"; // auto generated file
|
import { routeTree } from "./router.gen"; // auto generated file
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
|
||||||
// Set up a Router instance
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const platform = type();
|
const platform = type();
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Column } from "@/components/column";
|
import { Column } from "@/components/column";
|
||||||
import { Toolbar } from "@/components/toolbar";
|
import { Toolbar } from "@/components/toolbar";
|
||||||
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { NostrQuery } from "@lume/system";
|
import { NostrQuery } from "@lume/system";
|
||||||
import type { ColumnEvent, LumeColumn } from "@lume/types";
|
import type { ColumnEvent, LumeColumn } from "@lume/types";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
@ -45,17 +45,6 @@ function Screen() {
|
|||||||
getCurrent().emit("child-webview", { resize: true, direction: "x" });
|
getCurrent().emit("child-webview", { resize: true, direction: "x" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openLumeStore = useDebouncedCallback(async () => {
|
|
||||||
await getCurrent().emit("columns", {
|
|
||||||
type: "add",
|
|
||||||
column: {
|
|
||||||
label: "store",
|
|
||||||
name: "Store",
|
|
||||||
content: "/store/official",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||||
column.label = `${column.label}-${nanoid()}`; // update col label
|
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||||
setColumns((prev) => [column, ...prev]);
|
setColumns((prev) => [column, ...prev]);
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
import { ChevronDownIcon, ComposeFilledIcon, PlusIcon } from "@lume/icons";
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ComposeFilledIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from "@lume/icons";
|
||||||
import { LumeWindow, NostrAccount } from "@lume/system";
|
import { LumeWindow, NostrAccount } from "@lume/system";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback, useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account")({
|
export const Route = createFileRoute("/$account")({
|
||||||
beforeLoad: async ({ params }) => {
|
beforeLoad: async ({ params }) => {
|
||||||
@ -23,6 +28,17 @@ export const Route = createFileRoute("/$account")({
|
|||||||
function Screen() {
|
function Screen() {
|
||||||
const { platform } = Route.useRouteContext();
|
const { platform } = Route.useRouteContext();
|
||||||
|
|
||||||
|
const openLumeStore = async () => {
|
||||||
|
await getCurrent().emit("columns", {
|
||||||
|
type: "add",
|
||||||
|
column: {
|
||||||
|
label: "store",
|
||||||
|
name: "Store",
|
||||||
|
content: "/store/official",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-screen h-screen">
|
<div className="flex flex-col w-screen h-screen">
|
||||||
<div
|
<div
|
||||||
@ -38,22 +54,25 @@ function Screen() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-1 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
onClick={() => openLumeStore()}
|
||||||
|
className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-5" />
|
<PlusIcon className="size-5" />
|
||||||
Add column
|
Column
|
||||||
</button>
|
</button>
|
||||||
<div id="toolbar" />
|
<div id="toolbar" />
|
||||||
</div>
|
</div>
|
||||||
<div data-tauri-drag-region className="hidden md:flex md:flex-1" />
|
<div data-tauri-drag-region className="hidden md:flex md:flex-1">
|
||||||
|
<Search />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="flex-1 flex items-center justify-end gap-2"
|
className="flex-1 flex items-center justify-end gap-3"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEditor()}
|
onClick={() => LumeWindow.openEditor()}
|
||||||
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium text-white bg-blue-500 rounded-full w-max hover:bg-blue-600"
|
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
|
||||||
>
|
>
|
||||||
<ComposeFilledIcon className="size-4" />
|
<ComposeFilledIcon className="size-4" />
|
||||||
New Post
|
New Post
|
||||||
@ -127,7 +146,7 @@ const Accounts = memo(function Accounts() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-tauri-drag-region className="hidden md:flex items-center gap-2">
|
<div data-tauri-drag-region className="hidden md:flex items-center gap-3">
|
||||||
{otherAccounts.map((npub) => (
|
{otherAccounts.map((npub) => (
|
||||||
<button key={npub} type="button" onClick={(e) => changeAccount(npub)}>
|
<button key={npub} type="button" onClick={(e) => changeAccount(npub)}>
|
||||||
<User.Provider pubkey={npub}>
|
<User.Provider pubkey={npub}>
|
||||||
@ -152,3 +171,55 @@ const Accounts = memo(function Accounts() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const Search = memo(function Search() {
|
||||||
|
const [searchType, setSearchType] = useState<"notes" | "users">("notes");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Notes",
|
||||||
|
action: () => setSearchType("notes"),
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Users",
|
||||||
|
action: () => setSearchType("users"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-full px-3 text-sm rounded-full inline-flex items-center bg-black/5 dark:bg-white/5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className="inline-flex items-center gap-1 capitalize text-sm font-medium pr-2 border-r border-black/10 dark:border-white/10"
|
||||||
|
>
|
||||||
|
{searchType}
|
||||||
|
<ChevronDownIcon className="size-3 text-black/50 dark:text-white/50" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
LumeWindow.openSearch(searchType, query);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-full w-full px-3 text-sm rounded-full border-none ring-0 focus:ring-0 focus:outline-none bg-transparent placeholder:text-black/50 dark:placeholder:text-black/50"
|
||||||
|
/>
|
||||||
|
<SearchIcon className="size-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
@ -1,52 +0,0 @@
|
|||||||
import { ZapIcon } from "@lume/icons";
|
|
||||||
import { NostrAccount } from "@lume/system";
|
|
||||||
import { Container } from "@lume/ui";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/nwc")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const [uri, setUri] = useState("");
|
|
||||||
const [isDone, setIsDone] = useState(false);
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
const nwc = await NostrAccount.setWallet(uri);
|
|
||||||
setIsDone(nwc);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container withDrag>
|
|
||||||
<div className="flex-1 w-full h-full px-5">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-light">
|
|
||||||
Connect <span className="font-semibold">bitcoin wallet</span> to
|
|
||||||
start zapping to your favorite content and creator.
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 mt-10">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label>Paste a Nostr Wallet Connect connection string</label>
|
|
||||||
<textarea
|
|
||||||
value={uri}
|
|
||||||
onChange={(e) => setUri(e.target.value)}
|
|
||||||
placeholder="nostrconnect://"
|
|
||||||
className="w-full h-24 px-3 bg-transparent rounded-lg border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={save}
|
|
||||||
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
Save & Connect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
122
apps/desktop2/src/routes/search.notes.tsx
Normal file
122
apps/desktop2/src/routes/search.notes.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { Conversation } from "@/components/conversation";
|
||||||
|
import { Quote } from "@/components/quote";
|
||||||
|
import { RepostNote } from "@/components/repost";
|
||||||
|
import { TextNote } from "@/components/text";
|
||||||
|
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||||
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
|
import { Kind } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
|
type Search = {
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/search/notes")({
|
||||||
|
validateSearch: (search: Record<string, string>): Search => {
|
||||||
|
return {
|
||||||
|
query: search.query,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const settings = await NostrQuery.getUserSettings();
|
||||||
|
return { settings };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { query } = Route.useSearch();
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["search", query],
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
|
const events = await NostrQuery.searchEvent(query, pageParam);
|
||||||
|
return events;
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||||
|
select: (data) => data?.pages.flat(),
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(event: LumeEvent) => {
|
||||||
|
if (!event) return;
|
||||||
|
switch (event.kind) {
|
||||||
|
case Kind.Repost:
|
||||||
|
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
||||||
|
default: {
|
||||||
|
if (event.isConversation) {
|
||||||
|
return (
|
||||||
|
<Conversation key={event.id} className="mb-3" event={event} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.isQuote) {
|
||||||
|
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||||
|
}
|
||||||
|
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
|
||||||
|
<Virtualizer scrollRef={ref}>
|
||||||
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
|
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : !data.length ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
Yo. You're catching up on all notes
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((item) => renderItem(item))
|
||||||
|
)}
|
||||||
|
{data?.length && hasNextPage ? (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage || isLoading}
|
||||||
|
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
|
Load more
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Virtualizer>
|
||||||
|
</ScrollArea.Viewport>
|
||||||
|
);
|
||||||
|
}
|
@ -1,148 +1,25 @@
|
|||||||
import { Note } from "@/components/note";
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
import { User } from "@/components/user";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
import { SearchIcon } from "@lume/icons";
|
|
||||||
import { LumeEvent, LumeWindow } from "@lume/system";
|
|
||||||
import { Kind, type NostrEvent } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/search")({
|
export const Route = createFileRoute("/search")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [events, setEvents] = useState<LumeEvent[]>([]);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [searchValue] = useDebounce(search, 500);
|
|
||||||
|
|
||||||
const searchEvents = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
|
|
||||||
const res = await fetch(query);
|
|
||||||
const content = await res.json();
|
|
||||||
const events = content.data as NostrEvent[];
|
|
||||||
const lumeEvents = events.map((ev) => new LumeEvent(ev));
|
|
||||||
const sorted = lumeEvents.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
setEvents(sorted);
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
await message(String(e), {
|
|
||||||
title: "Search",
|
|
||||||
kind: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchValue.length >= 3 && searchValue.length < 500) {
|
|
||||||
searchEvents();
|
|
||||||
}
|
|
||||||
}, [searchValue]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-tauri-drag-region className="flex flex-col w-full h-full">
|
<ScrollArea.Root
|
||||||
<div className="relative flex flex-col h-24 border-b shrink-0 border-black/5 dark:border-white/5">
|
type={"scroll"}
|
||||||
<div data-tauri-drag-region className="w-full h-4 shrink-0" />
|
scrollHideDelay={300}
|
||||||
<input
|
className="overflow-hidden size-full"
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") searchEvents();
|
|
||||||
}}
|
|
||||||
placeholder="Search anything..."
|
|
||||||
className="w-full h-20 px-3 pt-10 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center w-full h-full">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
) : events.length ? (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
|
|
||||||
Users
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col flex-1 gap-1">
|
|
||||||
{events
|
|
||||||
.filter((ev) => ev.kind === Kind.Metadata)
|
|
||||||
.map((event) => (
|
|
||||||
<SearchUser key={event.pubkey} event={event} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
|
|
||||||
Notes
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col flex-1 gap-3">
|
|
||||||
{events
|
|
||||||
.filter((ev) => ev.kind === Kind.Text)
|
|
||||||
.map((event) => (
|
|
||||||
<SearchNote key={event.id} event={event} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{!loading && !events.length ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
|
||||||
<div className="inline-flex items-center justify-center rounded-full size-16 bg-black/10 dark:bg-white/10">
|
|
||||||
<SearchIcon className="size-6" />
|
|
||||||
</div>
|
|
||||||
Try searching for people, notes, or keywords
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchUser({ event }: { event: LumeEvent }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={event.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
|
||||||
className="col-span-1 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
|
<Outlet />
|
||||||
<User.Root className="flex items-center gap-2">
|
<ScrollArea.Scrollbar
|
||||||
<User.Avatar className="rounded-full size-9 shrink-0" />
|
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||||
<div className="inline-flex items-center gap-1.5">
|
orientation="vertical"
|
||||||
<User.Name className="font-semibold" />
|
>
|
||||||
<User.NIP05 />
|
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||||
</div>
|
</ScrollArea.Scrollbar>
|
||||||
</User.Root>
|
<ScrollArea.Corner className="bg-transparent" />
|
||||||
</User.Provider>
|
</ScrollArea.Root>
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchNote({ event }: { event: LumeEvent }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root>
|
|
||||||
<div className="flex items-center justify-between px-3 h-14">
|
|
||||||
<Note.User />
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
<Note.Content className="px-3" quote={false} mention={false} />
|
|
||||||
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
|
||||||
<Note.Open />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,6 @@
|
|||||||
"@tauri-apps/plugin-dialog": "2.0.0-beta.5",
|
"@tauri-apps/plugin-dialog": "2.0.0-beta.5",
|
||||||
"@tauri-apps/plugin-fs": "2.0.0-beta.5",
|
"@tauri-apps/plugin-fs": "2.0.0-beta.5",
|
||||||
"@tauri-apps/plugin-http": "2.0.0-beta.5",
|
"@tauri-apps/plugin-http": "2.0.0-beta.5",
|
||||||
"@tauri-apps/plugin-notification": "2.0.0-beta.5",
|
|
||||||
"@tauri-apps/plugin-os": "github:tauri-apps/tauri-plugin-os#v2",
|
"@tauri-apps/plugin-os": "github:tauri-apps/tauri-plugin-os#v2",
|
||||||
"@tauri-apps/plugin-process": "2.0.0-beta.5",
|
"@tauri-apps/plugin-process": "2.0.0-beta.5",
|
||||||
"@tauri-apps/plugin-shell": "2.0.0-beta.6",
|
"@tauri-apps/plugin-shell": "2.0.0-beta.6",
|
||||||
|
@ -388,6 +388,22 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async searchEvent(until: string | null, query: string) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("search_event", { until, query }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async searchUser(until: string | null, query: string) : Promise<Result<string[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("search_user", { until, query }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
async showInFolder(path: string) : Promise<void> {
|
async showInFolder(path: string) : Promise<void> {
|
||||||
await TAURI_INVOKE("show_in_folder", { path });
|
await TAURI_INVOKE("show_in_folder", { path });
|
||||||
},
|
},
|
||||||
|
@ -243,6 +243,31 @@ export class NostrQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async searchEvent(searchQuery: string, asOf?: number) {
|
||||||
|
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
||||||
|
const query = await commands.searchEvent(until, searchQuery);
|
||||||
|
|
||||||
|
if (query.status === "ok") {
|
||||||
|
const data = NostrQuery.#toLumeEvents(query.data);
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async searchUser(searchQuery: string, asOf?: number) {
|
||||||
|
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
||||||
|
const query = await commands.searchUser(until, searchQuery);
|
||||||
|
|
||||||
|
if (query.status === "ok") {
|
||||||
|
const events: NostrEvent[] = query.data.map((item) => JSON.parse(item));
|
||||||
|
const meta: Metadata[] = events.map((event) => JSON.parse(event.content));
|
||||||
|
return meta;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async verifyNip05(pubkey: string, nip05?: string) {
|
static async verifyNip05(pubkey: string, nip05?: string) {
|
||||||
const query = await commands.verifyNip05(pubkey, nip05);
|
const query = await commands.verifyNip05(pubkey, nip05);
|
||||||
|
|
||||||
|
@ -129,11 +129,17 @@ export class LumeWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async openSearch() {
|
static async openSearch(searchType: "notes" | "users", searchQuery: string) {
|
||||||
const label = "search";
|
const url = `/search/${searchType}/?query=${searchQuery}`;
|
||||||
|
const label = `search-${searchQuery
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w ]+/g, "")
|
||||||
|
.replace(/ +/g, "_")
|
||||||
|
.replace(/_+/g, "_")}`;
|
||||||
|
|
||||||
const query = await commands.openWindow({
|
const query = await commands.openWindow({
|
||||||
label,
|
label,
|
||||||
url: "/search",
|
url,
|
||||||
title: "Search",
|
title: "Search",
|
||||||
width: 400,
|
width: 400,
|
||||||
height: 600,
|
height: 600,
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
export * from "./src/constants";
|
export * from "./src/constants";
|
||||||
export * from "./src/delay";
|
|
||||||
export * from "./src/formater";
|
export * from "./src/formater";
|
||||||
export * from "./src/editor";
|
export * from "./src/editor";
|
||||||
export * from "./src/cn";
|
export * from "./src/cn";
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
|
@ -23,9 +23,6 @@ importers:
|
|||||||
'@tauri-apps/plugin-http':
|
'@tauri-apps/plugin-http':
|
||||||
specifier: 2.0.0-beta.5
|
specifier: 2.0.0-beta.5
|
||||||
version: 2.0.0-beta.5
|
version: 2.0.0-beta.5
|
||||||
'@tauri-apps/plugin-notification':
|
|
||||||
specifier: 2.0.0-beta.5
|
|
||||||
version: 2.0.0-beta.5
|
|
||||||
'@tauri-apps/plugin-os':
|
'@tauri-apps/plugin-os':
|
||||||
specifier: github:tauri-apps/tauri-plugin-os#v2
|
specifier: github:tauri-apps/tauri-plugin-os#v2
|
||||||
version: https://codeload.github.com/tauri-apps/tauri-plugin-os/tar.gz/6ca6f068998b4e8c6f84cdefbc152357a8daafa7
|
version: https://codeload.github.com/tauri-apps/tauri-plugin-os/tar.gz/6ca6f068998b4e8c6f84cdefbc152357a8daafa7
|
||||||
@ -1702,9 +1699,6 @@ packages:
|
|||||||
'@tauri-apps/plugin-http@2.0.0-beta.5':
|
'@tauri-apps/plugin-http@2.0.0-beta.5':
|
||||||
resolution: {integrity: sha512-zRWWWCNw/TdewCSrBY8Racm2dwMpkzMOnFmHGExEIoklqUUIq3FU/hkqcX/67Nplf+t1y+WljOxKpCHbm1fOQw==}
|
resolution: {integrity: sha512-zRWWWCNw/TdewCSrBY8Racm2dwMpkzMOnFmHGExEIoklqUUIq3FU/hkqcX/67Nplf+t1y+WljOxKpCHbm1fOQw==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-notification@2.0.0-beta.5':
|
|
||||||
resolution: {integrity: sha512-5zYK2aT1ZvR+LnuwsnTvg28iEhI7FiZSPvBqQ8y6fj28T4oIHVox19Pk4YRyTyfyJN1nZclXxVAqWLSXLI9SKQ==}
|
|
||||||
|
|
||||||
'@tauri-apps/plugin-os@https://codeload.github.com/tauri-apps/tauri-plugin-os/tar.gz/6ca6f068998b4e8c6f84cdefbc152357a8daafa7':
|
'@tauri-apps/plugin-os@https://codeload.github.com/tauri-apps/tauri-plugin-os/tar.gz/6ca6f068998b4e8c6f84cdefbc152357a8daafa7':
|
||||||
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-os/tar.gz/6ca6f068998b4e8c6f84cdefbc152357a8daafa7}
|
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-os/tar.gz/6ca6f068998b4e8c6f84cdefbc152357a8daafa7}
|
||||||
version: 2.0.0-beta.5
|
version: 2.0.0-beta.5
|
||||||
@ -4897,10 +4891,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.0.0-beta.13
|
'@tauri-apps/api': 2.0.0-beta.13
|
||||||
|
|
||||||
'@tauri-apps/plugin-notification@2.0.0-beta.5':
|
|
||||||
dependencies:
|
|
||||||
'@tauri-apps/api': 2.0.0-beta.13
|
|
||||||
|
|
||||||
'@tauri-apps/plugin-os@https://codeload.github.com/tauri-apps/tauri-plugin-os/tar.gz/6ca6f068998b4e8c6f84cdefbc152357a8daafa7':
|
'@tauri-apps/plugin-os@https://codeload.github.com/tauri-apps/tauri-plugin-os/tar.gz/6ca6f068998b4e8c6f84cdefbc152357a8daafa7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.0.0-beta.13
|
'@tauri-apps/api': 2.0.0-beta.13
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
"main",
|
"main",
|
||||||
"panel",
|
"panel",
|
||||||
"settings",
|
"settings",
|
||||||
"search",
|
"search-*",
|
||||||
"zap-*",
|
"zap-*",
|
||||||
"event-*",
|
"event-*",
|
||||||
"user-*",
|
"user-*",
|
||||||
|
@ -1 +1 @@
|
|||||||
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","panel","settings","search","zap-*","event-*","user-*","editor-*"],"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","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","window:allow-start-dragging","window:allow-create","window:allow-close","window:allow-destroy","window:allow-set-focus","window:allow-center","window:allow-minimize","window:allow-maximize","window:allow-set-size","window:allow-set-focus","window:allow-start-dragging","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","theme:allow-set-theme","theme:allow-get-theme","menu:allow-new","menu:allow-popup","http:default","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]}}
|
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","panel","settings","search-*","zap-*","event-*","user-*","editor-*"],"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","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","window:allow-start-dragging","window:allow-create","window:allow-close","window:allow-destroy","window:allow-set-focus","window:allow-center","window:allow-minimize","window:allow-maximize","window:allow-set-size","window:allow-set-focus","window:allow-start-dragging","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","theme:allow-set-theme","theme:allow-get-theme","menu:allow-new","menu:allow-popup","http:default","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]}}
|
@ -124,6 +124,8 @@ fn main() {
|
|||||||
nostr::event::repost,
|
nostr::event::repost,
|
||||||
nostr::event::event_to_bech32,
|
nostr::event::event_to_bech32,
|
||||||
nostr::event::user_to_bech32,
|
nostr::event::user_to_bech32,
|
||||||
|
nostr::event::search_event,
|
||||||
|
nostr::event::search_user,
|
||||||
commands::folder::show_in_folder,
|
commands::folder::show_in_folder,
|
||||||
commands::window::create_column,
|
commands::window::create_column,
|
||||||
commands::window::close_column,
|
commands::window::close_column,
|
||||||
|
@ -663,3 +663,72 @@ pub async fn user_to_bech32(user: &str, state: State<'_, Nostr>) -> Result<Strin
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn search_event(
|
||||||
|
until: Option<&str>,
|
||||||
|
query: &str,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let as_of = match until {
|
||||||
|
Some(until) => Timestamp::from_str(until).map_err(|err| err.to_string())?,
|
||||||
|
None => Timestamp::now(),
|
||||||
|
};
|
||||||
|
let filter = Filter::new()
|
||||||
|
.search(query)
|
||||||
|
.kinds(vec![Kind::TextNote])
|
||||||
|
.limit(20)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
match client.get_events_of(vec![filter], None).await {
|
||||||
|
Ok(events) => {
|
||||||
|
let futures = events.into_iter().map(|ev| async move {
|
||||||
|
let raw = ev.as_json();
|
||||||
|
let parsed = if ev.kind == Kind::TextNote {
|
||||||
|
Some(parse_event(&ev.content).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
RichEvent { raw, parsed }
|
||||||
|
});
|
||||||
|
let rich_events = join_all(futures).await;
|
||||||
|
|
||||||
|
Ok(rich_events)
|
||||||
|
}
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn search_user(
|
||||||
|
until: Option<&str>,
|
||||||
|
query: &str,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let as_of = match until {
|
||||||
|
Some(until) => Timestamp::from_str(until).map_err(|err| err.to_string())?,
|
||||||
|
None => Timestamp::now(),
|
||||||
|
};
|
||||||
|
let filter = Filter::new()
|
||||||
|
.search(query)
|
||||||
|
.kinds(vec![Kind::Metadata])
|
||||||
|
.limit(20)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
match client.get_events_of(vec![filter], None).await {
|
||||||
|
Ok(events) => {
|
||||||
|
let json = events
|
||||||
|
.into_iter()
|
||||||
|
.map(|ev| ev.as_json())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user