feat: use negentropy (#182)

* feat: use negentropy

* chore: polish
This commit is contained in:
雨宮蓮 2024-04-24 10:18:51 +07:00 committed by GitHub
parent 174a3cc74e
commit f027eae52d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 121 additions and 133 deletions

View File

@ -12,21 +12,13 @@ import { routeTree } from "./router.gen"; // auto generated file
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons"; import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
import { Ark } from "@lume/ark"; import { Ark } from "@lume/ark";
const queryClient = new QueryClient({ const ark = new Ark();
defaultOptions: { const queryClient = new QueryClient();
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
const persister = createSyncStoragePersister({ const persister = createSyncStoragePersister({
storage: window.localStorage, storage: window.localStorage,
}); });
const ark = new Ark();
// Set up a Router instance // Set up a Router instance
const router = createRouter({ const router = createRouter({
routeTree, routeTree,

View File

@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { LumeColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
export function Col({ export function Col({
column, column,
@ -81,20 +79,5 @@ export function Col({
}; };
}, [webview]); }, [webview]);
return ( return <div ref={container} className="h-full w-[440px] shrink-0 p-2" />;
<div ref={container} className="h-full w-[440px] shrink-0 p-2">
{column.label !== "open" ? (
<div
className={cn(
"w-full h-full flex items-center justify-center rounded-xl flex-col",
!webview ? "bg-black/5 dark:bg-white/5 backdrop-blur-lg" : "",
)}
>
<button type="button" className="size-5" disabled>
<Spinner className="size-5" />
</button>
</div>
) : null}
</div>
);
} }

View File

@ -2,7 +2,13 @@ import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { type Ark } from "@lume/ark"; import { type Ark } from "@lume/ark";
import { type QueryClient } from "@tanstack/react-query"; import { type QueryClient } from "@tanstack/react-query";
import { type Platform } from "@tauri-apps/plugin-os"; import { type Platform } from "@tauri-apps/plugin-os";
import type { Account, Interests, Metadata, Settings } from "@lume/types"; import type {
Account,
Contact,
Interests,
Metadata,
Settings,
} from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { type Descendant } from "slate"; import { type Descendant } from "slate";
@ -22,6 +28,7 @@ interface RouterContext {
accounts?: Account[]; accounts?: Account[];
initialValue?: EditorElement[]; initialValue?: EditorElement[];
profile?: Metadata; profile?: Metadata;
contacts?: Contact[];
} }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({

View File

@ -1,4 +1,4 @@
import { ComposeFilledIcon, NsfwIcon, TrashIcon } from "@lume/icons"; import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
import { import {
Portal, Portal,
cn, cn,
@ -33,7 +33,6 @@ import {
import { Contact } from "@lume/types"; import { Contact } from "@lume/types";
import { Spinner, User } from "@lume/ui"; import { Spinner, User } from "@lume/ui";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { NsfwToggle } from "./-components/nsfw"; import { NsfwToggle } from "./-components/nsfw";
@ -42,14 +41,6 @@ type EditorSearch = {
quote: boolean; quote: boolean;
}; };
const contactQueryOptions = queryOptions({
queryKey: ["contacts"],
queryFn: () => invoke("get_contact_metadata"),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
export const Route = createFileRoute("/editor/")({ export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => { validateSearch: (search: Record<string, string>): EditorSearch => {
return { return {
@ -58,7 +49,10 @@ export const Route = createFileRoute("/editor/")({
}; };
}, },
beforeLoad: async ({ search }) => { beforeLoad: async ({ search }) => {
const contacts: Contact[] = await invoke("get_contact_metadata");
return { return {
contacts,
initialValue: search.quote initialValue: search.quote
? [ ? [
{ {
@ -83,16 +77,14 @@ export const Route = createFileRoute("/editor/")({
], ],
}; };
}, },
loader: ({ context }) => {
context.queryClient.ensureQueryData(contactQueryOptions);
},
component: Screen, component: Screen,
pendingComponent: Pending, pendingComponent: Pending,
}); });
function Screen() { function Screen() {
const ref = useRef<HTMLDivElement | null>();
const { reply_to, quote } = Route.useSearch(); const { reply_to, quote } = Route.useSearch();
const { ark, initialValue } = Route.useRouteContext(); const { ark, initialValue, contacts } = Route.useRouteContext();
const [t] = useTranslation(); const [t] = useTranslation();
const [editorValue, setEditorValue] = useState(initialValue); const [editorValue, setEditorValue] = useState(initialValue);
@ -105,9 +97,6 @@ function Screen() {
withMentions(withNostrEvent(withImages(withReact(createEditor())))), withMentions(withNostrEvent(withImages(withReact(createEditor())))),
); );
const ref = useRef<HTMLDivElement | null>();
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
const filters = contacts const filters = contacts
?.filter((c) => ?.filter((c) =>
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()), c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),

View File

@ -27,21 +27,26 @@ export const Route = createFileRoute("/newsfeed")({
export function Screen() { export function Screen() {
const { label, name, account } = Route.useSearch(); const { label, name, account } = Route.useSearch();
const { ark } = Route.useRouteContext(); const { ark } = Route.useRouteContext();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = const {
useInfiniteQuery({ data,
queryKey: [label, account], isLoading,
initialPageParam: 0, isFetching,
queryFn: async ({ pageParam }: { pageParam: number }) => { isFetchingNextPage,
const events = await ark.get_events(20, pageParam); hasNextPage,
return events; fetchNextPage,
}, } = useInfiniteQuery({
getNextPageParam: (lastPage) => { queryKey: [label, account],
const lastEvent = lastPage?.at(-1); initialPageParam: 0,
return lastEvent ? lastEvent.created_at - 1 : null; queryFn: async ({ pageParam }: { pageParam: number }) => {
}, const events = await ark.get_events(20, pageParam);
select: (data) => data?.pages.flatMap((page) => page), return events;
refetchOnWindowFocus: false, },
}); getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
});
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
if (!event) return; if (!event) return;
@ -57,9 +62,18 @@ export function Screen() {
<Column.Root> <Column.Root>
<Column.Header label={label} name={name} /> <Column.Header label={label} name={name} />
<Column.Content> <Column.Content>
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-16 flex items-center justify-center border-b border-neutral-100 dark:border-neutral-900">
<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 ? ( {isLoading ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <div className="flex h-16 w-full items-center justify-center gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div> </div>
) : !data.length ? ( ) : !data.length ? (
<Empty /> <Empty />

View File

@ -122,15 +122,15 @@ export class Ark {
} }
} }
public async get_events_from(id: string, limit: number, asOf?: number) { public async get_events_from(pubkey: string, limit: number, asOf?: number) {
try { try {
let until: string = undefined; let until: string = undefined;
if (asOf && asOf > 0) until = asOf.toString(); if (asOf && asOf > 0) until = asOf.toString();
const nostrEvents: Event[] = await invoke("get_events_from", { const nostrEvents: Event[] = await invoke("get_events_from", {
id, public_key: pubkey,
limit, limit,
until, as_of: until,
}); });
return nostrEvents.sort((a, b) => b.created_at - a.created_at); return nostrEvents.sort((a, b) => b.created_at - a.created_at);

View File

@ -11,7 +11,7 @@ export function ColumnContent({
return ( return (
<div <div
className={cn( className={cn(
"flex-1 overflow-y-auto overflow-x-hidden scrollbar-none", "relative flex-1 overflow-y-auto overflow-x-hidden scrollbar-none",
className, className,
)} )}
> >

View File

@ -54,6 +54,10 @@ fn main() {
// Add some bootstrap relays // Add some bootstrap relays
// #TODO: Pull bootstrap relays from user's settings // #TODO: Pull bootstrap relays from user's settings
client
.add_relay("wss://relay.damus.io")
.await
.expect("Cannot connect to relay.damus.io, please try again later.");
client client
.add_relay("wss://relayable.org") .add_relay("wss://relayable.org")
.await .await

View File

@ -40,34 +40,33 @@ pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<String, Stri
#[tauri::command] #[tauri::command]
pub async fn get_events_from( pub async fn get_events_from(
id: &str, public_key: &str,
limit: usize, limit: usize,
until: Option<&str>, as_of: Option<&str>,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<Vec<Event>, String> { ) -> Result<Vec<Event>, String> {
let client = &state.client; let client = &state.client;
let f_until = match until {
Some(until) => Timestamp::from_str(until).unwrap(),
None => Timestamp::now(),
};
if let Ok(author) = PublicKey::from_str(id) { if let Ok(author) = PublicKey::from_str(public_key) {
let until = match as_of {
Some(until) => Timestamp::from_str(until).unwrap(),
None => Timestamp::now(),
};
let filter = Filter::new() let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost]) .kinds(vec![Kind::TextNote, Kind::Repost])
.authors(vec![author]) .authors(vec![author])
.limit(limit) .limit(limit)
.until(f_until); .until(until);
let _ = client
.reconcile(filter.clone(), NegentropyOptions::default())
.await;
if let Ok(events) = client match client.database().query(vec![filter], Order::Asc).await {
.get_events_of(vec![filter], Some(Duration::from_secs(10))) Ok(events) => Ok(events),
.await Err(err) => Err(err.to_string()),
{
Ok(events)
} else {
Err("Get text event failed".into())
} }
} else { } else {
Err("Parse author failed".into()) Err("Public Key is not valid, please check again.".into())
} }
} }
@ -92,14 +91,12 @@ pub async fn get_events(
.limit(limit) .limit(limit)
.until(as_of); .until(as_of);
if let Ok(events) = client match client
.get_events_of(vec![filter], Some(Duration::from_secs(15))) .get_events_of(vec![filter], Some(Duration::from_secs(15)))
.await .await
{ {
println!("total global events: {}", events.len()); Ok(events) => Ok(events),
Ok(events) Err(err) => Err(err.to_string()),
} else {
Err("Get events failed".into())
} }
} }
false => { false => {
@ -132,15 +129,13 @@ pub async fn get_events(
.limit(limit) .limit(limit)
.authors(val) .authors(val)
.until(as_of); .until(as_of);
let _ = client
.reconcile(filter.clone(), NegentropyOptions::default())
.await;
if let Ok(events) = client match client.database().query(vec![filter], Order::Asc).await {
.get_events_of(vec![filter], Some(Duration::from_secs(15))) Ok(events) => Ok(events),
.await Err(err) => Err(err.to_string()),
{
println!("total local events: {}", events.len());
Ok(events)
} else {
Err("Get events failed".into())
} }
} }
} }
@ -167,31 +162,36 @@ pub async fn get_events_from_interests(
.limit(limit) .limit(limit)
.until(as_of) .until(as_of)
.hashtags(hashtags); .hashtags(hashtags);
let _ = client
.reconcile(filter.clone(), NegentropyOptions::default())
.await;
if let Ok(events) = client match client.database().query(vec![filter], Order::Asc).await {
.get_events_of(vec![filter], Some(Duration::from_secs(15))) Ok(events) => Ok(events),
.await Err(err) => Err(err.to_string()),
{
println!("total events: {}", events.len());
Ok(events)
} else {
Err("Get text event failed".into())
} }
} }
#[tauri::command] #[tauri::command]
pub async fn get_event_thread(id: &str, state: State<'_, Nostr>) -> Result<Vec<Event>, ()> { pub async fn get_event_thread(id: &str, state: State<'_, Nostr>) -> Result<Vec<Event>, String> {
let client = &state.client; let client = &state.client;
let event_id = EventId::from_hex(id).unwrap();
let filter = Filter::new().kinds(vec![Kind::TextNote]).event(event_id);
if let Ok(events) = client match EventId::from_hex(id) {
.get_events_of(vec![filter], Some(Duration::from_secs(10))) Ok(event_id) => {
.await let filter = Filter::new().kinds(vec![Kind::TextNote]).event(event_id);
{ let _ = client
Ok(events) .reconcile(filter.clone(), NegentropyOptions::default())
} else { .await;
Err(())
match client
.get_events_of(vec![filter], Some(Duration::from_secs(10)))
.await
{
Ok(events) => Ok(events),
Err(err) => Err(err.to_string()),
}
}
Err(_) => Err("Event ID is not valid".into()),
} }
} }

View File

@ -152,30 +152,29 @@ pub async fn load_selected_account(npub: &str, state: State<'_, Nostr>) -> Resul
let client = &state.client; let client = &state.client;
let keyring = Entry::new("Lume Secret Storage", npub).unwrap(); let keyring = Entry::new("Lume Secret Storage", npub).unwrap();
if let Ok(password) = keyring.get_password() { match keyring.get_password() {
if password.starts_with("bunker://") { Ok(password) => {
let app_keys = Keys::generate(); if password.starts_with("bunker://") {
let bunker_uri = NostrConnectURI::parse(password).unwrap(); let app_keys = Keys::generate();
let signer = Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(60), None) let bunker_uri = NostrConnectURI::parse(password).unwrap();
.await let signer = Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(60), None)
.unwrap(); .await
.unwrap();
// Update signer // Update signer
client.set_signer(Some(signer.into())).await; client.set_signer(Some(signer.into())).await;
// Done } else {
Ok(true) let secret_key = SecretKey::from_bech32(password).expect("Get secret key failed");
} else { let keys = Keys::new(secret_key);
let secret_key = SecretKey::from_bech32(password).expect("Get secret key failed"); let signer = NostrSigner::Keys(keys);
let keys = Keys::new(secret_key);
let signer = NostrSigner::Keys(keys); // Update signer
client.set_signer(Some(signer)).await;
}
// Update signer
client.set_signer(Some(signer)).await;
// Done
Ok(true) Ok(true)
} }
} else { Err(err) => Err(err.to_string()),
Err("nsec not found".into())
} }
} }