chore: remove storage layer

This commit is contained in:
reya 2024-02-22 08:58:45 +07:00
parent 9127d5c5ea
commit e9ce932646
10 changed files with 150 additions and 425 deletions

View File

@ -15,10 +15,15 @@ function Home() {
const ark = useArk();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["timeline"],
queryKey: ["local_timeline"],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_text_events(FETCH_LIMIT, pageParam, true);
const events = await ark.get_events(
"local",
FETCH_LIMIT,
pageParam,
true,
);
return events;
},
getNextPageParam: (lastPage) => {

View File

@ -106,7 +106,8 @@ export class Ark {
}
}
public async get_text_events(
public async get_events(
type: "local" | "global",
limit: number,
asOf?: number,
dedup?: boolean,
@ -118,7 +119,7 @@ export class Ark {
const seenIds = new Set<string>();
const dedupQueue = new Set<string>();
const nostrEvents: Event[] = await invoke("get_local_events", {
const nostrEvents: Event[] = await invoke(`get_${type}_events`, {
limit,
until,
});
@ -287,6 +288,36 @@ export class Ark {
}
}
public async get_contact_list() {
try {
const cmd: string[] = await invoke("get_contact_list");
return cmd;
} catch (e) {
console.error(e);
return [];
}
}
public async follow(id: string, alias?: string) {
try {
const cmd: string = await invoke("follow", { id, alias });
return cmd;
} catch (e) {
console.error(e);
return false;
}
}
public async unfollow(id: string) {
try {
const cmd: string = await invoke("unfollow", { id });
return cmd;
} catch (e) {
console.error(e);
return false;
}
}
public async user_to_bech32(key: string, relays: string[]) {
try {
const cmd: string = await invoke("user_to_bech32", {

View File

@ -9,15 +9,28 @@ export function NoteReaction() {
const event = useNoteContext();
const [reaction, setReaction] = useState<"+" | "-">(null);
const [loading, setLoading] = useState(false);
const up = async () => {
// start loading
setLoading(false);
const res = await ark.upvote(event.id, event.pubkey);
if (res) setReaction("+");
// stop loading
setLoading(true);
};
const down = async () => {
// start loading
setLoading(false);
const res = await ark.downvote(event.id, event.pubkey);
if (res) setReaction("-");
// stop loading
setLoading(true);
};
return (
@ -25,7 +38,7 @@ export function NoteReaction() {
<button
type="button"
onClick={up}
disabled={!!reaction}
disabled={!!reaction || loading}
className={cn(
"inline-flex size-7 items-center justify-center rounded-full",
reaction === "+"
@ -38,7 +51,7 @@ export function NoteReaction() {
<button
type="button"
onClick={down}
disabled={!!reaction}
disabled={!!reaction || loading}
className={cn(
"inline-flex size-7 items-center justify-center rounded-full",
reaction === "-"

View File

@ -1,260 +1,12 @@
import { webln } from "@getalby/sdk";
import { type SendPaymentResponse } from "@getalby/sdk/dist/types";
import { CancelIcon, LoaderIcon, ZapIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { cn, compactNumber, displayNpub } from "@lume/utils";
import * as Dialog from "@radix-ui/react-dialog";
import * as Tooltip from "@radix-ui/react-tooltip";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import CurrencyInput from "react-currency-input-field";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNoteContext } from "../provider";
import { useProfile } from "@lume/ark";
import { ZapIcon } from "@lume/icons";
export function NoteZap() {
const storage = useStorage();
const event = useNoteContext();
const [amount, setAmount] = useState<string>("21");
const [zapMessage, setZapMessage] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [invoice, setInvoice] = useState<string>(null);
const { t } = useTranslation();
const { user } = useProfile(event.pubkey);
const createZapRequest = async (instant?: boolean) => {
if (instant && !storage.nwc) return;
let nwc: webln.NostrWebLNProvider = undefined;
try {
// start loading
setIsLoading(true);
const zapAmount = parseInt(amount) * 1000;
const res = await event.zap(zapAmount, zapMessage);
if (!storage.nwc) return setInvoice(res);
// user connect nwc
nwc = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: storage.nwc,
});
await nwc.enable();
// send payment via nwc
const send: SendPaymentResponse = await nwc.sendPayment(res);
if (send) {
toast.success(
`You've zapped ${compactNumber.format(send.amount)} sats to ${
user?.name || user?.displayName || "anon"
}`,
);
// reset after 1.5 secs
if (!instant) {
const timeout = setTimeout(() => setIsCompleted(false), 1500);
clearTimeout(timeout);
}
}
// eose
nwc.close();
// update state
setIsCompleted(true);
setIsLoading(false);
} catch (e) {
nwc?.close();
setIsLoading(false);
toast.error(String(e));
}
};
if (storage.settings.instantZap) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => createZapRequest(true)}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
>
{isLoading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<ZapIcon
className={cn(
"size-5 group-hover:text-blue-500",
isCompleted ? "text-blue-500" : "",
)}
/>
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] dark:bg-neutral-50 dark:text-neutral-950">
{t("note.zap.tooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Dialog.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
>
<ZapIcon className="size-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
</Dialog.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] dark:bg-neutral-50 dark:text-neutral-950">
{t("note.zap.tooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-white/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Dialog.Close className="absolute right-5 top-5 z-50">
<div className="flex flex-col gap-1.5">
<div className="inline-flex size-10 items-center justify-center rounded-lg bg-white dark:bg-black">
<CancelIcon className="size-5" />
</div>
<span className="text-sm font-medium">Esc</span>
</div>
</Dialog.Close>
<div className="relative h-min w-full max-w-xl rounded-xl bg-white dark:bg-black">
<div className="inline-flex w-full shrink-0 items-center justify-center px-5 py-3">
<div className="w-6" />
<Dialog.Title className="text-center font-semibold">
{t("note.zap.modalTitle")}{" "}
{user?.name ||
user?.displayName ||
displayNpub(event.pubkey, 16)}
</Dialog.Title>
</div>
{!invoice ? (
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
<div className="relative flex h-36 flex-col">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput
placeholder="0"
defaultValue={"21"}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(value)}
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-500 dark:text-neutral-400">
sats
</span>
</div>
<div className="inline-flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAmount("69")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
69 sats
</button>
<button
type="button"
onClick={() => setAmount("100")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
100 sats
</button>
<button
type="button"
onClick={() => setAmount("200")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
200 sats
</button>
<button
type="button"
onClick={() => setAmount("500")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
500 sats
</button>
<button
type="button"
onClick={() => setAmount("1000")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
1K sats
</button>
</div>
</div>
<div className="mt-4 flex w-full flex-col gap-2">
<input
name="zapMessage"
value={zapMessage}
onChange={(e) => setZapMessage(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder={t("note.zap.messagePlaceholder")}
className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:text-neutral-400"
/>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-9 w-full 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"
>
{isCompleted
? t("note.zap.buttonFinish")
: isLoading
? t("note.zap.buttonLoading")
: t("note.zap.zap")}
</button>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-5 pb-5">
<div className="rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<QRCodeSVG value={invoice} size={256} />
</div>
<div className="flex flex-col items-center gap-1">
<h3 className="text-lg font-medium">
{t("note.zap.invoiceButton")}
</h3>
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
{t("note.zap.invoiceFooter")}
</span>
</div>
</div>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
>
<ZapIcon className="size-5 group-hover:text-blue-500" />
</button>
);
}

View File

@ -1,4 +1,3 @@
import { useStorage } from "@lume/storage";
import { Kind } from "@lume/types";
import {
AUDIOS,
@ -8,14 +7,11 @@ import {
VIDEOS,
canPreview,
cn,
regionNames,
} from "@lume/utils";
import { fetch } from "@tauri-apps/plugin-http";
import getUrls from "get-urls";
import { nanoid } from "nanoid";
import { ReactNode, useMemo, useState } from "react";
import { ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { toast } from "sonner";
import { stripHtml } from "string-strip-html";
import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
@ -27,19 +23,12 @@ import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContent({ className }: { className?: string }) {
const storage = useStorage();
const event = useNoteContext();
const [content, setContent] = useState(event.content);
const [translate, setTranslate] = useState({
translatable: false,
translated: false,
});
const richContent = useMemo(() => {
if (event.kind !== Kind.Text) return content;
if (event.kind !== Kind.Text) return event.content;
let parsedContent: string | ReactNode[] = stripHtml(content).result;
let parsedContent: string | ReactNode[] = stripHtml(event.content).result;
let linkPreview: string = undefined;
let images: string[] = [];
let videos: string[] = [];
@ -50,32 +39,32 @@ export function NoteContent({ className }: { className?: string }) {
const words = text.split(/( |\n)/);
const urls = [...getUrls(text)];
if (storage.settings.media && !storage.settings.lowPower) {
images = urls.filter((word) =>
IMAGES.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
videos = urls.filter((word) =>
VIDEOS.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
audios = urls.filter((word) =>
AUDIOS.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
}
images = urls.filter((word) =>
IMAGES.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
videos = urls.filter((word) =>
VIDEOS.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
audios = urls.filter((word) =>
AUDIOS.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
@ -121,9 +110,7 @@ export function NoteContent({ className }: { className?: string }) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
if (storage.settings.hashtag)
return <Hashtag key={nanoid()} tag={hashtag} />;
return null;
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
@ -174,7 +161,7 @@ export function NoteContent({ className }: { className?: string }) {
);
parsedContent = reactStringReplace(parsedContent, "\n", () => {
return <div key={nanoid()} />;
return <br key={nanoid()} />;
});
if (typeof parsedContent[0] === "string") {
@ -186,35 +173,7 @@ export function NoteContent({ className }: { className?: string }) {
console.warn(event.id, `[parser] parse failed: ${e}`);
return parsedContent;
}
}, [content]);
const translateContent = async () => {
try {
if (!translate.translatable) return;
const res = await fetch("https://translate.nostr.wine/translate", {
method: "POST",
body: JSON.stringify({
q: event.content,
target: storage.locale.slice(0, 2),
api_key: storage.settings.translateApiKey,
}),
headers: { "Content-Type": "application/json" },
});
if (!res.ok)
toast.error(
"Cannot connect to translate service, please try again later.",
);
const data = await res.json();
setContent(data.translatedText);
setTranslate((state) => ({ ...state, translated: true }));
} catch (e) {
console.error("translate api: ", String(e));
}
};
}, []);
if (event.kind !== Kind.Text) {
return <NIP89 className={className} />;
@ -225,25 +184,6 @@ export function NoteContent({ className }: { className?: string }) {
<div className="content-break select-text whitespace-pre-line text-balance leading-normal">
{richContent}
</div>
{storage.settings.translation && translate.translatable ? (
translate.translated ? (
<button
type="button"
onClick={() => setContent(event.content)}
className="mt-3 border-none text-sm text-blue-500 shadow-none hover:text-blue-600 focus:outline-none"
>
Show original content
</button>
) : (
<button
type="button"
onClick={translateContent}
className="mt-3 border-none text-sm text-blue-500 shadow-none hover:text-blue-600 focus:outline-none"
>
Translate to {regionNames.of(storage.locale)}
</button>
)
) : null}
</div>
);
}

View File

@ -1,4 +1,3 @@
import { useStorage } from "@lume/storage";
import { cn } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
@ -7,59 +6,45 @@ import { useMemo } from "react";
import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext();
const storage = useStorage();
const user = useUserContext();
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user?.pubkey || nanoid(), 90, 50),
)}`,
[user],
);
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user?.pubkey || nanoid(), 90, 50),
)}`,
[user],
);
if (!user.profile) {
return (
<div className="shrink-0">
<div
className={cn(
"bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
</div>
);
}
if (!user.profile) {
return (
<div className="shrink-0">
<div
className={cn(
"animate-pulse rounded bg-black/20 dark:bg-white/20",
className,
)}
/>
</div>
);
}
return (
<Avatar.Root className="shrink-0">
{storage.settings.lowPower ? (
<Avatar.Image
src={fallbackAvatar}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn(
"bg-black dark:bg-white ring-1 ring-black/5 dark:ring-white/5",
className,
)}
/>
) : (
<Avatar.Image
src={user.profile.picture}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn("ring-1 ring-black/5 dark:ring-white/5", className)}
/>
)}
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
return (
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user.profile.picture}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn("ring-1 ring-black/5 dark:ring-white/5", className)}
/>
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}

View File

@ -3,15 +3,11 @@ import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useUserContext } from "./provider";
export function UserFollowButton({
target,
className,
}: {
target: string;
className?: string;
}) {
export function UserFollowButton({ className }: { className?: string }) {
const ark = useArk();
const user = useUserContext();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
@ -20,10 +16,10 @@ export function UserFollowButton({
const toggleFollow = async () => {
setLoading(true);
if (!followed) {
const add = await ark.createContact(target);
const add = await ark.follow(user.pubkey);
if (add) setFollowed(true);
} else {
const remove = await ark.deleteContact(target);
const remove = await ark.unfollow(user.pubkey);
if (remove) setFollowed(false);
}
setLoading(false);
@ -33,8 +29,8 @@ export function UserFollowButton({
async function status() {
setLoading(true);
const contacts = await ark.getUserContacts();
if (contacts?.includes(target)) {
const contacts = await ark.get_contact_list();
if (contacts?.includes(user.pubkey)) {
setFollowed(true);
}

View File

@ -1,6 +1,6 @@
import { useArk } from "@lume/ark";
import { Metadata } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { ReactNode, createContext, useContext } from "react";
const UserContext = createContext<{ pubkey: string; profile: Metadata }>(null);
@ -14,12 +14,13 @@ export function UserProvider({
children: ReactNode;
embed?: string;
}) {
const ark = useArk();
const { data: profile } = useQuery({
queryKey: ["user", pubkey],
queryFn: async () => {
if (embed) return JSON.parse(embed) as Metadata;
try {
const profile: Metadata = await invoke("get_profile", { id: pubkey });
const profile: Metadata = await ark.get_profile(pubkey);
return profile;
} catch (e) {
throw new Error(e);

View File

@ -47,15 +47,19 @@ fn main() {
client
.add_relay("wss://nostr.mutinywallet.com")
.await
.expect("Failed to add bootstrap relay.");
.unwrap_or_default();
client
.add_relay("wss://bostr.yonle.lecturify.net")
.add_relay("wss://relay.nostr.band")
.await
.expect("Failed to add bootstrap relay.");
.unwrap_or_default();
client
.add_relay("wss://relay.damus.io")
.await
.unwrap_or_default();
client
.add_relay("wss://purplepag.es")
.await
.expect("Failed to add bootstrap relay.");
.unwrap_or_default();
// Connect
client.connect().await;

View File

@ -48,7 +48,6 @@ pub async fn save_key(
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
.await
{
println!("total contacts: {}", list.len());
*contact_list = Some(list);
}
@ -161,7 +160,6 @@ pub async fn load_selected_account(
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
.await
{
println!("total contacts: {}", list.len());
*contact_list = Some(list);
}