chore: monorepo
This commit is contained in:
22
packages/@columns/notification/package.json
Normal file
22
packages/@columns/notification/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@columns/notification",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@nostr-dev-kit/ndk": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"react": "^18.2.0",
|
||||
"virtua": "^0.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@types/react": "^18.2.45",
|
||||
"tailwind": "^4.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
1
packages/@columns/notification/src/index.ts
Normal file
1
packages/@columns/notification/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./notification";
|
||||
173
packages/@columns/notification/src/notification.tsx
Normal file
173
packages/@columns/notification/src/notification.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { NoteSkeleton, TextNote, Widget, useArk, useStorage } from "@lume/ark";
|
||||
import {
|
||||
AnnouncementIcon,
|
||||
ArrowRightCircleIcon,
|
||||
LoaderIcon,
|
||||
} from "@lume/icons";
|
||||
import { FETCH_LIMIT, sendNativeNotification } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind, NDKSubscription } from "@nostr-dev-kit/ndk";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function NotificationColumn() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["notification"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [
|
||||
NDKKind.Text,
|
||||
NDKKind.Repost,
|
||||
NDKKind.Reaction,
|
||||
NDKKind.Zap,
|
||||
],
|
||||
"#p": [storage.account.pubkey],
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const renderEvent = (event: NDKEvent) => {
|
||||
if (event.pubkey === storage.account.pubkey) return null;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let sub: NDKSubscription = undefined;
|
||||
|
||||
if (status === "success" && storage.account) {
|
||||
const filter = {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
|
||||
"#p": [storage.account.pubkey],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
sub = ark.subscribe({
|
||||
filter,
|
||||
closeOnEose: false,
|
||||
cb: async (event) => {
|
||||
queryClient.setQueryData(
|
||||
["notification"],
|
||||
(prev: { pageParams: number; pages: Array<NDKEvent[]> }) => ({
|
||||
...prev,
|
||||
pages: [[event], ...prev.pages],
|
||||
}),
|
||||
);
|
||||
|
||||
const profile = await ark.getUserProfile({ pubkey: event.pubkey });
|
||||
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has replied to your note`,
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has reposted to your note`,
|
||||
);
|
||||
case NDKKind.Reaction:
|
||||
return await sendNativeNotification(
|
||||
`${profile.displayName || profile.name} has reacted ${
|
||||
event.content
|
||||
} to your note`,
|
||||
);
|
||||
case NDKKind.Zap:
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has zapped to your note`,
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (sub) sub.stop();
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<Widget.Root>
|
||||
<Widget.Header
|
||||
id="9998"
|
||||
queryKey={["notification"]}
|
||||
title="Notification"
|
||||
icon={<AnnouncementIcon className="h-5 w-5" />}
|
||||
/>
|
||||
<Widget.Content>
|
||||
<VList className="flex-1" overscan={2}>
|
||||
{status === "pending" ? (
|
||||
<NoteSkeleton />
|
||||
) : allEvents.length < 1 ? (
|
||||
<div className="my-3 flex w-full items-center justify-center gap-2">
|
||||
<div>🎉</div>
|
||||
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Hmm! Nothing new yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((event) => renderEvent(event))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center px-3 pb-3">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
</Widget.Content>
|
||||
</Widget.Root>
|
||||
);
|
||||
}
|
||||
8
packages/@columns/notification/tailwind.config.js
Normal file
8
packages/@columns/notification/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/@columns/notification/tsconfig.json
Normal file
8
packages/@columns/notification/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@lume/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
22
packages/@columns/timeline/package.json
Normal file
22
packages/@columns/timeline/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@columns/timeline",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@nostr-dev-kit/ndk": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"react": "^18.2.0",
|
||||
"virtua": "^0.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@types/react": "^18.2.45",
|
||||
"tailwind": "^4.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
1
packages/@columns/timeline/src/index.ts
Normal file
1
packages/@columns/timeline/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./timeline";
|
||||
104
packages/@columns/timeline/src/timeline.tsx
Normal file
104
packages/@columns/timeline/src/timeline.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { RepostNote, TextNote, Widget, useArk, useStorage } from "@lume/ark";
|
||||
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from "@lume/icons";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { VList, VListHandle } from "virtua";
|
||||
|
||||
export function TimelineColumn() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const ref = useRef<VListHandle>();
|
||||
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["newsfeed"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: !storage.account.contacts.length
|
||||
? [storage.account.pubkey]
|
||||
: storage.account.contacts,
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const renderItem = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
case NDKKind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default:
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget.Root>
|
||||
<Widget.Header
|
||||
id="9999"
|
||||
queryKey={["newsfeed"]}
|
||||
title="Timeline"
|
||||
icon={<TimelineIcon className="h-5 w-5" />}
|
||||
/>
|
||||
<Widget.Content>
|
||||
<VList ref={ref} overscan={2} className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
|
||||
<LoaderIcon className="size-5" />
|
||||
Loading
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center px-3 py-3">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
</Widget.Content>
|
||||
</Widget.Root>
|
||||
);
|
||||
}
|
||||
8
packages/@columns/timeline/tailwind.config.js
Normal file
8
packages/@columns/timeline/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/@columns/timeline/tsconfig.json
Normal file
8
packages/@columns/timeline/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@lume/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
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"]
|
||||
}
|
||||
94
packages/icons/index.ts
Normal file
94
packages/icons/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
export * from "./src/addWidget";
|
||||
export * from "./src/arrowLeft";
|
||||
export * from "./src/arrowRight";
|
||||
export * from "./src/bell";
|
||||
export * from "./src/cancel";
|
||||
export * from "./src/checkCircle";
|
||||
export * from "./src/chevronDown";
|
||||
export * from "./src/chevronRight";
|
||||
export * from "./src/compose";
|
||||
export * from "./src/copy";
|
||||
export * from "./src/edit";
|
||||
export * from "./src/enter";
|
||||
export * from "./src/eyeOff";
|
||||
export * from "./src/eyeOn";
|
||||
export * from "./src/feed";
|
||||
export * from "./src/heartbeat";
|
||||
export * from "./src/hide";
|
||||
export * from "./src/image";
|
||||
export * from "./src/like";
|
||||
export * from "./src/lume";
|
||||
export * from "./src/media";
|
||||
export * from "./src/mute";
|
||||
export * from "./src/space";
|
||||
export * from "./src/navArrowDown";
|
||||
export * from "./src/plus";
|
||||
export * from "./src/plusCircle";
|
||||
export * from "./src/refresh";
|
||||
export * from "./src/reply";
|
||||
export * from "./src/replyMessage";
|
||||
export * from "./src/repost";
|
||||
export * from "./src/threads";
|
||||
export * from "./src/trash";
|
||||
export * from "./src/world";
|
||||
export * from "./src/zap";
|
||||
export * from "./src/loader";
|
||||
export * from "./src/trending";
|
||||
export * from "./src/empty";
|
||||
export * from "./src/cmd";
|
||||
export * from "./src/verticalDots";
|
||||
export * from "./src/signal";
|
||||
export * from "./src/unverified";
|
||||
export * from "./src/settings";
|
||||
export * from "./src/logout";
|
||||
export * from "./src/follow";
|
||||
export * from "./src/unfollow";
|
||||
export * from "./src/reaction";
|
||||
export * from "./src/thread";
|
||||
export * from "./src/strangers";
|
||||
export * from "./src/download";
|
||||
export * from "./src/horizontalDots";
|
||||
export * from "./src/arrowRightCircle";
|
||||
export * from "./src/hashtag";
|
||||
export * from "./src/file";
|
||||
export * from "./src/share";
|
||||
export * from "./src/expand";
|
||||
export * from "./src/focus";
|
||||
export * from "./src/chevronUp";
|
||||
export * from "./src/secure";
|
||||
export * from "./src/verified";
|
||||
export * from "./src/mention";
|
||||
export * from "./src/groupFeeds";
|
||||
export * from "./src/article";
|
||||
export * from "./src/follows";
|
||||
export * from "./src/alby";
|
||||
export * from "./src/stars";
|
||||
export * from "./src/nwc";
|
||||
export * from "./src/timeline";
|
||||
export * from "./src/dots";
|
||||
export * from "./src/handArrowDown";
|
||||
export * from "./src/relay";
|
||||
export * from "./src/explore";
|
||||
export * from "./src/explore2";
|
||||
export * from "./src/home";
|
||||
export * from "./src/chats";
|
||||
export * from "./src/community";
|
||||
export * from "./src/heading1";
|
||||
export * from "./src/heading2";
|
||||
export * from "./src/heading3";
|
||||
export * from "./src/bold";
|
||||
export * from "./src/italic";
|
||||
export * from "./src/user";
|
||||
export * from "./src/advancedSettings";
|
||||
export * from "./src/info";
|
||||
export * from "./src/light";
|
||||
export * from "./src/dark";
|
||||
export * from "./src/system";
|
||||
export * from "./src/announcement";
|
||||
export * from "./src/depot";
|
||||
export * from "./src/search";
|
||||
export * from "./src/run";
|
||||
export * from "./src/gossip";
|
||||
export * from "./src/userAdd";
|
||||
export * from "./src/userRemove";
|
||||
export * from "./src/pin";
|
||||
14
packages/icons/package.json
Normal file
14
packages/icons/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@lume/icons",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tsconfig": "workspace:*",
|
||||
"@types/react": "^18.2.45",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
22
packages/icons/src/addWidget.tsx
Normal file
22
packages/icons/src/addWidget.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function AddWidgetIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M12.25 21.25h-6.5a1 1 0 01-1-1V3.75a1 1 0 011-1h12.5a1 1 0 011 1v8.5m-1 3v3m0 0v3m0-3h-3m3 0h3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
24
packages/icons/src/advancedSettings.tsx
Normal file
24
packages/icons/src/advancedSettings.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function AdvancedSettingsIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M13.75 7h-10m10 0a3.25 3.25 0 116.5 0 3.25 3.25 0 11-6.5 0zm6.5 10h-8m0 0a3.25 3.25 0 11-6.5 0m6.5 0a3.25 3.25 0 10-6.5 0m0 0h-2"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
74
packages/icons/src/alby.tsx
Normal file
74
packages/icons/src/alby.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function AlbyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="400"
|
||||
height="578"
|
||||
fill="none"
|
||||
viewBox="0 0 400 578"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M201.283 577.511c54.122 0 97.998-8.1 97.998-18.092 0-9.992-43.876-18.092-97.998-18.092-54.123 0-97.998 8.1-97.998 18.092 0 9.992 43.875 18.092 97.998 18.092z"
|
||||
opacity="0.1"
|
||||
></path>
|
||||
<path
|
||||
fill="#fff"
|
||||
stroke="#000"
|
||||
strokeWidth="15.077"
|
||||
d="M295.75 471.344c50.627 0 73.67-112.102 73.67-154.608 0-33.13-22.86-53.208-52.913-53.208-29.866 0-54.113 12.843-54.414 28.747-.001 41.971-7.388 179.069 33.657 179.069zM110.837 471.344c-50.627 0-73.67-112.102-73.67-154.608 0-33.13 22.86-53.208 52.913-53.208 29.866 0 54.113 12.843 54.414 28.747.001 41.971 7.388 179.069-33.657 179.069z"
|
||||
></path>
|
||||
<path
|
||||
fill="#FFDF6F"
|
||||
stroke="#000"
|
||||
strokeWidth="15"
|
||||
d="M68.83 303.262v-.002c-.054-.519.052-.82.16-1.016.127-.232.368-.508.773-.738.84-.477 2.014-.563 3.108.076 37.603 22.042 80.976 34.678 128.13 34.678 47.163 0 91.339-12.881 129.184-35.307 1.087-.645 2.26-.565 3.102-.091.407.229.65.504.779.737.109.197.216.499.163 1.019-5.854 58.014-37.322 105.977-79.618 128.054-13.969 7.293-23.576 19.962-32.013 31.089l-.452.597-.002.002c-6.857 9.046-13.063 17.147-20.648 23.116-7.584-5.969-13.791-14.07-20.648-23.116l-.001-.002-.452-.597c-8.437-11.127-18.043-23.796-32.013-31.089-42.135-21.992-73.523-69.677-79.551-127.41z"
|
||||
></path>
|
||||
<path
|
||||
fill="#000"
|
||||
stroke="#000"
|
||||
strokeWidth="15.077"
|
||||
d="M201.786 346.338c73.274 0 132.674-19.8 132.674-44.225s-59.4-44.225-132.674-44.225-132.674 19.8-132.674 44.225 59.4 44.225 132.674 44.225z"
|
||||
></path>
|
||||
<path
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="15.077"
|
||||
d="M95.245 376.491s65.44 22.112 107.546 22.112c42.105 0 107.546-22.112 107.546-22.112"
|
||||
></path>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M77 143c-16.569 0-30-13.431-30-30 0-16.569 13.431-30 30-30 16.569 0 30 13.431 30 30 0 16.569-13.431 30-30 30z"
|
||||
></path>
|
||||
<path stroke="#000" strokeWidth="15" d="M72 108.5l56 56"></path>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M322 143c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30-16.569 0-30 13.431-30 30 0 16.569 13.431 30 30 30z"
|
||||
></path>
|
||||
<path stroke="#000" strokeWidth="15" d="M327.5 108.5l-56 56"></path>
|
||||
<path
|
||||
fill="#FFDF6F"
|
||||
fillRule="evenodd"
|
||||
d="M85.516 292.019c-16.17-7.698-25.58-24.983-22.427-42.612C76.618 173.747 133 117 200.5 117c67.663 0 124.155 57.023 137.509 132.958 3.106 17.66-6.381 34.937-22.605 42.572C280.687 308.868 241.91 318 201 318c-41.335 0-80.493-9.323-115.484-25.981z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M70.472 250.728C83.544 177.62 137.582 124.5 200.5 124.5v-15c-72.082 0-130.809 60.375-144.794 138.587l14.766 2.641zM200.5 124.5c63.069 0 117.218 53.379 130.122 126.757l14.774-2.598C331.592 170.166 272.758 109.5 200.5 109.5v15zm111.71 161.244C278.472 301.621 240.783 310.5 201 310.5v15c42.037 0 81.902-9.386 117.597-26.183l-6.387-13.573zM201 310.5c-40.196 0-78.255-9.064-112.26-25.253l-6.448 13.544C118.269 315.918 158.526 325.5 201 325.5v-15zm129.622-59.243c2.49 14.159-5.091 28.219-18.412 34.487l6.387 13.573c19.128-9.002 30.52-29.497 26.799-50.658l-14.774 2.598zm-274.916-3.17c-3.778 21.124 7.524 41.629 26.586 50.704l6.447-13.544c-13.276-6.32-20.795-20.387-18.267-34.519l-14.766-2.641z"
|
||||
></path>
|
||||
<path
|
||||
fill="#000"
|
||||
fillRule="evenodd"
|
||||
d="M114.365 273.209c-13.015-5.301-20.736-19.149-16.226-32.459C112.047 199.704 152.618 170 200.5 170c47.882 0 88.453 29.704 102.361 70.75 4.51 13.31-3.211 27.158-16.226 32.459C260.053 284.035 230.973 290 200.5 290c-30.473 0-59.553-5.965-86.135-16.791z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M235 254c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20zM163.432 254.012c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
packages/icons/src/announcement.tsx
Normal file
18
packages/icons/src/announcement.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function AnnouncementIcon(props: JSX.IntrinsicElements['svg']) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M16.36 3.014A27.429 27.429 0 0 1 8.143 8.04l-4.67 1.825a5.126 5.126 0 0 0 1.7 6.34l1.631-.25m9.556-12.94c-.875.234-.824 3.262.114 6.764.938 3.501 2.408 6.15 3.283 5.915M16.36 3.014c.875-.234 2.345 2.414 3.284 5.915.938 3.502.989 6.53.113 6.765m0 0a27.428 27.428 0 0 0-8.595-.382m0 0L13.295 22H8.92l-2.116-6.044m4.358-.644c-.345.04-.69.085-1.034.138l-3.324.506" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
packages/icons/src/arrowLeft.tsx
Normal file
18
packages/icons/src/arrowLeft.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function ArrowLeftIcon(props: JSX.IntrinsicElements['svg']) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M8.83 6a30.23 30.23 0 0 0-5.62 5.406A.949.949 0 0 0 3 12m5.83 6a30.233 30.233 0 0 1-5.62-5.406A.949.949 0 0 1 3 12m0 0h18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
packages/icons/src/arrowRight.tsx
Normal file
18
packages/icons/src/arrowRight.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function ArrowRightIcon(props: JSX.IntrinsicElements['svg']) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M15.17 6a30.23 30.23 0 0 1 5.62 5.406c.14.174.21.384.21.594m-5.83 6a30.232 30.232 0 0 0 5.62-5.406A.949.949 0 0 0 21 12m0 0H3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
24
packages/icons/src/arrowRightCircle.tsx
Normal file
24
packages/icons/src/arrowRightCircle.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ArrowRightCircleIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M7.75 12h8M13 8.75l2.896 2.896a.5.5 0 010 .708L13 15.25M21.25 12a9.25 9.25 0 11-18.5 0 9.25 9.25 0 0118.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/article.tsx
Normal file
22
packages/icons/src/article.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ArticleIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M16.25 12V4.75a1 1 0 00-1-1H3.75a1 1 0 00-1 1v13a2.5 2.5 0 002.5 2.5H18.5M16.25 12v5.75a2.5 2.5 0 005 0V13a1 1 0 00-1-1h-4zm-9.5 3.75h5.5m-5.5-8h5.5v4.5h-5.5v-4.5z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
packages/icons/src/bell.tsx
Normal file
20
packages/icons/src/bell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function BellIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M16 18.25C15.3267 20.0159 13.7891 21.25 12 21.25C10.2109 21.25 8.67327 20.0159 8 18.25M20.5 18.25L18.9554 8.67345C18.4048 5.2596 15.458 2.75 12 2.75C8.54203 2.75 5.59523 5.2596 5.04461 8.67345L3.5 18.25H20.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/bold.tsx
Normal file
22
packages/icons/src/bold.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function BoldIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13 12H6m7 0a4 4 0 000-8H8a2 2 0 00-2 2v6m7 0h1a4 4 0 010 8H8a2 2 0 01-2-2v-6"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
packages/icons/src/cancel.tsx
Normal file
18
packages/icons/src/cancel.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function CancelIcon(props: JSX.IntrinsicElements['svg']) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="m6 18 6-6m0 0 6-6m-6 6L6 6m6 6 6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
packages/icons/src/chats.tsx
Normal file
21
packages/icons/src/chats.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ChatsIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M19.002 3a3 3 0 013 3v6a3 3 0 01-3 3h-1v1a3 3 0 01-3 3h-4.24l-4.274 2.374a1 1 0 01-1.486-.874V19a3 3 0 01-3-3v-6a3 3 0 013-3h1V6a3 3 0 013-3h10zm-11 4h7a3 3 0 013 3v3h1a1 1 0 001-1V6a1 1 0 00-1-1h-10a1 1 0 00-1 1v1z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
packages/icons/src/checkCircle.tsx
Normal file
23
packages/icons/src/checkCircle.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function CheckCircleIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM15.5805 9.97493C15.8428 9.65434 15.7955 9.18183 15.4749 8.91953C15.1543 8.65724 14.6818 8.70449 14.4195 9.02507L10.4443 13.8837L9.03033 12.4697C8.73744 12.1768 8.26256 12.1768 7.96967 12.4697C7.67678 12.7626 7.67678 13.2374 7.96967 13.5303L9.96967 15.5303C10.1195 15.6802 10.3257 15.7596 10.5374 15.7491C10.749 15.7385 10.9463 15.6389 11.0805 15.4749L15.5805 9.97493Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
24
packages/icons/src/chevronDown.tsx
Normal file
24
packages/icons/src/chevronDown.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ChevronDownIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 10L12 14L16 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
24
packages/icons/src/chevronRight.tsx
Normal file
24
packages/icons/src/chevronRight.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ChevronRightIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M10 16L14 12L10 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/chevronUp.tsx
Normal file
22
packages/icons/src/chevronUp.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ChevronUpIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M8 14l3.646-3.646a.5.5 0 01.708 0L16 14"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
packages/icons/src/cmd.tsx
Normal file
21
packages/icons/src/cmd.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function CommandIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="square"
|
||||
strokeWidth="1.5"
|
||||
d="M9.25 9.25V6.5A2.75 2.75 0 106.5 9.25h2.75zm0 0h5.5m-5.5 0v5.5m5.5-5.5V6.5a2.75 2.75 0 112.75 2.75h-2.75zm0 0v5.5m0 0h-5.5m5.5 0v2.75a2.75 2.75 0 102.75-2.75h-2.75zm-5.5 0v2.75a2.75 2.75 0 11-2.75-2.75h2.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
packages/icons/src/community.tsx
Normal file
19
packages/icons/src/community.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function CommunityIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.606 17.613C13.593 13.981 10.87 12 8 12s-5.594 1.98-6.608 5.613C.861 19.513 2.481 21 4.145 21h7.708c1.663 0 3.283-1.487 2.753-3.387zM3.999 7a4 4 0 118 0 4 4 0 01-8 0zM13.498 7.5a3.5 3.5 0 117 0 3.5 3.5 0 01-7 0zM14.194 12.773c1.046 1.136 1.86 2.59 2.339 4.303A4.501 4.501 0 0116.387 20h3.918c1.497 0 2.983-1.344 2.497-3.084-.883-3.168-3.268-4.916-5.8-4.916-.985 0-1.947.264-2.808.773z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
packages/icons/src/compose.tsx
Normal file
19
packages/icons/src/compose.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ComposeIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19.121 3.707a3 3 0 00-4.242 0l-12 12A3 3 0 002 17.828V20a1 1 0 001 1h2.172a3 3 0 002.12-.879l12-12a3 3 0 000-4.243l-.17-.171zM21.67 18.749a7.14 7.14 0 01-.93.827c-.58.43-1.503.968-2.574.968-1.006 0-1.878-.463-2.503-.796l-.066-.035c-.723-.384-1.168-.598-1.61-.598-1.057 0-1.732.558-2.26 1.116a1 1 0 11-1.454-1.373c.654-.692 1.824-1.743 3.714-1.743.986 0 1.85.46 2.468.79l.08.042c.717.38 1.172.598 1.631.598.429 0 .923-.234 1.384-.576a5.148 5.148 0 00.694-.624 1.01 1.01 0 011.41-.102c.476.412.418 1.076.017 1.506z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/copy.tsx
Normal file
22
packages/icons/src/copy.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function CopyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M15.25 15.25V21.25H2.75V8.75H8.75M8.75 15.25H21.25V2.75H8.75V15.25Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/dark.tsx
Normal file
22
packages/icons/src/dark.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function DarkIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M21.248 11.811a6.5 6.5 0 01-9.06-9.06 9.25 9.25 0 109.06 9.06z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
packages/icons/src/depot.tsx
Normal file
18
packages/icons/src/depot.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function DepotIcon(props: JSX.IntrinsicElements['svg']) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M20 5.7V12m0-6.3c0 1.491-3.582 2.7-8 2.7S4 7.191 4 5.7m16 0C20 4.209 16.418 3 12 3S4 4.209 4 5.7M20 12v6.131C20 19.716 16.418 21 12 21s-8-1.284-8-2.869V12m16 0c0 1.491-3.582 2.7-8 2.7S4 13.491 4 12m0 0V5.7M16 11h.01M16 17h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
packages/icons/src/dots.tsx
Normal file
20
packages/icons/src/dots.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function DotsPattern(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props}>
|
||||
<pattern
|
||||
id="pattern-circles"
|
||||
width="30"
|
||||
height="30"
|
||||
x="0"
|
||||
y="0"
|
||||
patternContentUnits="userSpaceOnUse"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="2" cy="2" r="1.626" fill="currentColor"></circle>
|
||||
</pattern>
|
||||
<rect width="100%" height="100%" x="0" y="0" fill="url(#pattern-circles)"></rect>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/download.tsx
Normal file
22
packages/icons/src/download.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function DownloadIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M20.25 14.75v4.5a1 1 0 01-1 1H4.75a1 1 0 01-1-1v-4.5M12 15V3.75M12 15l-3.5-3.5M12 15l3.5-3.5"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/edit.tsx
Normal file
22
packages/icons/src/edit.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function EditIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M13.25 6.25L17 2.5L21.5 7L17.75 10.75M13.25 6.25L2.75 16.75V21.25H7.25L17.75 10.75M13.25 6.25L17.75 10.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
61
packages/icons/src/empty.tsx
Normal file
61
packages/icons/src/empty.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function EmptyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="120"
|
||||
height="120"
|
||||
fill="none"
|
||||
viewBox="0 0 120 120"
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#clip0_110_63)">
|
||||
<path
|
||||
fill="#27272A"
|
||||
fillRule="evenodd"
|
||||
d="M60 120c33.137 0 60-26.863 60-60S93.137 0 60 0C45.133 0 39.482 17.832 29 26.787 16.119 37.792 0 41.73 0 60c0 33.137 26.863 60 60 60z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<g filter="url(#filter0_f_110_63)">
|
||||
<path
|
||||
fill="#18181B"
|
||||
fillRule="evenodd"
|
||||
d="M64 101c19.33 0 35-13.208 35-29.5S83.33 42 64 42c-8.672 0-11.969 8.767-18.083 13.17C38.403 60.58 29 62.517 29 71.5 29 87.792 44.67 101 64 101z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
fill="#3F3F46"
|
||||
fillRule="evenodd"
|
||||
d="M82.941 59H65.06C59.504 59 55 63.476 55 68.997v4.871c0 5.521 4.504 9.997 10.059 9.997h18.879l5.779 4.685a2.02 2.02 0 002.83-.286c.293-.356.453-.803.453-1.263V68.997C93 63.476 88.496 59 82.941 59z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="#D4D4D8"
|
||||
fillRule="evenodd"
|
||||
d="M41.161 39h32.678C81.659 39 88 45.408 88 53.314v12.864c0 7.905-6.34 14.314-14.161 14.314H41.547l-9.186 7.742a3.244 3.244 0 01-4.603-.422A3.325 3.325 0 0127 85.697V53.314C27 45.408 33.34 39 41.161 39z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_f_110_63"
|
||||
width="92"
|
||||
height="81"
|
||||
x="18"
|
||||
y="31"
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur result="effect1_foregroundBlur_110_63" stdDeviation="5.5" />
|
||||
</filter>
|
||||
<clipPath id="clip0_110_63">
|
||||
<path fill="#fff" d="M0 0H120V120H0z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/enter.tsx
Normal file
22
packages/icons/src/enter.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function EnterIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3.75 4.75V15H20.25M20.25 15L16.25 11M20.25 15L16.25 19"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/expand.tsx
Normal file
22
packages/icons/src/expand.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ExpandIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M13.75 3.75h5.75a.75.75 0 01.75.75v5.75m-16.5 3.5v5.75c0 .414.336.75.75.75h5.75M19.5 4.5L14 10m-4 4l-5.5 5.5"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
packages/icons/src/explore.tsx
Normal file
23
packages/icons/src/explore.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ExploreIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<g fill="currentColor" clipPath="url(#clip0_6331_51560)">
|
||||
<path d="M3.625 17.47c2.3 3.522 6.68 5.337 10.963 4.189 4.284-1.148 7.169-4.91 7.4-9.11-.35.192-.716.375-1.086.55-2.036.963-4.814 1.946-7.867 2.764-3.053.819-5.951 1.355-8.196 1.54-.408.033-.815.057-1.214.067zM9.411 2.34c4.311-1.155 8.717.69 11.004 4.254.532.007 1.015.05 1.42.141.273.062.566.158.825.32.264.165.557.443.672.873.14.52-.054.97-.242 1.256-.192.29-.46.545-.738.764-.563.443-1.363.898-2.305 1.343-1.898.897-4.556 1.844-7.53 2.64-2.973.797-5.749 1.307-7.841 1.479-1.038.085-1.959.092-2.668-.011-.35-.051-.71-.137-1.02-.293-.306-.153-.699-.446-.838-.966-.116-.43-.001-.818.145-1.093a2.66 2.66 0 01.555-.69c.305-.28.702-.559 1.158-.83.2-4.23 3.093-8.032 7.403-9.187z"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6331_51560">
|
||||
<path fill="#fff" d="M0 0H24V24H0z"></path>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
packages/icons/src/explore2.tsx
Normal file
21
packages/icons/src/explore2.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function Explore2Icon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M18.128 2.78c1.886-.538 3.63 1.205 3.09 3.091l-2.8 9.801a4 4 0 01-2.747 2.748l-9.801 2.8c-1.886.539-3.63-1.205-3.091-3.09l2.8-9.802a4 4 0 012.748-2.747l9.8-2.8zM9.498 12a2.5 2.5 0 115 0 2.5 2.5 0 01-5 0z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
packages/icons/src/eyeOff.tsx
Normal file
19
packages/icons/src/eyeOff.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function EyeOffIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M9.1654 4.42071C8.76876 4.5401 8.544 4.95841 8.66339 5.35505C8.78277 5.75169 9.20109 5.97645 9.59772 5.85706L9.1654 4.42071ZM22 12L22.671 12.3351C22.7763 12.1241 22.7763 11.8759 22.671 11.6649L22 12ZM19.1413 14.9666C18.8678 15.2776 18.8982 15.7515 19.2092 16.0251C19.5203 16.2986 19.9942 16.2682 20.2677 15.9571L19.1413 14.9666ZM3.28033 2.21967C2.98744 1.92678 2.51256 1.92678 2.21967 2.21967C1.92678 2.51256 1.92678 2.98744 2.21967 3.28033L3.28033 2.21967ZM2 11.9999L1.32902 11.6648C1.22366 11.8758 1.22366 12.124 1.32902 12.335L2 11.9999ZM17.4703 17.4703L18.0006 16.9399L17.4703 17.4703ZM20.7197 21.7803C21.0126 22.0732 21.4874 22.0732 21.7803 21.7803C22.0732 21.4874 22.0732 21.0126 21.7803 20.7197L20.7197 21.7803ZM10.2322 10.2322C10.5251 9.93934 10.5251 9.46447 10.2322 9.17157C9.93934 8.87868 9.46447 8.87868 9.17157 9.17157L10.2322 10.2322ZM14.8284 14.8284C15.1213 14.5355 15.1213 14.0607 14.8284 13.7678C14.5355 13.4749 14.0607 13.4749 13.7678 13.7678L14.8284 14.8284ZM9.59772 5.85706C13.745 4.60878 18.4769 6.624 21.329 12.3351L22.671 11.6649C19.5775 5.47055 14.1791 2.91165 9.1654 4.42071L9.59772 5.85706ZM20.2677 15.9571C21.1654 14.9364 21.9755 13.7277 22.671 12.3351L21.329 11.6649C20.6865 12.9515 19.9468 14.0507 19.1413 14.9666L20.2677 15.9571ZM2.21967 3.28033L5.99937 7.06003L7.06003 5.99937L3.28033 2.21967L2.21967 3.28033ZM2.67098 12.335C3.84083 9.99245 5.33197 8.27257 6.95699 7.14609L6.10242 5.91332C4.24158 7.20327 2.5948 9.13019 1.32902 11.6648L2.67098 12.335ZM5.99937 7.06003L16.9399 18.0006L18.0006 16.9399L7.06003 5.99937L5.99937 7.06003ZM16.9399 18.0006L20.7197 21.7803L21.7803 20.7197L18.0006 16.9399L16.9399 18.0006ZM1.32902 12.335C3.20469 16.0909 5.92036 18.5148 8.91701 19.5009C11.922 20.4898 15.1308 20.0045 17.8975 18.0866L17.043 16.8539C14.6436 18.5171 11.9221 18.9107 9.38589 18.0761C6.84135 17.2388 4.40494 15.1369 2.67098 11.6648L1.32902 12.335ZM12 14.5C10.6193 14.5 9.5 13.3807 9.5 12H8C8 14.2091 9.79086 16 12 16V14.5ZM9.5 12C9.5 11.3094 9.779 10.6855 10.2322 10.2322L9.17157 9.17157C8.44854 9.89461 8 10.8956 8 12H9.5ZM13.7678 13.7678C13.3145 14.221 12.6906 14.5 12 14.5V16C13.1044 16 14.1054 15.5515 14.8284 14.8284L13.7678 13.7678Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
packages/icons/src/eyeOn.tsx
Normal file
19
packages/icons/src/eyeOn.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function EyeOnIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M2 11.9999L1.32902 11.6648C1.22366 11.8758 1.22366 12.124 1.32902 12.335L2 11.9999ZM22 12L22.671 12.3351C22.7763 12.1241 22.7763 11.8759 22.671 11.6649L22 12ZM2.67098 12.335C4.9893 7.69273 8.55546 5.49997 12 5.5C15.4445 5.50003 19.0107 7.69284 21.329 12.3351L22.671 11.6649C20.1618 6.64058 16.1417 4.00003 12 4C7.85827 3.99997 3.83815 6.64046 1.32902 11.6648L2.67098 12.335ZM1.32902 12.335C3.83815 17.3593 7.85826 19.9999 12 19.9999C16.1417 20 20.1618 17.3595 22.671 12.3351L21.329 11.6649C19.0107 16.3072 15.4445 18.4999 12 18.4999C8.55547 18.4999 4.9893 16.3071 2.67098 11.6648L1.32902 12.335ZM14.5 12C14.5 13.3807 13.3807 14.5 12 14.5V16C14.2091 16 16 14.2091 16 12H14.5ZM12 14.5C10.6193 14.5 9.5 13.3807 9.5 12H8C8 14.2091 9.79086 16 12 16V14.5ZM9.5 12C9.5 10.6193 10.6193 9.5 12 9.5V8C9.79086 8 8 9.79086 8 12H9.5ZM12 9.5C13.3807 9.5 14.5 10.6193 14.5 12H16C16 9.79086 14.2091 8 12 8V9.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/feed.tsx
Normal file
22
packages/icons/src/feed.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function FeedIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M19.5 8.75V11.5M19.5 11.5V14.25M19.5 11.5H16.75M19.5 11.5H22.25M14.75 6.5C14.75 8.57107 13.0711 10.25 11 10.25C8.92893 10.25 7.25 8.57107 7.25 6.5C7.25 4.42893 8.92893 2.75 11 2.75C13.0711 2.75 14.75 4.42893 14.75 6.5ZM3.5 20.25C3.86894 16.3254 6.8098 13.25 11 13.25C15.1902 13.25 18.1311 16.3254 18.5 20.25H3.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
packages/icons/src/file.tsx
Normal file
21
packages/icons/src/file.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function FileIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.5"
|
||||
d="M12.75 3.25v5a1 1 0 001 1h5m-10 4h3.5m-3.5 4h6.5m-9.5-14.5h6.586a1 1 0 01.707.293l5.914 5.914a1 1 0 01.293.707V20.25a1 1 0 01-1 1H5.75a1 1 0 01-1-1V3.75a1 1 0 011-1z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
27
packages/icons/src/focus.tsx
Normal file
27
packages/icons/src/focus.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function FocusIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 13a3 3 0 013 3v3a3 3 0 01-3 3H5a3 3 0 01-3-3v-3a3 3 0 013-3h4z"
|
||||
></path>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20 6a1 1 0 00-1-1H6a1 1 0 00-1 1v4a1 1 0 11-2 0V6a3 3 0 013-3h13a3 3 0 013 3v7a3 3 0 01-3 3h-4a1 1 0 110-2h4a1 1 0 001-1V6z"
|
||||
></path>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17 7a1 1 0 011 1v3a1 1 0 11-2 0v-.586l-1.293 1.293a1 1 0 01-1.414-1.414L14.586 9H14a1 1 0 110-2h3z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/follow.tsx
Normal file
22
packages/icons/src/follow.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function FollowIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M11.85 13.251c-3.719.065-6.427 2.567-7.18 5.915-.13.575.338 1.084.927 1.084h6.901m-.647-6.999l.147-.001c.353 0 .696.022 1.03.064m-1.177-.063a7.889 7.889 0 00-1.852.249m3.028-.186c.334.042.658.104.972.186m-.972-.186a7.475 7.475 0 011.972.524m3.25 1.412v3m0 0v3m0-3h-3m3 0h3m-5.5-11.75a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/follows.tsx
Normal file
22
packages/icons/src/follows.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function FollowsIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M17.75 19.25h3.673c.581 0 1.045-.496.947-1.07-.531-3.118-2.351-5.43-5.37-5.43-.446 0-.866.05-1.26.147M11.25 7a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0zm8.5.5a2.75 2.75 0 11-5.5 0 2.75 2.75 0 015.5 0zM1.87 19.18c.568-3.68 2.647-6.43 6.13-6.43 3.482 0 5.561 2.75 6.13 6.43.088.575-.375 1.07-.956 1.07H2.825c-.58 0-1.043-.495-.955-1.07z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
packages/icons/src/gossip.tsx
Normal file
20
packages/icons/src/gossip.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export function GossipIcon(props: JSX.IntrinsicElements['svg']) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M8.577 5.764c.46-.893.69-1.34.999-1.485a1 1 0 0 1 .848 0c.31.145.539.592.999 1.485l1.191 2.315c.08.154.12.232.171.3a1 1 0 0 0 .157.165c.065.055.14.098.291.186l2.386 1.387c.782.454 1.173.681 1.305.977a1 1 0 0 1 0 .812c-.132.296-.523.523-1.305.977l-2.386 1.387c-.15.088-.226.131-.291.186-.059.049-.111.104-.157.165-.051.068-.091.146-.17.3l-1.192 2.315c-.46.893-.69 1.34-.999 1.485a1 1 0 0 1-.848 0c-.309-.145-.539-.592-.999-1.485l-1.191-2.315c-.08-.154-.12-.232-.171-.3a1.003 1.003 0 0 0-.157-.165 2.099 2.099 0 0 0-.291-.186l-2.386-1.387c-.782-.454-1.173-.681-1.305-.977a1 1 0 0 1 0-.812c.132-.296.523-.523 1.305-.977L6.767 8.73c.15-.088.226-.131.291-.186a1 1 0 0 0 .157-.165c.051-.068.091-.146.17-.3l1.192-2.315Z" />
|
||||
<path d="M17.46 19.406c-.254-.317-.381-.476-.429-.659a.993.993 0 0 1 0-.494c.048-.183.175-.342.429-.66l.815-1.019c.254-.317.38-.475.527-.535a.52.52 0 0 1 .396 0c.146.06.273.218.527.535l.816 1.02c.253.317.38.476.428.659a.993.993 0 0 1 0 .494c-.048.183-.175.342-.428.66l-.816 1.019c-.254.317-.38.475-.527.535a.52.52 0 0 1-.396 0c-.146-.06-.273-.218-.527-.535l-.815-1.02Z" />
|
||||
<path d="M18.23 4.362c-.127-.126-.19-.19-.214-.263a.32.32 0 0 1 0-.198c.024-.073.087-.137.214-.263l.408-.408c.126-.127.19-.19.263-.214a.32.32 0 0 1 .198 0c.073.023.137.087.264.214l.407.408c.127.126.19.19.214.263a.319.319 0 0 1 0 .198c-.023.073-.087.137-.214.263l-.407.408c-.127.127-.19.19-.264.214a.32.32 0 0 1-.198 0c-.073-.023-.137-.087-.263-.214l-.408-.408Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/groupFeeds.tsx
Normal file
22
packages/icons/src/groupFeeds.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function GroupFeedsIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M14.425 13.18c3.361-1.396 7.598.605 8.454 6.003.09.573-.372 1.067-.952 1.067H16.75M10.75 7a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0zm9 0a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0zm-6.966 13.25H2.072c-.58 0-1.042-.497-.951-1.07 1.362-8.573 11.252-8.573 12.614 0 .091.573-.371 1.07-.951 1.07z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
29
packages/icons/src/handArrowDown.tsx
Normal file
29
packages/icons/src/handArrowDown.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function HandArrowDownIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="130"
|
||||
height="130"
|
||||
fill="none"
|
||||
viewBox="0 0 130 130"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M59.95 53.86c.109.564.326 1.126.543 1.687.976 3.028 2.062 6.56 4.015 9.168 2.604 3.419 5.534 4.74 8.463 4.711 4.123-.044 8.355-3.086 10.959-7.254 2.604-4.13 3.69-9.301 2.062-13.466-1.085-2.855-3.472-5.291-7.596-6.62-4.665-1.532-9.114-.278-13.02 2.422-1.52 1.014-2.821 2.233-4.123 3.577-.326-1.768-.652-3.553-.977-5.363-.869-5.925-1.086-12.154-.76-18.127.325-7.901 1.954-15.46 4.992-22.763.217-.674-.109-1.447-.76-1.725-.651-.276-1.41.045-1.736.72-3.146 7.59-4.883 15.446-5.209 23.658-.217 6.146-.107 12.555.87 18.653.433 2.526.867 5.005 1.41 7.457-2.496 3.216-4.45 6.789-5.643 9.827-7.596 20.325 2.278 47.693 12.044 66.11a1.26 1.26 0 001.737.549c.65-.342.867-1.141.542-1.786-9.44-17.825-19.206-44.269-11.828-63.94.868-2.313 2.28-4.968 4.015-7.494zm2.062-2.63c.217 1.161.542 2.315.976 3.465.977 2.776 1.845 6.037 3.58 8.426 1.954 2.581 4.125 3.684 6.295 3.663 3.472-.036 6.727-2.651 8.789-6.022 2.17-3.409 3.255-7.656 1.845-11.094-.868-2.214-2.713-4.04-5.86-5.07-3.906-1.261-7.595-.142-10.742 2.084-1.844 1.238-3.472 2.816-4.883 4.548z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M66.784 126.734c-.542-.384-1.085-.956-1.628-1.53-1.085-1.104-2.061-2.274-2.93-2.89-5.208-3.796-11.501-8.282-17.686-10.272-.65-.225-1.41.156-1.627.85-.217.694.108 1.439.867 1.664 5.86 1.909 11.828 6.255 16.928 9.898.868.666 2.061 2.077 3.146 3.207.977.959 1.954 1.724 2.93 2.002.543.196 1.52-.021 2.17-1.243.868-1.732 1.629-6.541 1.737-7.007 1.085-4.715 2.17-8.865 6.293-11.842.543-.429.651-1.257.326-1.846-.434-.589-1.303-.719-1.845-.289-4.666 3.394-6.185 8.041-7.379 13.416 0 .343-.434 3.218-1.085 5.215-.108.216-.108.454-.217.667z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/hashtag.tsx
Normal file
22
packages/icons/src/hashtag.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function HashtagIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M8.75 3.75l-2 16.5m10.5-16.5l-2 16.5M3.75 7.75h16.5m0 8.5H3.75"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/heading1.tsx
Normal file
22
packages/icons/src/heading1.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function Heading1Icon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M3 5v7m0 7v-7m10-7v7m0 7v-7m0 0H3m15 1.5l3-2.5v8"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
22
packages/icons/src/heading2.tsx
Normal file
22
packages/icons/src/heading2.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function Heading2Icon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13 5v7m0 0v7m0-7H3m0-7v7m0 0v7m19 0h-4l3.495-4.432A2 2 0 0022 13.24V13a2 2 0 00-3.732-1"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user