chore: monorepo

This commit is contained in:
2023-12-25 14:28:39 +07:00
parent a6da07cd3f
commit 227c2ddefa
374 changed files with 19966 additions and 12758 deletions

58
packages/ark/package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "@lume/ark",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"dependencies": {
"@getalby/sdk": "^3.2.1",
"@lume/icons": "workspace:^",
"@lume/ndk-cache-tauri": "workspace:^",
"@lume/storage": "workspace:^",
"@lume/utils": "workspace:^",
"@nostr-dev-kit/ndk": "^2.3.1",
"@nostr-fetch/adapter-ndk": "^0.14.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.14.2",
"@tauri-apps/api": "2.0.0-alpha.11",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.3",
"@tauri-apps/plugin-dialog": "2.0.0-alpha.3",
"@tauri-apps/plugin-fs": "2.0.0-alpha.3",
"@tauri-apps/plugin-http": "2.0.0-alpha.3",
"@tauri-apps/plugin-os": "2.0.0-alpha.4",
"@tauri-apps/plugin-process": "2.0.0-alpha.3",
"@tauri-apps/plugin-sql": "2.0.0-alpha.3",
"@tauri-apps/plugin-updater": "2.0.0-alpha.3",
"@tauri-apps/plugin-upload": "2.0.0-alpha.3",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@vidstack/react": "^1.9.8",
"markdown-to-jsx": "^7.3.2",
"minidenticons": "^4.2.0",
"nanoid": "^5.0.4",
"nostr-fetch": "^0.14.1",
"nostr-tools": "1.17.0",
"qrcode.react": "^3.1.0",
"re-resizable": "^6.9.11",
"react": "^18.2.0",
"react-currency-input-field": "^3.6.12",
"react-router-dom": "^6.21.0",
"react-string-replace": "^1.1.1",
"sonner": "^1.2.4",
"tippy.js": "^6.3.7",
"use-context-selector": "^1.4.1"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@types/react": "^18.2.45",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3"
}
}

543
packages/ark/src/ark.ts Normal file
View File

@@ -0,0 +1,543 @@
import { LumeStorage } from "@lume/storage";
import {
type Account,
type NDKEventWithReplies,
type NIP05,
} from "@lume/types";
import NDK, {
NDKEvent,
NDKFilter,
NDKKind,
NDKNip46Signer,
NDKPrivateKeySigner,
NDKRelay,
NDKSubscriptionCacheUsage,
NDKTag,
NDKUser,
NostrEvent,
} from "@nostr-dev-kit/ndk";
import { open } from "@tauri-apps/plugin-dialog";
import { readBinaryFile } from "@tauri-apps/plugin-fs";
import { fetch } from "@tauri-apps/plugin-http";
import { NostrFetcher, normalizeRelayUrl } from "nostr-fetch";
import { nip19 } from "nostr-tools";
export class Ark {
#storage: LumeStorage;
#fetcher: NostrFetcher;
public ndk: NDK;
public account: Account;
constructor({
ndk,
storage,
fetcher,
}: {
ndk: NDK;
storage: LumeStorage;
fetcher: NostrFetcher;
}) {
this.ndk = ndk;
this.#storage = storage;
this.#fetcher = fetcher;
}
public async connectDepot() {
return this.ndk.addExplicitRelay(
new NDKRelay(normalizeRelayUrl("ws://localhost:6090")),
undefined,
true,
);
}
public updateNostrSigner({
signer,
}: { signer: NDKNip46Signer | NDKPrivateKeySigner }) {
this.ndk.signer = signer;
return this.ndk.signer;
}
public subscribe({
filter,
closeOnEose = false,
cb,
}: {
filter: NDKFilter;
closeOnEose: boolean;
cb: (event: NDKEvent) => void;
}) {
const sub = this.ndk.subscribe(filter, { closeOnEose });
sub.addListener("event", (event: NDKEvent) => cb(event));
return sub;
}
public async createEvent({
kind,
tags,
content,
rootReplyTo = undefined,
replyTo = undefined,
}: {
kind: NDKKind | number;
tags: NDKTag[];
content?: string;
rootReplyTo?: string;
replyTo?: string;
}) {
try {
const event = new NDKEvent(this.ndk);
if (content) event.content = content;
event.kind = kind;
event.tags = tags;
if (rootReplyTo) {
const rootEvent = await this.ndk.fetchEvent(rootReplyTo);
if (rootEvent) event.tag(rootEvent, "root");
}
if (replyTo) {
const replyEvent = await this.ndk.fetchEvent(replyTo);
if (replyEvent) event.tag(replyEvent, "reply");
}
const publish = await event.publish();
if (!publish) throw new Error("Failed to publish event");
return {
id: event.id,
seens: [...publish.values()].map((item) => item.url),
};
} catch (e) {
throw new Error(e);
}
}
public async getUserProfile({ pubkey }: { pubkey: string }) {
try {
// get clean pubkey without any special characters
let hexstring = pubkey.replace(/[^a-zA-Z0-9]/g, "");
if (
hexstring.startsWith("npub1") ||
hexstring.startsWith("nprofile1") ||
hexstring.startsWith("naddr1")
) {
const decoded = nip19.decode(hexstring);
if (decoded.type === "nprofile") hexstring = decoded.data.pubkey;
if (decoded.type === "npub") hexstring = decoded.data;
if (decoded.type === "naddr") hexstring = decoded.data.pubkey;
}
const user = this.ndk.getUser({ pubkey: hexstring });
const profile = await user.fetchProfile({
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
if (!profile) return null;
return profile;
} catch (e) {
throw new Error(e);
}
}
public async getUserContacts({
pubkey = undefined,
outbox = undefined,
}: {
pubkey?: string;
outbox?: boolean;
}) {
try {
const user = this.ndk.getUser({
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
});
const contacts = [...(await user.follows(undefined, outbox))].map(
(user) => user.pubkey,
);
if (pubkey === this.#storage.account.pubkey)
this.#storage.account.contacts = contacts;
return contacts;
} catch (e) {
throw new Error(e);
}
}
public async getUserRelays({ pubkey }: { pubkey?: string }) {
try {
const user = this.ndk.getUser({
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
});
return await user.relayList();
} catch (e) {
throw new Error(e);
}
}
public async newContactList({ tags }: { tags: NDKTag[] }) {
const publish = await this.createEvent({
kind: NDKKind.Contacts,
tags: tags,
});
if (publish) {
this.#storage.account.contacts = tags.map((item) => item[1]);
return publish;
}
}
public async createContact({ pubkey }: { pubkey: string }) {
const user = this.ndk.getUser({ pubkey: this.#storage.account.pubkey });
const contacts = await user.follows();
return await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
}
public async deleteContact({ pubkey }: { pubkey: string }) {
const user = this.ndk.getUser({ pubkey: this.#storage.account.pubkey });
const contacts = await user.follows();
contacts.delete(new NDKUser({ pubkey: pubkey }));
const event = new NDKEvent(this.ndk);
event.content = "";
event.kind = NDKKind.Contacts;
event.tags = [...contacts].map((item) => [
"p",
item.pubkey,
item.relayUrls?.[0] || "",
"",
]);
return await event.publish();
}
public async getAllEvents({ filter }: { filter: NDKFilter }) {
const events = await this.ndk.fetchEvents(filter);
if (!events) return [];
return [...events];
}
public async getEventById({ id }: { id: string }) {
let eventId: string = id;
if (
eventId.startsWith("nevent1") ||
eventId.startsWith("note1") ||
eventId.startsWith("naddr1")
) {
const decode = nip19.decode(eventId);
if (decode.type === "nevent") eventId = decode.data.id;
if (decode.type === "note") eventId = decode.data;
if (decode.type === "naddr") {
return await this.ndk.fetchEvent({
kinds: [decode.data.kind],
"#d": [decode.data.identifier],
authors: [decode.data.pubkey],
});
}
}
return await this.ndk.fetchEvent(id, {
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
}
public async getEventByFilter({ filter }: { filter: NDKFilter }) {
const event = await this.ndk.fetchEvent(filter, {
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
if (!event) return null;
return event;
}
public getEventThread({ tags }: { tags: NDKTag[] }) {
let rootEventId: string = null;
let replyEventId: string = null;
const events = tags.filter((el) => el[0] === "e");
if (!events.length) return null;
if (events.length === 1)
return {
rootEventId: events[0][1],
replyEventId: null,
};
if (events.length > 1) {
rootEventId = events.find((el) => el[3] === "root")?.[1];
replyEventId = events.find((el) => el[3] === "reply")?.[1];
if (!rootEventId && !replyEventId) {
rootEventId = events[0][1];
replyEventId = events[1][1];
}
}
return {
rootEventId,
replyEventId,
};
}
public async getThreads({
id,
data,
}: { id: string; data?: NDKEventWithReplies[] }) {
let events = data || null;
if (!data) {
const relayUrls = [...this.ndk.pool.relays.values()].map(
(item) => item.url,
);
const rawEvents = (await this.#fetcher.fetchAllEvents(
relayUrls,
{
kinds: [NDKKind.Text],
"#e": [id],
},
{ since: 0 },
{ sort: true },
)) as unknown as NostrEvent[];
events = rawEvents.map(
(event) => new NDKEvent(this.ndk, event),
) as NDKEvent[] as NDKEventWithReplies[];
}
if (events.length > 0) {
const replies = new Set();
for (const event of events) {
const tags = event.tags.filter((el) => el[0] === "e" && el[1] !== id);
if (tags.length > 0) {
for (const tag of tags) {
const rootIndex = events.findIndex((el) => el.id === tag[1]);
if (rootIndex !== -1) {
const rootEvent = events[rootIndex];
if (rootEvent?.replies) {
rootEvent.replies.push(event);
} else {
rootEvent.replies = [event];
}
replies.add(event.id);
}
}
}
}
const cleanEvents = events.filter((ev) => !replies.has(ev.id));
return cleanEvents;
}
return events;
}
public async getAllRelaysFromContacts() {
const LIMIT = 1;
const connectedRelays = this.ndk.pool
.connectedRelays()
.map((item) => item.url);
const relayMap = new Map<string, string[]>();
const relayEvents = this.#fetcher.fetchLatestEventsPerAuthor(
{
authors: this.#storage.account.contacts,
relayUrls: connectedRelays,
},
{ kinds: [NDKKind.RelayList] },
LIMIT,
);
for await (const { author, events } of relayEvents) {
if (events[0]) {
for (const tag of events[0].tags) {
const users = relayMap.get(tag[1]);
if (!users) relayMap.set(tag[1], [author]);
users.push(author);
}
}
}
return relayMap;
}
public async getInfiniteEvents({
filter,
limit,
pageParam = 0,
signal = undefined,
dedup = true,
}: {
filter: NDKFilter;
limit: number;
pageParam?: number;
signal?: AbortSignal;
dedup?: boolean;
}) {
const rootIds = new Set();
const dedupQueue = new Set();
const connectedRelays = this.ndk.pool
.connectedRelays()
.map((item) => item.url);
const events = await this.#fetcher.fetchLatestEvents(
connectedRelays,
filter,
limit,
{
asOf: pageParam === 0 ? undefined : pageParam,
abortSignal: signal,
},
);
const ndkEvents = events.map((event) => {
return new NDKEvent(this.ndk, event);
});
if (dedup) {
for (const event of ndkEvents) {
const tags = event.tags.filter((el) => el[0] === "e");
if (tags && tags.length > 0) {
const rootId = tags.filter((el) => el[3] === "root")[1] ?? tags[0][1];
if (rootIds.has(rootId)) {
dedupQueue.add(event.id);
break;
}
rootIds.add(rootId);
}
}
return ndkEvents
.filter((event) => !dedupQueue.has(event.id))
.sort((a, b) => b.created_at - a.created_at);
}
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
}
public async getRelayEvents({
relayUrl,
filter,
limit,
pageParam = 0,
signal = undefined,
}: {
relayUrl: string;
filter: NDKFilter;
limit: number;
pageParam?: number;
signal?: AbortSignal;
dedup?: boolean;
}) {
const events = await this.#fetcher.fetchLatestEvents(
[normalizeRelayUrl(relayUrl)],
filter,
limit,
{
asOf: pageParam === 0 ? undefined : pageParam,
abortSignal: signal,
},
);
const ndkEvents = events.map((event) => {
return new NDKEvent(this.ndk, event);
});
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
}
/**
* Upload media file to nostr.build
* @todo support multiple backends
*/
public async upload({ fileExts }: { fileExts?: string[] }) {
const defaultExts = ["png", "jpeg", "jpg", "gif"].concat(fileExts);
const selected = await open({
multiple: false,
filters: [
{
name: "Image",
extensions: defaultExts,
},
],
});
if (!selected) return null;
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (!res.ok) return null;
const json = await res.json();
const content = json.data[0];
return content.url as string;
}
public async validateNIP05({
pubkey,
nip05,
signal,
}: {
pubkey: string;
nip05: string;
signal?: AbortSignal;
}) {
const localPath = nip05.split("@")[0];
const service = nip05.split("@")[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const res = await fetch(verifyURL, {
method: "GET",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
signal,
});
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data: NIP05 = await res.json();
if (!data.names) return false;
if (data.names[localPath.toLowerCase()] === pubkey) return true;
if (data.names[localPath] === pubkey) return true;
return false;
}
public async replyTo({
content,
event,
}: { content: string; event: NDKEvent }) {
try {
const replyEvent = new NDKEvent(this.ndk);
replyEvent.content = content;
replyEvent.kind = NDKKind.Text;
replyEvent.tag(event, "reply");
return await replyEvent.publish();
} catch (e) {
throw new Error(e);
}
}
}

View File

@@ -0,0 +1,116 @@
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import {
Ref,
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { twMerge } from "tailwind-merge";
import { NDKCacheUserProfile } from "@lume/types";
type MentionListRef = {
onKeyDown: (props: { event: Event }) => boolean;
};
const List = (
props: {
items: NDKCacheUserProfile[];
command: (arg0: { id: string }) => void;
},
ref: Ref<unknown>,
) => {
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={twMerge(
"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">No result</div>
)}
</div>
);
};
export const MentionList = forwardRef<MentionListRef>(List);

View File

@@ -0,0 +1,76 @@
import { NavArrowDownIcon } from "@lume/icons";
import { NDKEventWithReplies } from "@lume/types";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { Note } from "..";
export function Reply({
event,
rootEvent,
}: {
event: NDKEventWithReplies;
rootEvent: string;
}) {
const [open, setOpen] = useState(false);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Note.Root>
<Note.User
pubkey={event.pubkey}
time={event.created_at}
className="h-14 px-3"
/>
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="-ml-1 flex items-center justify-between">
{event.replies?.length > 0 ? (
<Collapsible.Trigger asChild>
<div className="ml-4 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
<NavArrowDownIcon
className={twMerge(
"h-3 w-3",
open ? "rotate-180 transform" : "",
)}
/>
{`${event.replies?.length} ${
event.replies?.length === 1 ? "reply" : "replies"
}`}
</div>
</Collapsible.Trigger>
) : null}
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
<div className={twMerge("px-3", open ? "pb-3" : "")}>
{event.replies?.length > 0 ? (
<Collapsible.Content>
{event.replies?.map((childEvent) => (
<Note.Root key={childEvent.id}>
<Note.User pubkey={event.pubkey} time={event.created_at} />
<Note.TextContent
content={event.content}
className="min-w-0 px-3"
/>
<div className="-ml-1 flex h-14 items-center justify-between px-3">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
</Note.Root>
))}
</Collapsible.Content>
) : null}
</div>
</Note.Root>
</Collapsible.Root>
);
}

View File

@@ -0,0 +1,82 @@
import { NDKEvent, NDKKind, NostrEvent } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query";
import { Note } from "..";
import { useArk } from "../../../provider";
export function RepostNote({ event }: { event: NDKEvent }) {
const ark = useArk();
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed = JSON.parse(event.content) as NostrEvent;
return new NDKEvent(ark.ndk, embed);
}
const id = event.tags.find((el) => el[0] === "e")[1];
return await ark.getEventById({ id });
} catch {
throw new Error("Failed to get repost event");
}
},
refetchOnWindowFocus: false,
});
const renderContentByKind = () => {
if (!repostEvent) return null;
switch (repostEvent.kind) {
case NDKKind.Text:
return <Note.TextContent content={repostEvent.content} />;
case 1063:
return <Note.MediaContent tags={repostEvent.tags} />;
default:
return null;
}
};
if (isLoading) {
return <div className="w-full px-3 pb-3" />;
}
if (isError) {
return (
<div className="my-3 h-min w-full px-3">
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
<div className="relative flex flex-col gap-2">
<div className="px-3">
<p>Failed to load event</p>
</div>
</div>
</div>
</div>
);
}
return (
<Note.Root>
<Note.User
pubkey={event.pubkey}
time={event.created_at}
variant="repost"
className="h-14"
/>
<div className="relative flex flex-col gap-2 px-3">
<Note.User pubkey={repostEvent.pubkey} time={repostEvent.created_at} />
{renderContentByKind()}
<div className="flex h-14 items-center justify-between">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={repostEvent.id} />
<Note.Reaction event={repostEvent} />
<Note.Repost event={repostEvent} />
<Note.Zap event={repostEvent} />
</div>
</div>
</div>
</Note.Root>
);
}

View File

@@ -0,0 +1,24 @@
import { Note } from '..';
export function NoteSkeleton() {
return (
<Note.Root>
<div className="flex h-min flex-col p-3">
<div className="flex items-start gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
<div className="h-6 w-full">
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
<div className="-mt-4 flex gap-3">
<div className="w-10 shrink-0" />
<div className="flex w-full flex-col gap-1">
<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" />
<div className="h-3 w-1/2 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
</div>
</Note.Root>
);
}

View File

@@ -0,0 +1,29 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { Note } from "..";
import { useArk } from "../../../provider";
export function TextNote({ event }: { event: NDKEvent }) {
const ark = useArk();
const thread = ark.getEventThread({ tags: event.tags });
return (
<Note.Root>
<Note.User
pubkey={event.pubkey}
time={event.created_at}
className="h-14 px-3"
/>
<Note.Thread thread={thread} className="mb-2" />
<Note.TextContent content={event.content} className="min-w-0 px-3" />
<div className="flex h-14 items-center justify-between px-3">
<Note.Pin eventId={event.id} />
<div className="inline-flex items-center gap-10">
<Note.Reply eventId={event.id} rootEventId={thread?.rootEventId} />
<Note.Reaction event={event} />
<Note.Repost event={event} />
<Note.Zap event={event} />
</div>
</div>
</Note.Root>
);
}

View File

@@ -0,0 +1,37 @@
import { PinIcon } from "@lume/icons";
import { WIDGET_KIND } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useWidget } from "../../../hooks/useWidget";
export function NotePin({ eventId }: { eventId: string }) {
const { addWidget } = useWidget();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: "Thread",
content: eventId,
})
}
className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900"
>
<PinIcon className="size-4" />
Pin
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] 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 dark:bg-neutral-800 dark:text-neutral-100">
Pin note
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,136 @@
import { ReactionIcon } from "@lume/icons";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import * as Popover from "@radix-ui/react-popover";
import { useState } from "react";
import { toast } from "sonner";
const REACTIONS = [
{
content: "👏",
img: "/clapping_hands.png",
},
{
content: "🤪",
img: "/face_with_tongue.png",
},
{
content: "😮",
img: "/face_with_open_mouth.png",
},
{
content: "😢",
img: "/crying_face.png",
},
{
content: "🤡",
img: "/clown_face.png",
},
];
export function NoteReaction({ event }: { event: NDKEvent }) {
const [open, setOpen] = useState(false);
const [reaction, setReaction] = useState<string | null>(null);
const getReactionImage = (content: string) => {
const reaction: { img: string } = REACTIONS.find(
(el) => el.content === content,
);
return reaction.img;
};
const react = async (content: string) => {
try {
setReaction(content);
// react
await event.react(content);
setOpen(false);
} catch (e) {
toast.error(e);
}
};
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
{reaction ? (
<img
src={getReactionImage(reaction)}
alt={reaction}
className="h-5 w-5"
/>
) : (
<ReactionIcon className="h-5 w-5 group-hover:text-blue-500" />
)}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="select-none rounded-md bg-neutral-200 px-1 py-1 text-sm will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800"
sideOffset={0}
side="top"
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => react("👏")}
className="inline-flex h-8 w-8 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/clapping_hands.png"
alt="Clapping Hands"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react("🤪")}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/face_with_tongue.png"
alt="Face with Tongue"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react("😮")}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/face_with_open_mouth.png"
alt="Face with Open Mouth"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react("😢")}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img
src="/crying_face.png"
alt="Crying Face"
className="h-6 w-6"
/>
</button>
<button
type="button"
onClick={() => react("🤡")}
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
<img src="/clown_face.png" alt="Clown Face" className="h-6 w-6" />
</button>
</div>
<Popover.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -0,0 +1,43 @@
import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { createSearchParams, useNavigate } from "react-router-dom";
export function NoteReply({
eventId,
rootEventId,
}: {
eventId: string;
rootEventId?: string;
}) {
const navigate = useNavigate();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
navigate({
pathname: "/new/",
search: createSearchParams({
replyTo: eventId,
rootReplyTo: rootEventId,
}).toString(),
})
}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<ReplyIcon className="h-5 w-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] 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 dark:bg-neutral-800 dark:text-neutral-100">
Quick reply
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,50 @@
import { RepostIcon } from "@lume/icons";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useState } from "react";
import { toast } from "sonner";
import { twMerge } from "tailwind-merge";
export function NoteRepost({ event }: { event: NDKEvent }) {
const [isRepost, setIsRepost] = useState(false);
const submit = async () => {
try {
// repost
await event.repost(true);
// update state
setIsRepost(true);
toast.success("You've reposted this post successfully");
} catch (e) {
toast.error("Repost failed, try again later");
}
};
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={submit}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<RepostIcon
className={twMerge(
"h-5 w-5 group-hover:text-blue-600",
isRepost ? "text-blue-500" : "",
)}
/>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] 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 dark:bg-neutral-800 dark:text-neutral-100">
Repost
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,260 @@
import { webln } from "@getalby/sdk";
import { SendPaymentResponse } from "@getalby/sdk/dist/types";
import { CancelIcon, ZapIcon } from "@lume/icons";
import {
compactNumber,
displayNpub,
sendNativeNotification,
} from "@lume/utils";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import * as Dialog from "@radix-ui/react-dialog";
import { invoke } from "@tauri-apps/api/primitives";
import { message } from "@tauri-apps/plugin-dialog";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useRef, useState } from "react";
import CurrencyInput from "react-currency-input-field";
import { useNavigate } from "react-router-dom";
import { useProfile } from "../../../hooks/useProfile";
import { useArk, useStorage } from "../../../provider";
export function NoteZap({ event }: { event: NDKEvent }) {
const [walletConnectURL, setWalletConnectURL] = useState<string>(null);
const [amount, setAmount] = useState<string>("21");
const [zapMessage, setZapMessage] = useState<string>("");
const [invoice, setInvoice] = useState<null | string>(null);
const [isOpen, setIsOpen] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { user } = useProfile(event.pubkey);
const ark = useArk();
const storage = useStorage();
const nwc = useRef(null);
const navigate = useNavigate();
const createZapRequest = async () => {
try {
if (!ark.ndk.signer) return navigate("/new/privkey");
const zapAmount = parseInt(amount) * 1000;
const res = await event.zap(zapAmount, zapMessage);
if (!res)
return await message("Cannot create zap request", {
title: "Zap",
type: "error",
});
// user don't connect nwc, create QR Code for invoice
if (!walletConnectURL) return setInvoice(res);
// user connect nwc
nwc.current = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: walletConnectURL,
});
await nwc.current.enable();
// start loading
setIsLoading(true);
// send payment via nwc
const send: SendPaymentResponse = await nwc.current.sendPayment(res);
if (send) {
await sendNativeNotification(
`You've tipped ${compactNumber.format(send.amount)} sats to ${
user?.name || user?.display_name || user?.displayName
}`,
);
// eose
nwc.current.close();
setIsCompleted(true);
setIsLoading(false);
// reset after 3 secs
const timeout = setTimeout(() => setIsCompleted(false), 3000);
clearTimeout(timeout);
}
} catch (e) {
nwc.current.close();
setIsLoading(false);
await message(JSON.stringify(e), { title: "Zap", type: "error" });
}
};
useEffect(() => {
async function getWalletConnectURL() {
const uri: string = await invoke("secure_load", {
key: `${storage.account.pubkey}-nwc`,
});
if (uri) setWalletConnectURL(uri);
}
if (isOpen) getWalletConnectURL();
return () => {
setAmount("21");
setZapMessage("");
setIsCompleted(false);
setIsLoading(false);
};
}, [isOpen]);
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<ZapIcon className="h-5 w-5 group-hover:text-blue-500" />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<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-between px-5 py-3">
<div className="w-6" />
<Dialog.Title className="text-center font-semibold">
Send tip to{" "}
{user?.name ||
user?.displayName ||
displayNpub(event.pubkey, 16)}
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<CancelIcon className="h-4 w-4" />
</Dialog.Close>
</div>
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
{!invoice ? (
<>
<div className="relative flex h-40 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-600 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 border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
69 sats
</button>
<button
type="button"
onClick={() => setAmount("100")}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
100 sats
</button>
<button
type="button"
onClick={() => setAmount("200")}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
200 sats
</button>
<button
type="button"
onClick={() => setAmount("500")}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
500 sats
</button>
<button
type="button"
onClick={() => setAmount("1000")}
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 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="Enter message (optional)"
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-900 dark:text-neutral-400"
/>
<div className="flex flex-col gap-2">
{walletConnectURL ? (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
{isCompleted ? (
<p className="leading-tight">Successfully zapped</p>
) : isLoading ? (
<span className="flex flex-col">
<p className="leading-tight">
Waiting for approval
</p>
<p className="text-xs leading-tight text-neutral-100">
Go to your wallet and approve payment request
</p>
</span>
) : (
<span className="flex flex-col">
<p className="leading-tight">Send zap</p>
<p className="text-xs leading-tight text-neutral-100">
You&apos;re using nostr wallet connect
</p>
</span>
)}
</button>
) : (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
Create Lightning invoice
</button>
)}
</div>
</div>
</>
) : (
<div className="mt-3 flex flex-col items-center justify-center gap-4">
<div className="rounded-md 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">Scan to zap</h3>
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
You must use Bitcoin wallet which support Lightning
<br />
such as: Blue Wallet, Bitkit, Phoenix,...
</span>
</div>
</div>
)}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,38 @@
import { useEvent } from '../../hooks/useEvent';
import { NoteChildUser } from './childUser';
export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boolean }) {
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<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="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
if (isError) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event
</div>
</div>
);
}
return (
<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="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="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{data.content}
</div>
</div>
<NoteChildUser pubkey={data.pubkey} subtext={isRoot ? 'posted' : 'replied'} />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { displayNpub } from '@lume/utils';
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { useProfile } from '../../hooks/useProfile';
export function NoteChildUser({ pubkey, subtext }: { pubkey: string; subtext: string }) {
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() => `data:image/svg+xml;utf8,${encodeURIComponent(minidenticon(pubkey, 90, 50))}`,
[pubkey]
);
const { isLoading, user } = useProfile(pubkey);
if (isLoading) {
return (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black object-cover dark:bg-white"
/>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">{fallbackName} </div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}
return (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">
{user?.display_name || user?.name || user?.displayName || fallbackName}{' '}
</div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,42 @@
import { NotePin } from "./buttons/pin";
import { NoteReaction } from "./buttons/reaction";
import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost";
import { NoteZap } from "./buttons/zap";
import { NoteChild } from "./child";
import { NoteArticleContent } from "./kinds/article";
import { NoteMediaContent } from "./kinds/media";
import { NoteTextContent } from "./kinds/text";
import { NoteMenu } from "./menu";
import { NoteReplies } from "./reply";
import { NoteRoot } from "./root";
import { NoteThread } from "./thread";
import { NoteUser } from "./user";
export const Note = {
Root: NoteRoot,
User: NoteUser,
Menu: NoteMenu,
Reply: NoteReply,
Repost: NoteRepost,
Reaction: NoteReaction,
Zap: NoteZap,
Pin: NotePin,
Child: NoteChild,
Thread: NoteThread,
TextContent: NoteTextContent,
MediaContent: NoteMediaContent,
ArticleContent: NoteArticleContent,
Replies: NoteReplies,
};
export * from "./builds/text";
export * from "./builds/repost";
export * from "./builds/skeleton";
export * from "./preview/image";
export * from "./preview/link";
export * from "./preview/video";
export * from "./mentions/note";
export * from "./mentions/user";
export * from "./mentions/hashtag";
export * from "./mentions/invoice";

View File

@@ -0,0 +1,63 @@
import { NDKTag } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
export function NoteArticleContent({
eventId,
tags,
}: {
eventId: string;
tags: NDKTag[];
}) {
const getMetadata = () => {
const title = tags.find((tag) => tag[0] === 'title')?.[1];
const image = tags.find((tag) => tag[0] === 'image')?.[1];
const summary = tags.find((tag) => tag[0] === 'summary')?.[1];
let publishedAt: Date | string | number = tags.find(
(tag) => tag[0] === 'published_at'
)?.[1];
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
return {
title,
image,
publishedAt,
summary,
};
};
const metadata = getMetadata();
return (
<Link
to={`/events/${eventId}`}
preventScrollReset={true}
className="flex w-full flex-col rounded-lg border border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900"
>
{metadata.image && (
<img
src={metadata.image}
alt={metadata.title}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-auto w-full rounded-t-lg object-cover"
/>
)}
<div className="flex flex-col gap-1 rounded-b-lg bg-neutral-200 px-3 py-3 dark:bg-neutral-800">
<h5 className="break-all font-semibold text-neutral-900 dark:text-neutral-100">
{metadata.title}
</h5>
{metadata.summary ? (
<p className="line-clamp-3 break-all text-sm text-neutral-600 dark:text-neutral-400">
{metadata.summary}
</p>
) : null}
<span className="mt-2.5 text-sm text-neutral-600 dark:text-neutral-400">
{metadata.publishedAt.toString()}
</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,80 @@
import { DownloadIcon } from "@lume/icons";
import { fileType } from "@lume/utils";
import { NDKTag } from "@nostr-dev-kit/ndk";
import { downloadDir } from "@tauri-apps/api/path";
import { download } from "@tauri-apps/plugin-upload";
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default";
import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
export function NoteMediaContent({
tags,
className,
}: {
tags: NDKTag[];
className?: string;
}) {
const url = tags.find((el) => el[0] === "url")[1];
const type = fileType(url);
const downloadImage = async (url: string) => {
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf("/") + 1);
return await download(url, downloadDirPath + `/${filename}`);
};
if (type === "image") {
return (
<div key={url} className={twMerge("group relative", className)}>
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="h-auto w-full object-cover"
/>
<button
type="button"
onClick={() => downloadImage(url)}
className="absolute right-2 top-2 hidden h-10 w-10 items-center justify-center rounded-lg bg-black/50 backdrop-blur-xl group-hover:inline-flex hover:bg-blue-500"
>
<DownloadIcon className="h-5 w-5 text-white" />
</button>
</div>
);
}
if (type === "video") {
return (
<div className={className}>
<MediaPlayer
src={url}
className="w-full overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
</div>
);
}
return (
<div className={className}>
<Link
to={url}
target="_blank"
rel="noreferrer"
className="text-blue-500 hover:text-blue-600"
>
{url}
</Link>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { twMerge } from "tailwind-merge";
import { useRichContent } from "../../../hooks/useRichContent";
export function NoteTextContent({
content,
className,
}: {
content: string;
className?: string;
}) {
const { parsedContent } = useRichContent(content);
return (
<div
className={twMerge(
"break-p select-text whitespace-pre-line text-balance leading-normal",
className,
)}
>
{parsedContent}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { WIDGET_KIND } from "@lume/utils";
import { useWidget } from "../../../hooks/useWidget";
export function Hashtag({ tag }: { tag: string }) {
const { addWidget } = useWidget();
return (
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.hashtag,
title: tag,
content: tag.replace("#", ""),
})
}
className="cursor-default break-all text-blue-500 hover:text-blue-600"
>
{tag}
</button>
);
}

View File

@@ -0,0 +1,10 @@
import { QRCodeSVG } from 'qrcode.react';
import { memo } from 'react';
export const Invoice = memo(function Invoice({ invoice }: { invoice: string }) {
return (
<div className="mt-2 flex items-center rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800">
<QRCodeSVG value={invoice} includeMargin={true} className="rounded-lg" />
</div>
);
});

View File

@@ -0,0 +1,70 @@
import { WIDGET_KIND } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { memo } from "react";
import { Note } from "..";
import { useEvent } from "../../../hooks/useEvent";
import { useWidget } from "../../../hooks/useWidget";
export const MentionNote = memo(function MentionNote({
eventId,
}: { eventId: string }) {
const { isLoading, isError, data } = useEvent(eventId);
const { addWidget } = useWidget();
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <Note.TextContent content={event.content} />;
case NDKKind.Article:
return <Note.ArticleContent eventId={event.id} tags={event.tags} />;
case 1063:
return <Note.MediaContent tags={event.tags} />;
default:
return <Note.TextContent content={event.content} />;
}
};
if (isLoading) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
Loading
</div>
);
}
if (isError) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
Failed to fetch event
</div>
);
}
return (
<Note.Root className="my-2 flex w-full cursor-default flex-col gap-1 rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="mt-3 px-3">
<Note.User
pubkey={data.pubkey}
time={data.created_at}
variant="mention"
/>
</div>
<div className="mt-1 px-3 pb-3">
{renderKind(data)}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: "Thread",
content: data.id,
})
}
className="mt-2 text-blue-500 hover:text-blue-600"
>
Show more
</button>
</div>
</Note.Root>
);
});

View File

@@ -0,0 +1,27 @@
import { WIDGET_KIND } from "@lume/utils";
import { memo } from "react";
import { useProfile } from "../../../hooks/useProfile";
import { useWidget } from "../../../hooks/useWidget";
export const MentionUser = memo(function MentionUser({
pubkey,
}: { pubkey: string }) {
const { user } = useProfile(pubkey);
const { addWidget } = useWidget();
return (
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.user,
title: user?.name || user?.display_name || user?.displayName,
content: pubkey,
})
}
className="break-words text-blue-500 hover:text-blue-600"
>
{`@${user?.name || user?.displayName || user?.username || "unknown"}`}
</button>
);
});

View File

@@ -0,0 +1,63 @@
import { HorizontalDotsIcon } from '@lume/icons';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/types/nip19';
import { useState } from 'react';
import { Link } from 'react-router-dom';
export function NoteMenu({ eventId, pubkey }: { eventId: string; pubkey: string }) {
const [open, setOpen] = useState(false);
const copyID = async () => {
await writeText(nip19.neventEncode({ id: eventId, author: pubkey } as EventPointer));
setOpen(false);
};
const copyLink = async () => {
await writeText(
`https://njump.me/${nip19.neventEncode({ id: eventId, author: pubkey } as EventPointer)}`
);
setOpen(false);
};
return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<DropdownMenu.Trigger asChild>
<button type="button" className="inline-flex h-6 w-6 items-center justify-center">
<HorizontalDotsIcon className="h-4 w-4 text-neutral-800 hover:text-blue-500 dark:text-neutral-200" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Copy shareable link
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Copy ID
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<Link
to={`/users/${pubkey}`}
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
>
View profile
</Link>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -0,0 +1,57 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { downloadDir } from "@tauri-apps/api/path";
import { Window } from "@tauri-apps/api/window";
import { download } from "@tauri-apps/plugin-upload";
import { SyntheticEvent, useState } from "react";
export function ImagePreview({ url }: { url: string }) {
const [downloaded, setDownloaded] = useState(false);
const downloadImage = async (e: { stopPropagation: () => void }) => {
try {
e.stopPropagation();
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf("/") + 1);
await download(url, downloadDirPath + `/${filename}`);
setDownloaded(true);
} catch (e) {
console.error(e);
}
};
const open = () => {
return new Window("image-viewer", { url, title: "Image Viewer" });
};
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
event.currentTarget.src = "/fallback-image.jpg";
};
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div onClick={open} className="group relative my-2">
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
onError={fallback}
className="h-auto w-full rounded-lg border border-neutral-200/50 object-cover dark:border-neutral-800/50"
/>
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute right-2 top-2 z-10 hidden h-10 w-10 items-center justify-center rounded-lg bg-blue-500 group-hover:inline-flex hover:bg-blue-600"
>
{downloaded ? (
<CheckCircleIcon className="h-5 w-5 text-white" />
) : (
<DownloadIcon className="h-5 w-5 text-white" />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useOpenGraph } from "@lume/utils";
import { Link } from "react-router-dom";
function isImage(url: string) {
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
}
export function LinkPreview({ url }: { url: string }) {
const domain = new URL(url);
const { status, data } = useOpenGraph(url);
if (status === "pending") {
return (
<div className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="h-48 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<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-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</span>
</div>
</div>
);
}
if (!data.title && !data.image) {
return (
<Link
to={url}
target="_blank"
rel="noreferrer"
className="text-blue-500 hover:text-blue-600"
>
{url}
</Link>
);
}
return (
<Link
to={url}
target="_blank"
rel="noreferrer"
className="my-2 flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"
>
{isImage(data.image) ? (
<img
src={data.image}
alt={url}
className="h-48 w-full rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start px-3 py-3">
<div className="flex flex-col items-start gap-1 text-left">
{data.title ? (
<div className="break-all text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="mb-2 line-clamp-3 break-all text-sm text-neutral-700 dark:text-neutral-400">
{data.description}
</div>
) : null}
</div>
<div className="break-all text-sm text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,19 @@
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from '@vidstack/react/player/layouts/default';
export function VideoPreview({ url }: { url: string }) {
return (
<MediaPlayer
src={url}
className="my-2 w-full overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
);
}

View File

@@ -0,0 +1,67 @@
import { LoaderIcon } from '@lume/icons';
import { NDKEventWithReplies } from '@lume/types';
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '../../provider';
import { Reply } from './builds/reply';
export function NoteReplies({ eventId }: { eventId: string }) {
const ark = useArk();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
useEffect(() => {
let sub: NDKSubscription;
let isCancelled = false;
async function fetchRepliesAndSub() {
const events = await ark.getThreads({ id: eventId });
if (!isCancelled) {
setData(events);
}
// subscribe for new replies
sub = ark.subscribe({
filter: {
'#e': [eventId],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
});
}
fetchRepliesAndSub();
return () => {
isCancelled = true;
if (sub) sub.stop();
};
}, [eventId]);
if (!data) {
return (
<div className="mt-3">
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
</div>
);
}
return (
<div className="mt-3 flex flex-col gap-5">
<h3 className="font-semibold">Replies</h3>
{data?.length === 0 ? (
<div className="mt-2 flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply!
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} rootEvent={eventId} />)
)}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export function NoteRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={twMerge(
'mt-3 flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 px-3 dark:bg-neutral-950',
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { WIDGET_KIND } from '@lume/utils';
import { twMerge } from 'tailwind-merge';
import { Note } from '.';
import { useWidget } from '../../hooks/useWidget';
export function NoteThread({
thread,
className,
}: {
thread: { rootEventId: string; replyEventId: string };
className?: string;
}) {
const { addWidget } = useWidget();
if (!thread) return null;
return (
<div className={twMerge('w-full px-3', className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? <Note.Child eventId={thread.rootEventId} isRoot /> : null}
{thread.replyEventId ? <Note.Child eventId={thread.replyEventId} /> : null}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { RepostIcon } from '@lume/icons';
import { displayNpub, formatCreatedAt } from '@lume/utils';
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { twMerge } from 'tailwind-merge';
import { useProfile } from '../../hooks/useProfile';
export function NoteUser({
pubkey,
time,
variant = 'text',
className,
}: {
pubkey: string;
time: number;
variant?: 'text' | 'repost' | 'mention';
className?: string;
}) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() => `data:image/svg+xml;utf8,${encodeURIComponent(minidenticon(pubkey, 90, 50))}`,
[pubkey]
);
const { isLoading, user } = useProfile(pubkey);
if (variant === 'mention') {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
return (
<div className="flex h-6 items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
if (variant === 'repost') {
if (isLoading) {
return (
<div className={twMerge('flex gap-3', className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<div className="h-6 w-6 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className={twMerge('flex gap-2', className)}>
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className={twMerge('flex items-center gap-3', className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Root>
<div className="h-6 flex-1">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{fallbackName}
</div>
</div>
</div>
);
}
return (
<div className={twMerge('flex items-center gap-3', className)}>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-9 w-9 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex h-6 flex-1 items-start justify-between gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name || user?.display_name || user?.displayName || fallbackName}
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export function WidgetContent({ children }: { children: ReactNode }) {
return <div className="h-full w-full">{children}</div>;
}

View File

@@ -0,0 +1,112 @@
import {
ArrowLeftIcon,
ArrowRightIcon,
HorizontalDotsIcon,
RefreshIcon,
ThreadIcon,
TrashIcon,
} from '@lume/icons';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useQueryClient } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useWidget } from '../../hooks/useWidget';
export function WidgetHeader({
id,
title,
queryKey,
icon,
}: {
id: string;
title: string;
queryKey?: string[];
icon?: ReactNode;
}) {
const queryClient = useQueryClient();
const { removeWidget } = useWidget();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
const moveLeft = async () => {
removeWidget.mutate(id);
};
const moveRight = async () => {
removeWidget.mutate(id);
};
const deleteWidget = async () => {
removeWidget.mutate(id);
};
return (
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<div className="inline-flex items-center gap-4">
<div className="h-5 w-1 rounded-full bg-blue-500" />
<div className="inline-flex items-center gap-2">
{icon ? icon : <ThreadIcon className="h-5 w-5" />}
<div className="text-sm font-medium">{title}</div>
</div>
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center"
>
<HorizontalDotsIcon className="h-4 w-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] flex-col overflow-hidden rounded-xl border border-neutral-100 bg-white p-2 shadow-lg shadow-neutral-200/50 focus:outline-none dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-neutral-900/50">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<RefreshIcon className="h-5 w-5" />
Refresh
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveLeft}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowLeftIcon className="h-5 w-5" />
Move left
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveRight}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowRightIcon className="h-5 w-5" />
Move right
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-100 dark:bg-neutral-900" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={deleteWidget}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-red-500 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="h-5 w-5" />
Delete
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { WidgetContent } from "./content";
import { WidgetHeader } from "./header";
import { WidgetLive } from "./live";
import { WidgetRoot } from "./root";
export const Widget = {
Root: WidgetRoot,
Live: WidgetLive,
Header: WidgetHeader,
Content: WidgetContent,
};

View File

@@ -0,0 +1,42 @@
import { ChevronUpIcon } from '@lume/icons';
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useArk } from '../../provider';
export function WidgetLive({
filter,
onClick,
}: {
filter: NDKFilter;
onClick: () => void;
}) {
const ark = useArk();
const [events, setEvents] = useState<NDKEvent[]>([]);
useEffect(() => {
const sub = ark.subscribe({
filter,
closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
});
return () => {
if (sub) sub.stop();
};
}, []);
if (!events.length) return null;
return (
<div className="absolute left-0 top-11 z-50 flex h-11 w-full items-center justify-center">
<button
type="button"
onClick={onClick}
className="inline-flex h-9 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-2.5 text-sm font-semibold text-white hover:bg-blue-600"
>
<ChevronUpIcon className="h-4 w-4" />
{events.length} {events.length === 1 ? 'event' : 'events'}
</button>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { Resizable } from "re-resizable";
import { ReactNode, useState } from "react";
import { twMerge } from "tailwind-merge";
export function WidgetRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
return (
<Resizable
size={{ width, height: "100%" }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
}}
minWidth={420}
maxWidth={600}
className={twMerge(
"relative flex flex-col border-r-2 border-neutral-50 hover:border-neutral-100 dark:border-neutral-950 dark:hover:border-neutral-900",
className,
)}
enable={{ right: true }}
>
{children}
</Resizable>
);
}

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { useArk } from '../provider';
export function useEvent(id: string) {
const ark = useArk();
const { status, isLoading, isError, data } = useQuery({
queryKey: ['event', id],
queryFn: async () => {
const event = await ark.getEventById({ id });
if (!event)
throw new Error(`Cannot get event with ${id}, will be retry after 10 seconds`);
return event;
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 2,
});
return { status, isLoading, isError, data };
}

View File

@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { useArk } from '../provider';
export function useProfile(pubkey: string) {
const ark = useArk();
const {
isLoading,
isError,
data: user,
} = useQuery({
queryKey: ['user', pubkey],
queryFn: async () => {
const profile = await ark.getUserProfile({ pubkey });
if (!profile)
throw new Error(
`Cannot get metadata for ${pubkey}, will be retry after 10 seconds`
);
return profile;
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 2,
});
return { isLoading, isError, user };
}

View File

@@ -0,0 +1,96 @@
import { NDKKind, NDKRelayUrl, NDKTag } from "@nostr-dev-kit/ndk";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useArk, useStorage } from "../provider";
export function useRelay() {
const ark = useArk();
const storage = useStorage();
const queryClient = useQueryClient();
const connectRelay = useMutation({
mutationFn: async (
relay: NDKRelayUrl,
purpose?: "read" | "write" | undefined,
) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({
queryKey: ["relays", storage.account.pubkey],
});
// Snapshot the previous value
const prevRelays: NDKTag[] = queryClient.getQueryData([
"relays",
storage.account.pubkey,
]);
// create new relay list if not exist
if (!prevRelays) {
await ark.createEvent({
kind: NDKKind.RelayList,
tags: [["r", relay, purpose ?? ""]],
});
}
// add relay to exist list
const index = prevRelays.findIndex((el) => el[1] === relay);
if (index > -1) return;
await ark.createEvent({
kind: NDKKind.RelayList,
tags: [...prevRelays, ["r", relay, purpose ?? ""]],
});
// Optimistically update to the new value
queryClient.setQueryData(
["relays", storage.account.pubkey],
(prev: NDKTag[]) => [...prev, ["r", relay, purpose ?? ""]],
);
// Return a context object with the snapshotted value
return { prevRelays };
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["relays", storage.account.pubkey],
});
},
});
const removeRelay = useMutation({
mutationFn: async (relay: NDKRelayUrl) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({
queryKey: ["relays", storage.account.pubkey],
});
// Snapshot the previous value
const prevRelays: NDKTag[] = queryClient.getQueryData([
"relays",
storage.account.pubkey,
]);
if (!prevRelays) return;
const index = prevRelays.findIndex((el) => el[1] === relay);
if (index > -1) prevRelays.splice(index, 1);
await ark.createEvent({
kind: NDKKind.RelayList,
tags: prevRelays,
});
// Optimistically update to the new value
queryClient.setQueryData(["relays", storage.account.pubkey], prevRelays);
// Return a context object with the snapshotted value
return { prevRelays };
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["relays", storage.account.pubkey],
});
},
});
return { connectRelay, removeRelay };
}

View File

@@ -0,0 +1,183 @@
import { nanoid } from 'nanoid';
import { nip19 } from 'nostr-tools';
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import reactStringReplace from 'react-string-replace';
import {
Hashtag,
ImagePreview,
LinkPreview,
MentionNote,
MentionUser,
VideoPreview,
} from '../components/note';
import { useStorage } from '../provider';
const NOSTR_MENTIONS = [
'@npub1',
'nostr:npub1',
'nostr:nprofile1',
'nostr:naddr1',
'npub1',
'nprofile1',
'naddr1',
'Nostr:npub1',
'Nostr:nprofile1',
'Nostr:naddre1',
];
const NOSTR_EVENTS = [
'nostr:note1',
'note1',
'nostr:nevent1',
'nevent1',
'Nostr:note1',
'Nostr:nevent1',
];
// const BITCOINS = ['lnbc', 'bc1p', 'bc1q'];
const IMAGES = ['.jpg', '.jpeg', '.gif', '.png', '.webp', '.avif', '.tiff'];
const VIDEOS = [
'.mp4',
'.mov',
'.webm',
'.wmv',
'.flv',
'.mts',
'.avi',
'.ogv',
'.mkv',
'.mp3',
'.m3u8',
];
export function useRichContent(content: string, textmode = false) {
const storage = useStorage();
let parsedContent: string | ReactNode[] = content.replace(/\n+/g, '\n');
let linkPreview: string;
let images: string[] = [];
let videos: string[] = [];
let events: string[] = [];
const text = parsedContent;
const words = text.split(/( |\n)/);
if (!textmode) {
if (storage.settings.media) {
images = words.filter((word) => IMAGES.some((el) => word.endsWith(el)));
videos = words.filter((word) => VIDEOS.some((el) => word.endsWith(el)));
}
events = words.filter((word) => NOSTR_EVENTS.some((el) => word.startsWith(el)));
}
const hashtags = words.filter((word) => word.startsWith('#'));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el))
);
try {
if (images.length) {
for(const image of images) {
parsedContent = reactStringReplace(parsedContent, image, (match, i) => (
<ImagePreview key={match + i} url={match} />
));
}
}
if (videos.length) {
for(const video of videos) {
parsedContent = reactStringReplace(parsedContent, video, (match, i) => (
<VideoPreview key={match + i} url={match} />
));
}
}
if (hashtags.length) {
for(const hashtag of hashtags) {
parsedContent = reactStringReplace(parsedContent, hashtag, (match, i) => {
if (storage.settings.hashtag) return <Hashtag key={match + i} tag={hashtag} />;
return null;
});
}
}
if (events.length) {
for(const event of events) {
const address = event.replace('nostr:', '').replace(/[^a-zA-Z0-9]/g, '');
const decoded = nip19.decode(address);
if (decoded.type === 'note') {
parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
<MentionNote key={match + i} eventId={decoded.data} />
));
}
if (decoded.type === 'nevent') {
parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
<MentionNote key={match + i} eventId={decoded.data.id} />
));
}
}
}
if (mentions.length) {
for(const mention of mentions) {
const address = mention
.replace('nostr:', '')
.replace('@', '')
.replace(/[^a-zA-Z0-9]/g, '');
const decoded = nip19.decode(address);
if (decoded.type === 'npub') {
parsedContent = reactStringReplace(parsedContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={decoded.data} />
));
}
if (decoded.type === 'nprofile' || decoded.type === 'naddr') {
parsedContent = reactStringReplace(parsedContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={decoded.data.pubkey} />
));
}
}
}
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
const url = new URL(match);
url.search = '';
if (!linkPreview && !textmode) {
linkPreview = match;
return <LinkPreview key={match + i} url={url.toString()} />;
}
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-all font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
});
parsedContent = reactStringReplace(parsedContent, '\n', () => {
return <div key={nanoid()} className="h-3" />;
});
if (typeof parsedContent[0] === 'string') {
parsedContent[0] = parsedContent[0].trimStart();
}
return { parsedContent };
} catch (e) {
console.warn('[parser] parse failed: ', e);
return { parsedContent };
}
}

View File

@@ -0,0 +1,78 @@
import { MentionOptions } from "@tiptap/extension-mention";
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
import { MentionList } from "../components/mentions";
import { useStorage } from "../provider";
export function useSuggestion() {
const storage = useStorage();
const suggestion: MentionOptions["suggestion"] = {
items: async ({ query }) => {
const users = await storage.getAllCacheUsers();
return users
.filter((item) => {
if (item.name)
return item.name.toLowerCase().startsWith(query.toLowerCase());
return item.displayName.toLowerCase().startsWith(query.toLowerCase());
})
.slice(0, 5);
},
render: () => {
let component;
let popup;
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
};
return { suggestion };
}

View File

@@ -0,0 +1,79 @@
import { type WidgetProps } from '@lume/types';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useStorage } from '../provider';
export function useWidget() {
const storage = useStorage();
const queryClient = useQueryClient();
const addWidget = useMutation({
mutationFn: async (widget: WidgetProps) => {
return await storage.createWidget(widget.kind, widget.title, widget.content);
},
onSuccess: (data) => {
queryClient.setQueryData(['widgets'], (old: WidgetProps[]) => [...old, data]);
},
});
const replaceWidget = useMutation({
mutationFn: async ({
currentId,
widget,
}: {
currentId: string;
widget: WidgetProps;
}) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['widgets'] });
// Snapshot the previous value
const prevWidgets = queryClient.getQueryData(['widgets']);
// create new widget
await storage.removeWidget(currentId);
const newWidget = await storage.createWidget(
widget.kind,
widget.title,
widget.content
);
// Optimistically update to the new value
queryClient.setQueryData(['widgets'], (prev: WidgetProps[]) => [
...prev.filter((t) => t.id !== currentId),
newWidget,
]);
// Return a context object with the snapshotted value
return { prevWidgets };
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['widgets'] });
},
});
const removeWidget = useMutation({
mutationFn: async (id: string) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['widgets'] });
// Snapshot the previous value
const prevWidgets = queryClient.getQueryData(['widgets']);
// Optimistically update to the new value
queryClient.setQueryData(['widgets'], (prev: WidgetProps[]) =>
prev.filter((t) => t.id !== id)
);
// Update in database
await storage.removeWidget(id);
// Return a context object with the snapshotted value
return { prevWidgets };
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['widgets'] });
},
});
return { addWidget, replaceWidget, removeWidget };
}

10
packages/ark/src/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export * from "./ark";
export * from "./provider";
export * from "./components/widget";
export * from "./components/note";
export * from "./hooks/useWidget";
export * from "./hooks/useRichContent";
export * from "./hooks/useEvent";
export * from "./hooks/useProfile";
export * from "./hooks/useSuggestion";
export * from "./hooks/useRelay";

View File

@@ -0,0 +1,227 @@
import { LoaderIcon } from '@lume/icons';
import { NDKCacheAdapterTauri } from '@lume/ndk-cache-tauri';
import { LumeStorage } from '@lume/storage';
import { QUOTES, delay } from '@lume/utils';
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
import { platform } from '@tauri-apps/plugin-os';
import { relaunch } from '@tauri-apps/plugin-process';
import Database from '@tauri-apps/plugin-sql';
import { check } from '@tauri-apps/plugin-updater';
import Markdown from 'markdown-to-jsx';
import { NostrFetcher, normalizeRelayUrl, normalizeRelayUrlSet } from 'nostr-fetch';
import { PropsWithChildren, useEffect, useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
import { Ark } from './ark';
type Context = {
storage: LumeStorage;
ark: Ark;
};
const LumeContext = createContext<Context>({
storage: undefined,
ark: undefined,
});
const LumeProvider = ({ children }: PropsWithChildren<object>) => {
const [context, setContext] = useState<Context>(undefined);
const [isNewVersion, setIsNewVersion] = useState(false);
async function initNostrSigner({
storage,
nsecbunker,
}: {
storage: LumeStorage;
nsecbunker?: boolean;
}) {
try {
if (!storage.account) return null;
// NIP-46 Signer
if (nsecbunker) {
const localSignerPrivkey = await storage.loadPrivkey(
`${storage.account.id}-nsecbunker`
);
if (!localSignerPrivkey) return null;
const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
const bunker = new NDK({
explicitRelayUrls: normalizeRelayUrlSet([
'wss://relay.nsecbunker.com/',
'wss://nostr.vulpem.com/',
]),
});
await bunker.connect(3000);
const remoteSigner = new NDKNip46Signer(
bunker,
storage.account.pubkey,
localSigner
);
await remoteSigner.blockUntilReady();
return remoteSigner;
}
// Privkey Signer
const userPrivkey = await storage.loadPrivkey(storage.account.pubkey);
if (!userPrivkey) {
return null;
}
return new NDKPrivateKeySigner(userPrivkey);
} catch (e) {
console.error(e);
return null;
}
}
async function init() {
const platformName = await platform();
const sqliteAdapter = await Database.load('sqlite:lume_v2.db');
const storage = new LumeStorage(sqliteAdapter, platformName);
storage.init();
// check for new update
if (storage.settings.autoupdate) {
const update = await check();
// install new version
if (update) {
setIsNewVersion(true);
await update.downloadAndInstall();
await relaunch();
}
}
const explicitRelayUrls = normalizeRelayUrlSet([
'wss://relay.damus.io',
'wss://relay.nostr.band/all',
'wss://nostr.mutinywallet.com',
]);
if (storage.settings.depot) {
await storage.launchDepot();
await delay(2000);
explicitRelayUrls.push(normalizeRelayUrl('ws://localhost:6090'));
}
// #TODO: user should config outbox relays
const outboxRelayUrls = normalizeRelayUrlSet(['wss://purplepag.es']);
// #TODO: user should config blacklist relays
// No need to connect depot tunnel url
const blacklistRelayUrls = storage.settings.tunnelUrl.length
? [storage.settings.tunnelUrl, `${storage.settings.tunnelUrl}/`]
: [];
const cacheAdapter = new NDKCacheAdapterTauri(storage);
const ndk = new NDK({
cacheAdapter,
explicitRelayUrls,
outboxRelayUrls,
blacklistRelayUrls,
enableOutboxModel: storage.settings.lowPowerMode ? false : storage.settings.outbox,
autoConnectUserRelays: storage.settings.lowPowerMode ? false : true,
autoFetchUserMutelist: storage.settings.lowPowerMode ? false : true,
// clientName: 'Lume',
// clientNip89: '',
});
// add signer
const signer = await initNostrSigner({
storage,
nsecbunker: storage.settings.bunker,
});
if (signer) ndk.signer = signer;
// connect
await ndk.connect(3000);
// update account's metadata
if (storage.account) {
const user = ndk.getUser({ pubkey: storage.account.pubkey });
ndk.activeUser = user;
const contacts = await user.follows();
storage.account.contacts = [...contacts].map((user) => user.pubkey);
}
// init nostr fetcher
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(ndk));
// ark utils
const ark = new Ark({ storage, ndk, fetcher });
// update context
setContext({ ark, storage });
}
useEffect(() => {
if (!context && !isNewVersion) init();
}, []);
if (!context) {
return (
<div
data-tauri-drag-region
className="relative flex h-screen w-screen items-center justify-center bg-neutral-50 dark:bg-neutral-950"
>
<div className="flex max-w-2xl flex-col items-start gap-1">
<h5 className="font-semibold uppercase">TIP:</h5>
<Markdown
options={{
overrides: {
a: {
props: {
className: 'text-blue-500 hover:text-blue-600',
target: '_blank',
},
},
},
}}
className="text-4xl font-semibold leading-snug text-neutral-300 dark:text-neutral-700"
>
{QUOTES[Math.floor(Math.random() * QUOTES.length)]}
</Markdown>
</div>
<div className="absolute bottom-5 right-5 inline-flex items-center gap-2.5">
<LoaderIcon className="h-6 w-6 animate-spin text-blue-500" />
<p className="font-semibold">
{isNewVersion ? 'Found a new version, updating...' : 'Starting...'}
</p>
</div>
</div>
);
}
return (
<LumeContext.Provider value={{ ark: context.ark, storage: context.storage }}>
{children}
</LumeContext.Provider>
);
};
const useArk = () => {
const context = useContextSelector(LumeContext, (state) => state.ark);
if (context === undefined) {
throw new Error('Please import Ark Provider to use useArk() hook');
}
return context;
};
const useStorage = () => {
const context = useContextSelector(LumeContext, (state) => state.storage);
if (context === undefined) {
throw new Error('Please import Ark Provider to use useStorage() hook');
}
return context;
};
export { LumeProvider, useArk, useStorage };

View File

@@ -0,0 +1,8 @@
import sharedConfig from "@lume/tailwindcss";
const config = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
presets: [sharedConfig],
};
export default config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@lume/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}