chore: monorepo
This commit is contained in:
58
packages/ark/package.json
Normal file
58
packages/ark/package.json
Normal 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
543
packages/ark/src/ark.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
packages/ark/src/components/mentions.tsx
Normal file
116
packages/ark/src/components/mentions.tsx
Normal 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);
|
||||
76
packages/ark/src/components/note/builds/reply.tsx
Normal file
76
packages/ark/src/components/note/builds/reply.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
packages/ark/src/components/note/builds/repost.tsx
Normal file
82
packages/ark/src/components/note/builds/repost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
packages/ark/src/components/note/builds/skeleton.tsx
Normal file
24
packages/ark/src/components/note/builds/skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
packages/ark/src/components/note/builds/text.tsx
Normal file
29
packages/ark/src/components/note/builds/text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
packages/ark/src/components/note/buttons/pin.tsx
Normal file
37
packages/ark/src/components/note/buttons/pin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
packages/ark/src/components/note/buttons/reaction.tsx
Normal file
136
packages/ark/src/components/note/buttons/reaction.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
packages/ark/src/components/note/buttons/reply.tsx
Normal file
43
packages/ark/src/components/note/buttons/reply.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
packages/ark/src/components/note/buttons/repost.tsx
Normal file
50
packages/ark/src/components/note/buttons/repost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
packages/ark/src/components/note/buttons/zap.tsx
Normal file
260
packages/ark/src/components/note/buttons/zap.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
38
packages/ark/src/components/note/child.tsx
Normal file
38
packages/ark/src/components/note/child.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
packages/ark/src/components/note/childUser.tsx
Normal file
64
packages/ark/src/components/note/childUser.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
packages/ark/src/components/note/index.ts
Normal file
42
packages/ark/src/components/note/index.ts
Normal 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";
|
||||
63
packages/ark/src/components/note/kinds/article.tsx
Normal file
63
packages/ark/src/components/note/kinds/article.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
packages/ark/src/components/note/kinds/media.tsx
Normal file
80
packages/ark/src/components/note/kinds/media.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
packages/ark/src/components/note/kinds/text.tsx
Normal file
23
packages/ark/src/components/note/kinds/text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
packages/ark/src/components/note/mentions/hashtag.tsx
Normal file
22
packages/ark/src/components/note/mentions/hashtag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
packages/ark/src/components/note/mentions/invoice.tsx
Normal file
10
packages/ark/src/components/note/mentions/invoice.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
70
packages/ark/src/components/note/mentions/note.tsx
Normal file
70
packages/ark/src/components/note/mentions/note.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
27
packages/ark/src/components/note/mentions/user.tsx
Normal file
27
packages/ark/src/components/note/mentions/user.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
63
packages/ark/src/components/note/menu.tsx
Normal file
63
packages/ark/src/components/note/menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
packages/ark/src/components/note/preview/image.tsx
Normal file
57
packages/ark/src/components/note/preview/image.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
packages/ark/src/components/note/preview/link.tsx
Normal file
73
packages/ark/src/components/note/preview/link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
packages/ark/src/components/note/preview/video.tsx
Normal file
19
packages/ark/src/components/note/preview/video.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
packages/ark/src/components/note/reply.tsx
Normal file
67
packages/ark/src/components/note/reply.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
packages/ark/src/components/note/root.tsx
Normal file
21
packages/ark/src/components/note/root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
packages/ark/src/components/note/thread.tsx
Normal file
38
packages/ark/src/components/note/thread.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
packages/ark/src/components/note/user.tsx
Normal file
173
packages/ark/src/components/note/user.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
packages/ark/src/components/widget/content.tsx
Normal file
5
packages/ark/src/components/widget/content.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function WidgetContent({ children }: { children: ReactNode }) {
|
||||
return <div className="h-full w-full">{children}</div>;
|
||||
}
|
||||
112
packages/ark/src/components/widget/header.tsx
Normal file
112
packages/ark/src/components/widget/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
packages/ark/src/components/widget/index.ts
Normal file
11
packages/ark/src/components/widget/index.ts
Normal 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,
|
||||
};
|
||||
42
packages/ark/src/components/widget/live.tsx
Normal file
42
packages/ark/src/components/widget/live.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
packages/ark/src/components/widget/root.tsx
Normal file
32
packages/ark/src/components/widget/root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
packages/ark/src/hooks/useEvent.ts
Normal file
21
packages/ark/src/hooks/useEvent.ts
Normal 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 };
|
||||
}
|
||||
27
packages/ark/src/hooks/useProfile.ts
Normal file
27
packages/ark/src/hooks/useProfile.ts
Normal 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 };
|
||||
}
|
||||
96
packages/ark/src/hooks/useRelay.ts
Normal file
96
packages/ark/src/hooks/useRelay.ts
Normal 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 };
|
||||
}
|
||||
183
packages/ark/src/hooks/useRichContent.tsx
Normal file
183
packages/ark/src/hooks/useRichContent.tsx
Normal 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 };
|
||||
}
|
||||
}
|
||||
78
packages/ark/src/hooks/useSuggestion.ts
Normal file
78
packages/ark/src/hooks/useSuggestion.ts
Normal 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 };
|
||||
}
|
||||
79
packages/ark/src/hooks/useWidget.ts
Normal file
79
packages/ark/src/hooks/useWidget.ts
Normal 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
10
packages/ark/src/index.ts
Normal 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";
|
||||
227
packages/ark/src/provider.tsx
Normal file
227
packages/ark/src/provider.tsx
Normal 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 };
|
||||
8
packages/ark/tailwind.config.js
Normal file
8
packages/ark/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import sharedConfig from "@lume/tailwindcss";
|
||||
|
||||
const config = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
||||
presets: [sharedConfig],
|
||||
};
|
||||
|
||||
export default config;
|
||||
8
packages/ark/tsconfig.json
Normal file
8
packages/ark/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@lume/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user