Merge pull request #58 from luminous-devs/v1.1.0

prepare v1.1.0
This commit is contained in:
Ren Amamiya 2023-07-22 17:35:37 +07:00 committed by GitHub
commit b66e11433f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
115 changed files with 5231 additions and 2427 deletions

View File

@ -1,7 +1,7 @@
{
"name": "lume",
"private": true,
"version": "1.0.1",
"version": "1.1.0",
"scripts": {
"dev": "vite",
"build": "vite build",
@ -17,67 +17,78 @@
},
"dependencies": {
"@headlessui/react": "^1.7.15",
"@nostr-dev-kit/ndk": "^0.7.5",
"@nostr-dev-kit/ndk": "^0.7.7",
"@nostr-fetch/adapter-ndk": "^0.11.0",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.29.19",
"@tanstack/react-query-devtools": "^4.29.19",
"@tanstack/react-query": "^4.32.0",
"@tanstack/react-query-devtools": "^4.32.0",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.4.0",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-mention": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.9",
"destr": "^1.2.2",
"framer-motion": "^10.12.18",
"framer-motion": "^10.13.0",
"get-urls": "^11.0.0",
"html-to-text": "^9.0.5",
"immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0",
"nostr-tools": "^1.12.1",
"nostr-fetch": "^0.12.1",
"nostr-tools": "^1.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.1",
"react-hook-form": "^7.45.2",
"react-hotkeys-hook": "^4.4.1",
"react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-router-dom": "^6.14.1",
"react-router-dom": "^6.14.2",
"react-string-replace": "^1.1.1",
"react-virtuoso": "^4.3.11",
"slate": "^0.94.1",
"slate-history": "^0.93.0",
"slate-react": "^0.94.2",
"tailwind-merge": "^1.13.2",
"react-virtuoso": "^4.4.1",
"remark-gfm": "^3.0.1",
"tailwind-merge": "^1.14.0",
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
"tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1",
"tippy.js": "^6.3.7",
"zustand": "^4.3.9"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/node": "^18.16.19",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/node": "^18.16.20",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.44.0",
"eslint": "^8.45.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react": "^7.33.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"postcss": "^8.4.25",
"postcss": "^8.4.27",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"prop-types": "^15.8.1",
"tailwindcss": "^3.3.2",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.5",
"vite": "^4.4.2",
"vite": "^4.4.6",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0"
}

File diff suppressed because it is too large Load Diff

463
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "lume"
version = "1.0.1"
version = "1.1.0"
description = "nostr client"
authors = ["Ren Amamiya"]
license = ""
@ -16,10 +16,11 @@ tauri-build = { version = "1.2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = [ "path-all", "fs-read-dir", "fs-read-file", "clipboard-read-text", "clipboard-write-text", "dialog-open", "http-all", "http-multipart", "notification-all", "os-all", "process-relaunch", "shell-open", "system-tray", "updater", "window-close", "window-start-dragging"] }
tauri = { version = "1.2", features = [ "fs-write-file", "window-create", "path-all", "fs-read-dir", "fs-read-file", "clipboard-read-text", "clipboard-write-text", "dialog-open", "http-all", "http-multipart", "notification-all", "os-all", "process-relaunch", "shell-open", "system-tray", "updater", "window-close", "window-start-dragging"] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
sqlx-cli = {version = "0.7.0", default-features = false, features = ["sqlite"] }
rust-argon2 = "1.0"
rand = "0.8.5"

View File

@ -0,0 +1,6 @@
-- Add migration script here
DROP TABLE IF EXISTS blacklist;
DROP TABLE IF EXISTS channel_messages;
DROP TABLE IF EXISTS channels;

View File

@ -107,6 +107,12 @@ fn main() {
sql: include_str!("../migrations/20230619082415_add_replies.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230718072634,
description: "clean up",
sql: include_str!("../migrations/20230718072634_clean_up_old_tables.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),
@ -115,8 +121,8 @@ fn main() {
tauri_plugin_stronghold::Builder::new(|password| {
let config = argon2::Config {
lanes: 2,
mem_cost: 50_000,
time_cost: 30,
mem_cost: 10_000,
time_cost: 10,
thread_mode: argon2::ThreadMode::from_threads(2),
variant: argon2::Variant::Argon2id,
..Default::default()
@ -144,6 +150,7 @@ fn main() {
.emit_all("single-instance", Payload { args: argv, cwd })
.unwrap();
}))
.plugin(tauri_plugin_upload::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "Lume",
"version": "1.0.1"
"version": "1.1.0"
},
"tauri": {
"allowlist": {
@ -28,6 +28,7 @@
"all": false,
"readFile": true,
"readDir": true,
"writeFile": true,
"scope": [
"$APPDATA/*",
"$DATA/*",
@ -62,7 +63,8 @@
},
"window": {
"startDragging": true,
"close": true
"close": true,
"create": true
},
"process": {
"all": false,

View File

@ -24,7 +24,7 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink rounded-md">
<Image
src={user.picture || user.image}
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-10 w-10 rounded-md object-cover"
@ -32,10 +32,10 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-100">
{user.name || user.displayName || user.display_name}
{user?.name || user?.displayName || user?.display_name}
</span>
<span className="text-base leading-tight text-zinc-400">
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
{user?.nip05?.toLowerCase() || shortenKey(pubkey)}
</span>
</div>
</div>

View File

@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { BaseDirectory, writeTextFile } from '@tauri-apps/api/fs';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
@ -19,6 +20,7 @@ export function CreateStep1Screen() {
const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey);
@ -34,6 +36,17 @@ export function CreateStep1Screen() {
}
};
const download = async () => {
await writeTextFile(
'lume-keys.txt',
`Public key: ${pubkey}\nPrivate key: ${privkey}`,
{
dir: BaseDirectory.Download,
}
);
setDownloaded(true);
};
const account = useMutation({
mutationFn: (data: {
npub: string;
@ -68,9 +81,7 @@ export function CreateStep1Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Lume is auto-generated key for you
</h1>
<h1 className="text-xl font-semibold text-zinc-100">Save your access key!</h1>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
@ -110,14 +121,26 @@ export function CreateStep1Screen() {
)}
</button>
</div>
<div className="mt-2 text-sm text-zinc-500">
<p>
Your private key is your password. If you lose this key, you will lose
access to your account! Copy it and keep it in a safe place. There is no way
to reset your private key.
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'I have saved my key, continue →'
)}
</Button>
<Button preset="large-alt" onClick={() => download()}>
{downloaded ? 'Saved in Download folder' : 'Download'}
</Button>
</div>
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</Button>
</div>
</div>
);

View File

@ -32,12 +32,9 @@ export function CreateStep2Screen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const [privkey, setPassword] = useStronghold((state) => [
state.privkey,
state.setPassword,
]);
const pubkey = useOnboarding((state) => state.privkey);
const privkey = useStronghold((state) => state.privkey);
const pubkey = useOnboarding((state) => state.pubkey);
const { save } = useSecureStorage();
@ -60,9 +57,6 @@ export function CreateStep2Screen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
// save privkey to secure storage
await save(pubkey, privkey, data.password);

View File

@ -137,7 +137,7 @@ export function CreateStep5Screen() {
};
const update = useMutation({
mutationFn: (follows: any) => {
mutationFn: (follows: string[]) => {
return updateAccount('follows', follows, account.pubkey);
},
onSuccess: () => {

View File

@ -32,11 +32,8 @@ export function ImportStep2Screen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const [privkey, setPassword] = useStronghold((state) => [
state.privkey,
state.setPassword,
]);
const privkey = useStronghold((state) => state.privkey);
const pubkey = useOnboarding((state) => state.pubkey);
const { save } = useSecureStorage();
@ -60,9 +57,6 @@ export function ImportStep2Screen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
// save privkey to secure storage
await save(pubkey, privkey, data.password);
@ -115,9 +109,9 @@ export function ImportStep2Screen() {
</div>
<div className="text-sm text-zinc-500">
<p>
Password is use to secure your key store in local machine, when you move
to other clients, you just need to copy your private key as nsec or
hexstring
Password is use to unlock app and secure your key store in local machine.
When you move to other clients, you just need to copy your private key as
nsec or hexstring
</p>
</div>
<span className="text-sm text-red-400">

View File

@ -33,13 +33,10 @@ const resolver: Resolver<FormValues> = async (values) => {
export function MigrateScreen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const [setPassword, setPrivkey] = useStronghold((state) => [
state.setPassword,
state.setPrivkey,
]);
const { account } = useAccount();
const { save } = useSecureStorage();
@ -63,9 +60,6 @@ export function MigrateScreen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
// load private in secure storage
try {
// save privkey to secure storage

View File

@ -21,8 +21,7 @@ export function OnboardingScreen() {
// publish event
publish({
content:
'Running Lume, fighting for better future, join us here: https://lume.nu',
content: 'Running Lume, join with me: https://lume.nu',
kind: 1,
tags: [],
});
@ -37,15 +36,16 @@ export function OnboardingScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<div className="mb-4 text-center">
<h1 className="mb-2 text-xl font-semibold text-zinc-100">
👋 Hello, welcome you to Lume
</h1>
<p className="text-sm text-zinc-300">
You&apos;re a part of better future that we&apos;re fighting
You&apos;re a part of Nostr community now
</p>
<p className="text-sm text-zinc-300">
If Lume gets your attention, please help us spread via button below
If Lume gets your attention, please help us spread it and don&apos;t forget
invite your friend join with you, we can have fun togother
</p>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
@ -54,18 +54,15 @@ export function OnboardingScreen() {
<User pubkey={account.pubkey} time={Math.floor(Date.now() / 1000)} />
)}
<div className="-mt-6 select-text whitespace-pre-line break-words pl-[49px] text-base text-zinc-100">
<p>Running Lume, fighting for better future</p>
<p>
join us here:{' '}
<a
href="https://lume.nu"
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
target="_blank"
rel="noreferrer"
>
https://lume.nu
</a>
</p>
<p>Running Lume, join with me</p>
<a
href="https://lume.nu"
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
target="_blank"
rel="noreferrer"
>
https://lume.nu
</a>
</div>
</div>
</div>
@ -84,16 +81,16 @@ export function OnboardingScreen() {
) : (
<>
<span className="w-5" />
<span>Publish</span>
<span>Spread</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<Link
to="/"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-zinc-800 px-6 font-medium text-zinc-300 hover:bg-zinc-900"
>
Skip for now
Skip
</Link>
</div>
</div>

View File

@ -29,13 +29,10 @@ const resolver: Resolver<FormValues> = async (values) => {
export function UnlockScreen() {
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const [setPrivkey, setPassword] = useStronghold((state) => [
state.setPrivkey,
state.setPassword,
]);
const { account } = useAccount();
const { load } = useSecureStorage();
@ -59,9 +56,6 @@ export function UnlockScreen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
// load private in secure storage
try {
const privkey = await load(account.pubkey, data.password);
@ -99,7 +93,7 @@ export function UnlockScreen() {
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
className="relative w-full rounded-lg bg-zinc-800 py-3 text-center text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
<button
type="button"

View File

@ -6,7 +6,10 @@ import { ChatsListSelfItem } from '@app/chat/components/self';
import { getChats } from '@libs/storage';
import { StrangersIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
import { compactNumber } from '@utils/number';
export function ChatsList() {
const { account } = useAccount();
@ -15,11 +18,7 @@ export function ChatsList() {
data: chats,
isFetching,
} = useQuery(['chats'], async () => {
const chats = await getChats();
const sorted = chats.sort(
(a, b) => parseInt(a.new_messages) - parseInt(b.new_messages)
);
return sorted;
return await getChats();
});
if (status === 'loading') {
@ -39,7 +38,6 @@ export function ChatsList() {
return (
<div className="flex flex-col">
<NewMessageModal />
{account ? (
<ChatsListSelfItem data={account} />
) : (
@ -48,11 +46,25 @@ export function ChatsList() {
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
)}
{chats.map((item) => {
{chats.follows.map((item) => {
if (account.pubkey !== item.sender_pubkey) {
return <ChatsListItem key={item.sender_pubkey} data={item} />;
}
})}
<button
type="button"
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<StrangersIcon className="h-3 w-3 text-zinc-200" />
</div>
<div>
<h5 className="font-medium text-zinc-400">
{compactNumber.format(chats.unknown)} strangers
</h5>
</div>
</button>
<NewMessageModal />
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />

View File

@ -36,7 +36,7 @@ export function NewMessageModal() {
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<PlusIcon width={12} height={12} className="text-zinc-500" />
<PlusIcon className="h-3 w-3 text-zinc-200" />
</div>
<div>
<h5 className="font-medium text-zinc-400">New chat</h5>

View File

@ -1,12 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { useLiveThread } from '@app/space/hooks/useLiveThread';
import { getNoteByID } from '@libs/storage';
import { Kind1 } from '@shared/notes/contents/kind1';
import { Kind1063 } from '@shared/notes/contents/kind1063';
import { NoteMetadata } from '@shared/notes/metadata';
import { NoteReplyForm } from '@shared/notes/replies/form';
import { RepliesList } from '@shared/notes/replies/list';
@ -14,16 +9,12 @@ import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useAccount } from '@utils/hooks/useAccount';
import { parser } from '@utils/parser';
import { useEvent } from '@utils/hooks/useEvent';
export function NoteScreen() {
const { id } = useParams();
const { account } = useAccount();
const { status, data } = useQuery(['thread', id], async () => {
const res = await getNoteByID(id);
res['content'] = parser(res);
return res;
});
const { status, data } = useEvent(id);
useLiveThread(id);
@ -41,9 +32,7 @@ export function NoteScreen() {
<div className="rounded-md bg-zinc-900 px-5 pt-5">
<User pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3">
{data.kind === 1 && <Kind1 content={data.content} />}
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
<NoteMetadata id={data.event_id || id} eventPubkey={data.pubkey} />
<NoteMetadata id={data.event_id || id} />
</div>
</div>
<div className="mt-3 rounded-md bg-zinc-900">
@ -52,7 +41,7 @@ export function NoteScreen() {
</div>
)}
<div className="px-3">
<RepliesList parent_id={id} />
<RepliesList id={id} />
</div>
</div>
</div>

View File

@ -1,53 +1,67 @@
import { NDKFilter } from '@nostr-dev-kit/ndk';
import { useEffect, useRef } from 'react';
import { NDKUser } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { prefetchEvents } from '@libs/ndk/utils';
import {
countTotalNotes,
createChat,
createNote,
getLastLogin,
updateAccount,
updateLastLogin,
} from '@libs/storage';
import { LoaderIcon, LumeIcon } from '@shared/icons';
import { LoaderIcon } from '@shared/icons';
import { dateToUnix, getHourAgo } from '@utils/date';
import { nHoursAgo } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
const totalNotes = await countTotalNotes();
const lastLogin = await getLastLogin();
export function Root() {
const now = useRef(new Date());
const navigate = useNavigate();
const { ndk } = useNDK();
const { ndk, relayUrls, fetcher } = useNDK();
const { status, account } = useAccount();
async function getFollows() {
const authors: string[] = [];
const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows();
follows.forEach((follow: NDKUser) => {
authors.push(nip19.decode(follow.npub).data as string);
});
// update follows in db
await updateAccount('follows', authors, account.pubkey);
return authors;
}
async function fetchNotes() {
try {
const follows = JSON.parse(account.follows);
const follows = await getFollows();
if (follows.length > 0) {
let since: number;
if (totalNotes === 0 || lastLogin === 0) {
since = dateToUnix(getHourAgo(48, now.current));
since = nHoursAgo(48);
} else {
since = lastLogin;
}
const filter: NDKFilter = {
kinds: [1, 6],
authors: follows,
since: since,
};
const events = await prefetchEvents(ndk, filter);
for (const event of events) {
const events = fetcher.allEventsIterator(
relayUrls,
{ kinds: [1], authors: follows },
{ since: since },
{ skipVerification: true }
);
for await (const event of events) {
await createNote(
event.id,
event.pubkey,
@ -67,20 +81,23 @@ export function Root() {
async function fetchChats() {
try {
const sendFilter: NDKFilter = {
kinds: [4],
authors: [account.pubkey],
since: lastLogin,
};
const sendMessages = await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [4],
authors: [account.pubkey],
},
{ since: lastLogin }
);
const receiveFilter: NDKFilter = {
kinds: [4],
'#p': [account.pubkey],
since: lastLogin,
};
const sendMessages = await prefetchEvents(ndk, sendFilter);
const receiveMessages = await prefetchEvents(ndk, receiveFilter);
const receiveMessages = await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [4],
'#p': [account.pubkey],
},
{ since: lastLogin }
);
const events = [...sendMessages, ...receiveMessages];
for (const event of events) {
@ -158,27 +175,24 @@ export function Root() {
}, [status]);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
<div className="relative h-full overflow-hidden">
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent"
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
/>
<div className="relative flex h-full flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2">
<LumeIcon className="h-16 w-16 text-black dark:text-zinc-100" />
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
<div className="flex flex-col items-center justify-center gap-4">
<LoaderIcon className="h-6 w-6 animate-spin text-zinc-100" />
<div className="text-center">
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
Here&apos;s an interesting fact:
<h3 className="text-lg font-semibold leading-tight text-zinc-100">
Prefetching data...
</h3>
<p className="font-medium text-zinc-300 dark:text-zinc-600">
Bitcoin and Nostr can be used by anyone, and no one can stop you!
<p className="text-zinc-600">
This may take a few seconds, please don&apos;t close app.
</p>
</div>
</div>
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
</div>
</div>

View File

@ -12,7 +12,7 @@ import { createBlock } from '@libs/storage';
import { CancelIcon, CheckCircleIcon, CommandIcon, LoaderIcon } from '@shared/icons';
import { DEFAULT_AVATAR } from '@stores/constants';
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
import { ADD_FEEDBLOCK_SHORTCUT } from '@stores/shortcuts';
import { useAccount } from '@utils/hooks/useAccount';
@ -38,7 +38,7 @@ export function AddFeedBlock() {
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => openModal());
const block = useMutation({
mutationFn: (data: any) => {
mutationFn: (data: { kind: number; title: string; content: string }) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
@ -53,7 +53,7 @@ export function AddFeedBlock() {
formState: { isDirty, isValid },
} = useForm();
const onSubmit = (data: any) => {
const onSubmit = (data: { kind: number; title: string; content: string }) => {
setLoading(true);
selected.forEach((item, index) => {
@ -64,7 +64,7 @@ export function AddFeedBlock() {
// insert to database
block.mutate({
kind: 1,
kind: BLOCK_KINDS.feed,
title: data.title,
content: JSON.stringify(selected),
});
@ -205,7 +205,7 @@ export function AddFeedBlock() {
{status === 'loading' ? (
<p>Loading...</p>
) : (
JSON.parse(account.follows).map((follow) => (
JSON.parse(account.follows as string).map((follow) => (
<Combobox.Option
key={follow}
value={follow}

View File

@ -1,5 +1,4 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { open } from '@tauri-apps/api/dialog';
import { Body, fetch } from '@tauri-apps/api/http';
@ -7,18 +6,15 @@ import { Fragment, useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNDK } from '@libs/ndk/provider';
import { createBlock } from '@libs/storage';
import { CancelIcon, CommandIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
import { ADD_IMAGEBLOCK_SHORTCUT } from '@stores/shortcuts';
import { createBlobFromFile } from '@utils/createBlobFromFile';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
import { usePublish } from '@utils/hooks/usePublish';
export function AddImageBlock() {
@ -29,9 +25,6 @@ export function AddImageBlock() {
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState('');
const { ndk } = useNDK();
const { account } = useAccount();
const tags = useRef(null);
const openModal = () => {
@ -101,7 +94,7 @@ export function AddImageBlock() {
};
const block = useMutation({
mutationFn: (data: any) => {
mutationFn: (data: { kind: number; title: string; content: string }) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
@ -109,14 +102,14 @@ export function AddImageBlock() {
},
});
const onSubmit = async (data: any) => {
const onSubmit = async (data: { kind: number; title: string; content: string }) => {
setLoading(true);
// publish file metedata
await publish({ content: data.title, kind: 1063, tags: tags.current });
// mutate
block.mutate({ kind: 0, title: data.title, content: data.content });
block.mutate({ kind: BLOCK_KINDS.image, title: data.title, content: data.content });
setLoading(false);
// reset form

View File

@ -1,18 +1,19 @@
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useCallback, useEffect, useRef } from 'react';
import { getNotesByAuthors, removeBlock } from '@libs/storage';
import { getNotesByAuthors } from '@libs/storage';
import { Note } from '@shared/notes/note';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { Block, LumeEvent } from '@utils/types';
const ITEM_PER_PAGE = 10;
export function FeedBlock({ params }: { params: any }) {
const queryClient = useQueryClient();
export function FeedBlock({ params }: { params: Block }) {
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['newsfeed', params.content],
@ -22,9 +23,9 @@ export function FeedBlock({ params }: { params: any }) {
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const notes = data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
@ -46,29 +47,74 @@ export function FeedBlock({ params }: { params: any }) {
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
const renderItem = useCallback(
(index: string | number) => {
const note: LumeEvent = notes[index];
if (!note) return;
switch (note.kind) {
case 1: {
const root = note.tags.find((el) => el[3] === 'root')?.[1];
const reply = note.tags.find((el) => el[3] === 'reply')?.[1];
if (root || reply) {
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteThread event={note} root={root} reply={reply} />
</div>
);
} else {
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={note} skipMetadata={false} />
</div>
);
}
}
case 6:
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<Repost key={note.event_id} event={note} />
</div>
);
case 1063:
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1063 key={note.event_id} event={note} />
</div>
);
default:
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKindUnsupport key={note.event_id} event={note} />
</div>
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const renderItem = (index: string | number) => {
const note = notes[index];
if (!note) return;
return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} />
</div>
);
};
[notes]
);
return (
<div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<TitleBar id={params.id} title={params.title} />
<div
ref={parentRef}
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
@ -105,9 +151,7 @@ export function FeedBlock({ params }: { params: any }) {
}px)`,
}}
>
{rowVirtualizer
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}

View File

@ -1,18 +1,21 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useNewsfeed } from '@app/space/hooks/useNewsfeed';
import { getNotes } from '@libs/storage';
import { Note } from '@shared/notes/note';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { useNote } from '@stores/note';
import { LumeEvent } from '@utils/types';
const ITEM_PER_PAGE = 10;
export function FollowingBlock() {
@ -33,7 +36,7 @@ export function FollowingBlock() {
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const notes = data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
@ -66,19 +69,76 @@ export function FollowingBlock() {
toggleHasNewNote(false);
};
const renderItem = (index: string | number) => {
const note = notes[index];
if (!note) return;
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<Note event={note} />
</div>
);
};
const renderItem = useCallback(
(index: string | number) => {
const note: LumeEvent = notes[index];
if (!note) return;
switch (note.kind) {
case 1: {
let root: string;
let reply: string;
if (note.tags?.[0]?.[0] === 'e' && !note.tags?.[0]?.[3]) {
root = note.tags[0][1];
} else {
root = note.tags.find((el) => el[3] === 'root')?.[1];
reply = note.tags.find((el) => el[3] === 'reply')?.[1];
}
if (root || reply) {
return (
<div
key={(root || reply) + (note.event_id || note.id)}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteThread event={note} root={root} reply={reply} />
</div>
);
} else {
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={note} skipMetadata={false} />
</div>
);
}
}
case 6:
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<Repost key={note.event_id} event={note} />
</div>
);
case 1063:
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1063 key={note.event_id} event={note} />
</div>
);
default:
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKindUnsupport key={note.event_id} event={note} />
</div>
);
}
},
[notes]
);
return (
<div className="relative w-[400px] shrink-0 border-r border-zinc-900">
@ -138,9 +198,7 @@ export function FollowingBlock() {
}px)`,
}}
>
{rowVirtualizer
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}

View File

@ -0,0 +1,87 @@
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { nHoursAgo } from '@utils/date';
import { Block, LumeEvent } from '@utils/types';
export function HashtagBlock({ params }: { params: Block }) {
const { relayUrls, fetcher } = useNDK();
const { status, data } = useQuery(['hashtag', params.content], async () => {
const events = (await fetcher.fetchAllEvents(
relayUrls,
{ kinds: [1], '#t': [params.content] },
{ since: nHoursAgo(48) }
)) as unknown as LumeEvent[];
return events;
});
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: data ? data.length : 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
return (
<div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar id={params.id} title={params.title + ' in 48 hours ago'} />
<div
ref={parentRef}
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
style={{ contain: 'strict' }}
>
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-zinc-300">
No new posts about this hashtag in 48 hours ago
</p>
</div>
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={data[virtualRow.index]} />
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -1,23 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { removeBlock } from '@libs/storage';
import { CancelIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
export function ImageBlock({ params }: { params: any }) {
const queryClient = useQueryClient();
import { useBlock } from '@utils/hooks/useBlock';
import { Block } from '@utils/types';
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
export function ImageBlock({ params }: { params: Block }) {
const { remove } = useBlock();
return (
<div className="flex h-full w-[350px] shrink-0 flex-col justify-between border-r border-zinc-900">
@ -27,7 +17,7 @@ export function ImageBlock({ params }: { params: any }) {
<h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
<button
type="button"
onClick={() => block.mutate(params.id)}
onClick={() => remove.mutate(params.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white/30 backdrop-blur-lg"
>
<CancelIcon width={16} height={16} className="text-white" />

View File

@ -1,76 +1,53 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useLiveThread } from '@app/space/hooks/useLiveThread';
import { getNoteByID, removeBlock } from '@libs/storage';
import { Kind1 } from '@shared/notes/contents/kind1';
import { Kind1063 } from '@shared/notes/contents/kind1063';
import { NoteMetadata } from '@shared/notes/metadata';
import { NoteReplyForm } from '@shared/notes/replies/form';
// import { useLiveThread } from '@app/space/hooks/useLiveThread';
import {
NoteActions,
NoteContent,
NoteReplyForm,
NoteStats,
ThreadUser,
} from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { User } from '@shared/user';
import { useAccount } from '@utils/hooks/useAccount';
import { parser } from '@utils/parser';
export function ThreadBlock({ params }: { params: any }) {
useLiveThread(params.content);
const queryClient = useQueryClient();
import { useEvent } from '@utils/hooks/useEvent';
import { Block } from '@utils/types';
export function ThreadBlock({ params }: { params: Block }) {
const { status, data } = useEvent(params.content);
const { account } = useAccount();
const { status, data } = useQuery(['thread', params.content], async () => {
const res = await getNoteByID(params.content);
res['content'] = parser(res);
return res;
});
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
// subscribe to live reply
// useLiveThread(params.content);
return (
<div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pb-20 pt-1.5">
<TitleBar id={params.id} title={params.title} />
<div className="scrollbar-hide flex h-full w-full flex-col gap-3 overflow-y-auto pb-20 pt-1.5">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div className="h-min w-full px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-5 pt-5">
<User pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3">
{data.kind === 1 && <Kind1 content={data.content} />}
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
<NoteMetadata
id={data.event_id || params.content}
eventPubkey={data.pubkey}
/>
<Link to={`/app/note/${params.content}`}>Focus</Link>
<div className="h-min w-full px-3 pt-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-2">
<NoteContent content={data.content} />
</div>
<div>
<NoteActions id={data.id} pubkey={data.pubkey} noOpenThread={true} />
<NoteStats id={data.id} />
</div>
</div>
<div className="mt-3 rounded-md bg-zinc-900">
{account && (
<NoteReplyForm rootID={params.content} userPubkey={account.pubkey} />
)}
</div>
</div>
)}
<div className="px-3">
<RepliesList parent_id={params.content} />
<NoteReplyForm id={params.content} pubkey={account.pubkey} />
<RepliesList id={params.content} />
</div>
</div>
</div>

View File

@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { UserProfile } from '@shared/userProfile';
import { nHoursAgo } from '@utils/date';
import { Block, LumeEvent } from '@utils/types';
export function UserBlock({ params }: { params: Block }) {
const parentRef = useRef<HTMLDivElement>(null);
const { fetcher, relayUrls } = useNDK();
const { status, data } = useQuery(['user-feed', params.content], async () => {
const events = await fetcher.fetchAllEvents(
relayUrls,
{ kinds: [1], authors: [params.content] },
{ since: nHoursAgo(48) },
{ sort: true }
);
return events as unknown as LumeEvent[];
});
const rowVirtualizer = useVirtualizer({
count: data ? data.length : 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
return (
<div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar id={params.id} title={params.title} />
<div
ref={parentRef}
className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pt-1.5"
>
<div className="px-3 pt-1.5">
<UserProfile pubkey={params.content} />
</div>
<div>
<h3 className="mt-2 px-3 text-lg font-semibold text-zinc-300">
Latest activities
</h3>
<div
className="flex h-full w-full flex-col justify-between gap-1.5"
style={{ contain: 'strict' }}
>
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-zinc-300">
No new posts about this hashtag in 48 hours ago
</p>
</div>
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={data[virtualRow.index]} />
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -19,7 +19,7 @@ export function useNewsfeed() {
useEffect(() => {
if (status === 'success' && account) {
const follows = account ? JSON.parse(account.follows) : [];
const follows = account ? JSON.parse(account.follows as string) : [];
const filter: NDKFilter = {
kinds: [1, 6],
@ -30,7 +30,6 @@ export function useNewsfeed() {
sub.current = ndk.subscribe(filter, { closeOnEose: false });
sub.current.addListener('event', (event: NDKEvent) => {
console.log('new note: ', event);
// add to db
createNote(
event.id,
@ -46,7 +45,9 @@ export function useNewsfeed() {
}
return () => {
sub.current.stop();
if (sub.current) {
sub.current.stop();
}
};
}, [status]);
}

View File

@ -1,8 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { UserBlock } from '@app/space//components/blocks/user';
import { AddBlock } from '@app/space/components/add';
import { FeedBlock } from '@app/space/components/blocks/feed';
import { FollowingBlock } from '@app/space/components/blocks/following';
import { HashtagBlock } from '@app/space/components/blocks/hashtag';
import { ImageBlock } from '@app/space/components/blocks/image';
import { ThreadBlock } from '@app/space/components/blocks/thread';
@ -10,6 +13,8 @@ import { getBlocks } from '@libs/storage';
import { LoaderIcon } from '@shared/icons';
import { Block } from '@utils/types';
export function SpaceScreen() {
const {
status,
@ -28,6 +33,26 @@ export function SpaceScreen() {
}
);
const renderBlock = useCallback(
(block: Block) => {
switch (block.kind) {
case 0:
return <ImageBlock key={block.id} params={block} />;
case 1:
return <FeedBlock key={block.id} params={block} />;
case 2:
return <ThreadBlock key={block.id} params={block} />;
case 3:
return <HashtagBlock key={block.id} params={block} />;
case 5:
return <UserBlock key={block.id} params={block} />;
default:
break;
}
},
[blocks]
);
return (
<div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
<FollowingBlock />
@ -43,18 +68,7 @@ export function SpaceScreen() {
</div>
</div>
) : (
blocks.map((block: { kind: number; id: string }) => {
switch (block.kind) {
case 0:
return <ImageBlock key={block.id} params={block} />;
case 1:
return <FeedBlock key={block.id} params={block} />;
case 2:
return <ThreadBlock key={block.id} params={block} />;
default:
break;
}
})
blocks.map((block: Block) => renderBlock(block))
)}
{isFetching && (
<div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">

View File

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Note } from '@shared/notes/note';
import { NoteKind_1 } from '@shared/notes';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
@ -27,7 +27,7 @@ export function TrendingNotes() {
) : (
<div className="relative flex w-full flex-col pt-1.5">
{data.notes.map((item) => (
<Note key={item.id} event={item.event} skipMetadata={true} />
<NoteKind_1 key={item.id} event={item.event} skipMetadata={true} />
))}
</div>
)}

View File

@ -1,34 +1,84 @@
import { NDKFilter } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { Note } from '@shared/notes/note';
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
import { dateToUnix, getHourAgo } from '@utils/date';
import { nHoursAgo } from '@utils/date';
import { LumeEvent } from '@utils/types';
export function UserFeed({ pubkey }: { pubkey: string }) {
const { ndk } = useNDK();
const parentRef = useRef();
const { fetcher, relayUrls } = useNDK();
const { status, data } = useQuery(['user-feed', pubkey], async () => {
const now = new Date();
const filter: NDKFilter = {
kinds: [1],
authors: [pubkey],
since: dateToUnix(getHourAgo(48, now)),
};
const events = await ndk.fetchEvents(filter);
return [...events];
const events = await fetcher.fetchAllEvents(
relayUrls,
{ kinds: [1], authors: [pubkey] },
{ since: nHoursAgo(48) },
{ sort: true }
);
return events as unknown as LumeEvent[];
});
const rowVirtualizer = useVirtualizer({
count: data ? data.length : 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
return (
<div className="w-full max-w-[400px] px-2 pb-10">
<div
ref={parentRef}
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
style={{ contain: 'strict' }}
>
{status === 'loading' ? (
<div className="px-3">
<p>Loading...</p>
<div className="px-3 py-1.5">
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-zinc-300">
No new posts about this hashtag in 48 hours ago
</p>
</div>
</div>
</div>
) : (
data.map((note: LumeEvent) => <Note key={note.id} event={note} />)
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={data[virtualRow.index]} />
</div>
))}
</div>
</div>
)}
</div>
);

View File

@ -19,13 +19,13 @@ export function UserMetadata({ pubkey }: { pubkey: string }) {
<div className="flex w-full items-center gap-10">
<div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
{data.stats[pubkey].followers_pubkey_count ?? 0}
{compactNumber.format(data.stats[pubkey].followers_pubkey_count) ?? 0}
</span>
<span className="text-sm leading-none text-zinc-400">Followers</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
{data.stats[pubkey].pub_following_pubkey_count ?? 0}
{compactNumber.format(data.stats[pubkey].pub_following_pubkey_count) ?? 0}
</span>
<span className="text-sm leading-none text-zinc-400">Following</span>
</div>

View File

@ -15,7 +15,19 @@ button {
}
.markdown {
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:leading-tight prose-p:last:mb-0 prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 hover:prose-a:text-fuchsia-600 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-hr:mx-0 prose-hr:my-2;
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:last:mb-0 prose-a:break-all prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-400 hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-img:mt-3 prose-img:mb-2 prose-hr:mx-0 prose-hr:my-2;
}
.ProseMirror p.is-empty::before {
@apply text-zinc-500;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.ProseMirror img.ProseMirror-selectednode {
@apply outline-fuchsia-500;
}
/* For Webkit-based browsers (Chrome, Safari and Opera) */

View File

@ -1,15 +1,18 @@
// source: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
import { NostrFetcher, normalizeRelayUrlSet } from 'nostr-fetch';
import { useEffect, useState } from 'react';
import { getSetting } from '@libs/storage';
const setting = await getSetting('relays');
const relays = JSON.parse(setting);
const relays = normalizeRelayUrlSet(JSON.parse(setting));
export const NDKInstance = () => {
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>(relays);
const [fetcher, setFetcher] = useState<NostrFetcher>(undefined);
useEffect(() => {
loadNdk(relays);
@ -26,11 +29,13 @@ export const NDKInstance = () => {
setNDK(ndkInstance);
setRelayUrls(explicitRelayUrls);
setFetcher(NostrFetcher.withCustomPool(ndkAdapter(ndkInstance)));
}
return {
ndk,
relayUrls,
fetcher,
loadNdk,
};
};

View File

@ -1,5 +1,6 @@
// source: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import { NostrFetcher } from 'nostr-fetch';
import { PropsWithChildren, createContext, useContext } from 'react';
import { NDKInstance } from '@libs/ndk/instance';
@ -7,17 +8,19 @@ import { NDKInstance } from '@libs/ndk/instance';
interface NDKContext {
ndk: NDK;
relayUrls: string[];
fetcher: NostrFetcher;
loadNdk: (_: string[]) => void;
}
const NDKContext = createContext<NDKContext>({
ndk: new NDK({}),
relayUrls: [],
fetcher: undefined,
loadNdk: undefined,
});
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
const { ndk, relayUrls, loadNdk } = NDKInstance();
const { ndk, relayUrls, fetcher, loadNdk } = NDKInstance();
if (ndk)
return (
@ -25,6 +28,7 @@ const NDKProvider = ({ children }: PropsWithChildren<object>) => {
value={{
ndk,
relayUrls,
fetcher,
loadNdk,
}}
>

View File

@ -1,23 +0,0 @@
import NDK, { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
export async function prefetchEvents(
ndk: NDK,
filter: NDKFilter
): Promise<Set<NDKEvent>> {
return new Promise((resolve) => {
const events: Map<string, NDKEvent> = new Map();
const relaySetSubscription = ndk.subscribe(filter, {
closeOnEose: true,
});
relaySetSubscription.on('event', (event: NDKEvent) => {
event.ndk = ndk;
events.set(event.tagId(), event);
});
relaySetSubscription.on('eose', () => {
setTimeout(() => resolve(new Set(events.values())), 3000);
});
});
}

View File

@ -1,7 +1,9 @@
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
import destr from 'destr';
import Database from 'tauri-plugin-sql-api';
import { parser } from '@utils/parser';
import { getParentID } from '@utils/transform';
import { Account, Block, Chats, LumeEvent, Profile, Settings } from '@utils/types';
let db: null | Database = null;
@ -18,7 +20,9 @@ export async function connect(): Promise<Database> {
// get active account
export async function getActiveAccount() {
const db = await connect();
const result: any = await db.select('SELECT * FROM accounts WHERE is_active = 1;');
const result: Array<Account> = await db.select(
'SELECT * FROM accounts WHERE is_active = 1;'
);
if (result.length > 0) {
return result[0];
} else {
@ -29,9 +33,10 @@ export async function getActiveAccount() {
// get all accounts
export async function getAccounts() {
const db = await connect();
return await db.select(
const result: Array<Account> = await db.select(
'SELECT * FROM accounts WHERE is_active = 0 ORDER BY created_at DESC;'
);
return result;
}
// create account
@ -49,7 +54,7 @@ export async function createAccount(
if (res) {
await createBlock(
0,
'Preserve your freedom',
'Have fun together!',
'https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv'
);
}
@ -80,7 +85,7 @@ export async function countTotalChannels() {
// count total notes
export async function countTotalNotes() {
const db = await connect();
const result = await db.select(
const result: Array<{ total: string }> = await db.select(
'SELECT COUNT(*) AS "total" FROM notes WHERE kind IN (1, 6);'
);
return parseInt(result[0].total);
@ -92,11 +97,19 @@ export async function getNotes(limit: number, offset: number) {
const totalNotes = await countTotalNotes();
const nextCursor = offset + limit;
const notes: any = { data: null, nextCursor: 0 };
const query: any = await db.select(
const notes: { data: LumeEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: LumeEvent[] = await db.select(
`SELECT * FROM notes WHERE kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`
);
query.forEach(
(el) => (el.tags = typeof el.tags === 'string' ? destr(el.tags) : el.tags)
);
notes['data'] = query;
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
@ -106,11 +119,16 @@ export async function getNotes(limit: number, offset: number) {
// get all notes by pubkey
export async function getNotesByPubkey(pubkey: string) {
const db = await connect();
const res: any = await db.select(
const query: LumeEvent[] = await db.select(
`SELECT * FROM notes WHERE pubkey == "${pubkey}" AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC;`
);
return res;
query.forEach(
(el) => (el.tags = typeof el.tags === 'string' ? destr(el.tags) : el.tags)
);
return query;
}
// get all notes by authors
@ -121,11 +139,19 @@ export async function getNotesByAuthors(authors: string, limit: number, offset:
const array = JSON.parse(authors);
const finalArray = `'${array.join("','")}'`;
const notes: any = { data: null, nextCursor: 0 };
const query: any = await db.select(
const notes: { data: LumeEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: LumeEvent[] = await db.select(
`SELECT * FROM notes WHERE pubkey IN (${finalArray}) AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`
);
query.forEach(
(el) => (el.tags = typeof el.tags === 'string' ? destr(el.tags) : el.tags)
);
notes['data'] = query;
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
@ -135,8 +161,16 @@ export async function getNotesByAuthors(authors: string, limit: number, offset:
// get note by id
export async function getNoteByID(event_id: string) {
const db = await connect();
const result = await db.select(`SELECT * FROM notes WHERE event_id = "${event_id}";`);
return result[0];
const result: LumeEvent[] = await db.select(
`SELECT * FROM notes WHERE event_id = "${event_id}";`
);
if (result[0]) {
// @ts-expect-error, todo
if (result[0].kind === 1) result[0]['content'] = parser(result[0]);
return result[0];
} else {
return null;
}
}
// create note
@ -144,7 +178,7 @@ export async function createNote(
event_id: string,
pubkey: string,
kind: number,
tags: any,
tags: string[][],
content: string,
created_at: number
) {
@ -161,7 +195,7 @@ export async function createNote(
// get note replies
export async function getReplies(parent_id: string) {
const db = await connect();
const result: any = await db.select(
const result: Array<LumeEvent> = await db.select(
`SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`
);
return result;
@ -173,7 +207,7 @@ export async function createReplyNote(
event_id: string,
pubkey: string,
kind: number,
tags: any,
tags: string[][],
content: string,
created_at: number
) {
@ -272,11 +306,30 @@ export async function getChannelUsers(channel_id: string) {
export async function getChats() {
const db = await connect();
const account = await getActiveAccount();
const result: any = await db.select(
const follows =
typeof account.follows === 'string' ? JSON.parse(account.follows) : account.follows;
const chats: { follows: Array<Chats> | null; unknown: number } = {
follows: [],
unknown: 0,
};
let result: Array<Chats> = await db.select(
`SELECT DISTINCT sender_pubkey FROM chats WHERE receiver_pubkey = "${account.pubkey}" ORDER BY created_at DESC;`
);
const newArr: any = result.map((v) => ({ ...v, new_messages: 0 }));
return newArr;
result = result.map((v) => ({ ...v, new_messages: 0 }));
result = result.sort((a, b) => a.new_messages - b.new_messages);
chats.follows = result.filter((el) => {
return follows.some((i) => {
return i === el.sender_pubkey;
});
});
chats.unknown = result.length - chats.follows.length;
return chats;
}
// get chat messages
@ -284,7 +337,7 @@ export async function getChatMessages(receiver_pubkey: string, sender_pubkey: st
const db = await connect();
let receiver = [];
const sender: any = await db.select(
const sender: Array<Chats> = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${sender_pubkey}" AND receiver_pubkey = "${receiver_pubkey}";`
);
@ -321,7 +374,9 @@ export async function createChat(
// get setting
export async function getSetting(key: string) {
const db = await connect();
const result = await db.select(`SELECT value FROM settings WHERE key = "${key}";`);
const result: Array<Settings> = await db.select(
`SELECT value FROM settings WHERE key = "${key}";`
);
return result[0]?.value;
}
@ -334,7 +389,9 @@ export async function updateSetting(key: string, value: string | number) {
// get last login
export async function getLastLogin() {
const db = await connect();
const result = await db.select(`SELECT value FROM settings WHERE key = "last_login";`);
const result: Array<Settings> = await db.select(
`SELECT value FROM settings WHERE key = "last_login";`
);
if (result[0]) {
return parseInt(result[0].value);
} else {
@ -350,56 +407,22 @@ export async function updateLastLogin(value: number) {
);
}
// get blacklist by kind and account id
export async function getBlacklist(account_id: number, kind: number) {
const db = await connect();
return await db.select(
`SELECT * FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}";`
);
}
// get active blacklist by kind and account id
export async function getActiveBlacklist(account_id: number, kind: number) {
const db = await connect();
return await db.select(
`SELECT content FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}" AND status = 1;`
);
}
// add to blacklist
export async function addToBlacklist(
account_id: number,
content: string,
kind: number,
status?: number
) {
const db = await connect();
return await db.execute(
'INSERT OR IGNORE INTO blacklist (account_id, content, kind, status) VALUES (?, ?, ?, ?);',
[account_id, content, kind, status || 1]
);
}
// update item in blacklist
export async function updateItemInBlacklist(content: string, status: number) {
const db = await connect();
return await db.execute(
`UPDATE blacklist SET status = "${status}" WHERE content = "${content}";`
);
}
// get all blocks
export async function getBlocks() {
const db = await connect();
const activeAccount = await getActiveAccount();
const result: any = await db.select(
`SELECT * FROM blocks WHERE account_id = "${activeAccount.id}" ORDER BY created_at DESC;`
const account = await getActiveAccount();
const result: Array<Block> = await db.select(
`SELECT * FROM blocks WHERE account_id = "${account.id}" ORDER BY created_at DESC;`
);
return result;
}
// create block
export async function createBlock(kind: number, title: string, content: any) {
export async function createBlock(
kind: number,
title: string,
content: string | string[]
) {
const db = await connect();
const activeAccount = await getActiveAccount();
return await db.execute(
@ -437,12 +460,29 @@ export async function createMetadata(id: string, pubkey: string, content: string
);
}
// get metadata
export async function getAllMetadata() {
const db = await connect();
const result: LumeEvent[] = await db.select(`SELECT * FROM metadata;`);
const users: Profile[] = result.map((el) => {
const profile: Profile = destr(el.content);
return {
pubkey: el.pubkey,
ident: profile.name || profile.display_name || profile.username || 'anon',
picture:
profile.picture ||
profile.image ||
'https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih.jpg',
};
});
return users;
}
// get user metadata
export async function getUserMetadata(pubkey: string) {
const db = await connect();
const result = await db.select(`SELECT content FROM metadata WHERE id = "${pubkey}";`);
const result = await db.select(`SELECT * FROM metadata WHERE pubkey = "${pubkey}";`);
if (result[0]) {
return JSON.parse(result[0].content);
return JSON.parse(result[0].content) as Profile;
} else {
return null;
}

View File

@ -1,5 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { createRoot } from 'react-dom/client';
import { NDKProvider } from '@libs/ndk/provider';
@ -25,6 +24,5 @@ root.render(
<NDKProvider>
<App />
</NDKProvider>
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
</QueryClientProvider>
);

View File

@ -94,7 +94,7 @@ export function ActiveAccount({ data }: { data: any }) {
return (
<Link to={`/app/user/${data.pubkey}`} className="relative inline-block h-9 w-9">
<Image
src={user.image}
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={data.npub}
className="h-9 w-9 rounded-md object-cover"

View File

@ -7,7 +7,7 @@ export function Button({
disabled = false,
onClick = undefined,
}: {
preset: 'small' | 'publish' | 'large';
preset: 'small' | 'publish' | 'large' | 'large-alt';
children: ReactNode;
disabled?: boolean;
onClick?: () => void;
@ -26,6 +26,10 @@ export function Button({
preClass =
'h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600';
break;
case 'large-alt':
preClass =
'h-11 w-full bg-zinc-800 rounded-md font-medium text-zinc-300 border-t border-zinc-700/50 hover:bg-zinc-900';
break;
default:
break;
}

View File

@ -0,0 +1,164 @@
import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { Button } from '@shared/button';
import { Suggestion } from '@shared/composer';
import { CancelIcon, LoaderIcon, PlusCircleIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes';
import { useComposer } from '@stores/composer';
import { FULL_RELAYS } from '@stores/constants';
import { usePublish } from '@utils/hooks/usePublish';
import { useImageUploader } from '@utils/hooks/useUploader';
import { sendNativeNotification } from '@utils/notification';
export function Composer() {
const [status, setStatus] = useState<null | 'loading' | 'done'>(null);
const [reply, clearReply] = useComposer((state) => [state.reply, state.clearReply]);
const editor = useEditor({
extensions: [
StarterKit.configure({
dropcursor: {
color: '#fff',
},
}),
Placeholder.configure({ placeholder: 'Type something...' }),
Mention.configure({
suggestion: Suggestion,
renderLabel({ node }) {
return `nostr:${nip19.npubEncode(node.attrs.id.pubkey)} `;
},
}),
Image.configure({
HTMLAttributes: {
class:
'rounded-lg w-2/3 h-auto border border-zinc-800 outline outline-2 outline-offset-0 outline-zinc-700 ml-1',
},
}),
],
content: '',
editorProps: {
attributes: {
class: twMerge(
'scrollbar-hide markdown break-all max-h-[500px] overflow-y-auto outline-none pr-2',
`${reply.id ? '!min-h-42' : '!min-h-[100px]'}`
),
},
},
});
const upload = useImageUploader();
const publish = usePublish();
const uploadImage = async (file?: string) => {
const image = await upload(file);
if (image.url) {
editor.commands.setImage({ src: image.url });
editor.commands.createParagraphNear();
}
};
const submit = async () => {
setStatus('loading');
try {
let tags: string[][] = [];
if (reply.id && reply.pubkey) {
if (reply.root) {
tags = [
['e', reply.root, FULL_RELAYS[0], 'root'],
['e', reply.id, FULL_RELAYS[0], 'reply'],
['p', reply.pubkey],
];
} else {
tags = [
['e', reply.id, FULL_RELAYS[0], 'reply'],
['p', reply.pubkey],
];
}
}
// get plaintext content
const html = editor.getHTML();
const serializedContent = convert(html, {
selectors: [
{ selector: 'a', options: { linkBrackets: false } },
{ selector: 'img', options: { linkBrackets: false } },
],
});
// publish message
await publish({ content: serializedContent, kind: 1, tags });
// send native notifiation
await sendNativeNotification('Publish post successfully');
// update state
setStatus('done');
// reset editor
editor.commands.clearContent();
if (reply.id) {
clearReply();
}
} catch {
setStatus(null);
console.log('failed to publish');
}
};
return (
<div className="flex h-full flex-col px-4 pb-4">
<div className="flex h-full w-full gap-3">
<div className="flex w-8 shrink-0 items-center justify-center">
<div className="h-full w-[2px] bg-zinc-800" />
</div>
<div className="w-full">
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{reply.id && (
<div className="relative">
<MentionNote id={reply.id} />
<button
type="button"
onClick={() => clearReply()}
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center gap-2 rounded bg-zinc-800 px-2 hover:bg-zinc-700"
>
<CancelIcon className="h-4 w-4 text-zinc-100" />
</button>
</div>
)}
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<button
type="button"
onClick={() => uploadImage()}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-zinc-800"
>
<PlusCircleIcon className="h-5 w-5 text-zinc-500" />
</button>
<Button onClick={() => submit()} preset="publish">
{status === 'loading' ? (
<LoaderIcon className="h-4 w-4 animate-spin text-zinc-100" />
) : (
'Publish'
)}
</Button>
</div>
</div>
);
}

View File

@ -1,134 +0,0 @@
import { open } from '@tauri-apps/api/dialog';
import { listen } from '@tauri-apps/api/event';
import { Body, fetch } from '@tauri-apps/api/http';
import { useCallback, useEffect, useState } from 'react';
import { Transforms } from 'slate';
import { useSlateStatic } from 'slate-react';
import { PlusCircleIcon } from '@shared/icons';
import { createBlobFromFile } from '@utils/createBlobFromFile';
export function ImageUploader() {
const editor = useSlateStatic();
const [loading, setLoading] = useState(false);
const insertImage = (editor, url) => {
const image = { type: 'image', url, children: [{ text: url }] };
Transforms.insertNodes(editor, image);
};
const uploadToVoidCat = useCallback(
async (filepath) => {
const filename = filepath.split('/').pop();
const file = await createBlobFromFile(filepath);
const buf = await file.arrayBuffer();
try {
const res: { data: { file: { id: string } } } = await fetch(
'https://void.cat/upload?cli=false',
{
method: 'POST',
timeout: 5,
headers: {
accept: '*/*',
'Content-Type': 'application/octet-stream',
'V-Filename': filename,
'V-Description': 'Uploaded from https://lume.nu',
'V-Strip-Metadata': 'true',
},
body: Body.bytes(buf),
}
);
const image = `https://void.cat/d/${res.data.file.id}.webp`;
// update parent state
insertImage(editor, image);
// reset loading state
setLoading(false);
} catch (error) {
// reset loading state
setLoading(false);
// handle error
if (error instanceof SyntaxError) {
// Unexpected token < in JSON
console.log('There was a SyntaxError', error);
} else {
console.log('There was an error', error);
}
}
},
[editor]
);
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
setLoading(true);
// upload file
uploadToVoidCat(selected);
}
};
useEffect(() => {
async function initFileDrop() {
const unlisten = await listen('tauri://file-drop', (event) => {
// set loading state
setLoading(true);
// upload file
uploadToVoidCat(event.payload[0]);
});
return () => {
unlisten();
};
}
initFileDrop();
}, [uploadToVoidCat]);
return (
<button
type="button"
onClick={() => openFileDialog()}
className="inline-flex h-8 w-8 items-center justify-center rounded hover:bg-zinc-800"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<PlusCircleIcon width={20} height={20} className="text-zinc-500" />
)}
</button>
);
}

View File

@ -0,0 +1,6 @@
export * from './user';
export * from './modal';
export * from './composer';
export * from './mention/list';
export * from './mention/item';
export * from './mention/suggestion';

View File

@ -0,0 +1,31 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { displayNpub } from '@utils/shortenKey';
import { Profile } from '@utils/types';
export function MentionItem({ profile }: { profile: Profile }) {
return (
<div className="flex items-center gap-2">
<div className="h-8 w-8 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image
src={profile.picture || profile.image}
fallback={DEFAULT_AVATAR}
alt={profile.pubkey}
className="h-8 w-8 object-cover"
/>
</div>
<div className="flex flex-col gap-px">
<h5 className="max-w-[15rem] text-sm font-medium leading-none text-zinc-100">
{profile.ident || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
</h5>
<span className="text-sm leading-none text-zinc-400">
{displayNpub(profile.pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,75 @@
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { MentionItem } from '@shared/composer';
export const MentionList = forwardRef((props: any, ref: any) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item });
}
};
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-[250px] flex-col rounded-xl border-t border-zinc-700/50 bg-zinc-800 px-3 py-3">
{props.items.length ? (
props.items.map((item: NDKUserProfile, index: number) => (
<button
className={twMerge(
'h-11 w-full rounded-lg px-2 text-start text-sm font-medium hover:bg-zinc-700',
`${index === selectedIndex ? 'is-selected' : ''}`
)}
key={index}
onClick={() => selectItem(index)}
>
<MentionItem profile={item} />
</button>
))
) : (
<div>No result</div>
)}
</div>
);
});
MentionList.displayName = 'MentionList';

View File

@ -0,0 +1,71 @@
import { ReactRenderer } from '@tiptap/react';
import tippy from 'tippy.js';
import { getAllMetadata } from '@libs/storage';
import { MentionList } from '@shared/composer';
const users = await getAllMetadata();
export const Suggestion = {
items: ({ query }) => {
return users
.filter((item) => item.ident.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();
},
};
},
};

View File

@ -3,8 +3,7 @@ import { Fragment } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Button } from '@shared/button';
import { Post } from '@shared/composer/types/post';
import { User } from '@shared/composer/user';
import { Composer, ComposerUser } from '@shared/composer';
import {
CancelIcon,
ChevronDownIcon,
@ -17,9 +16,8 @@ import { COMPOSE_SHORTCUT } from '@stores/shortcuts';
import { useAccount } from '@utils/hooks/useAccount';
export function Composer() {
export function ComposerModal() {
const { account } = useAccount();
const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]);
const closeModal = () => {
@ -31,7 +29,7 @@ export function Composer() {
return (
<>
<Button onClick={() => toggle(true)} preset="small">
<ComposeIcon width={14} height={14} />
<ComposeIcon className="h-4 w-4" />
Compose
</Button>
<Transition appear show={open} as={Fragment}>
@ -60,7 +58,7 @@ export function Composer() {
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex items-center justify-between px-4 py-4">
<div className="flex items-center gap-2">
<div>{account && <User pubkey={account.pubkey} />}</div>
{account && <ComposerUser pubkey={account.pubkey} />}
<span>
<ChevronRightIcon
width={14}
@ -70,20 +68,18 @@ export function Composer() {
</span>
<div className="inline-flex h-7 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-sm font-medium text-zinc-400">
New Post
<ChevronDownIcon width={14} height={14} />
<ChevronDownIcon className="h-4 w-4" />
</div>
</div>
<div
<button
onClick={closeModal}
onKeyDown={closeModal}
role="button"
tabIndex={0}
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<CancelIcon width={16} height={16} className="text-zinc-500" />
</div>
</button>
</div>
{account && <Post />}
<Composer />
</Dialog.Panel>
</Transition.Child>
</div>

View File

@ -1,170 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Node, Transforms, createEditor } from 'slate';
import { withHistory } from 'slate-history';
import { Editable, ReactEditor, Slate, useSlateStatic, withReact } from 'slate-react';
import { Button } from '@shared/button';
import { ImageUploader } from '@shared/composer/imageUploader';
import { CancelIcon, TrashIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes/mentions/note';
import { useComposer } from '@stores/composer';
import { FULL_RELAYS } from '@stores/constants';
import { usePublish } from '@utils/hooks/usePublish';
const withImages = (editor) => {
const { isVoid } = editor;
editor.isVoid = (element) => {
return element.type === 'image' ? true : isVoid(element);
};
return editor;
};
const ImagePreview = ({
attributes,
children,
element,
}: {
attributes: any;
children: any;
element: any;
}) => {
const editor: any = useSlateStatic();
const path = ReactEditor.findPath(editor, element);
return (
<figure {...attributes} className="m-0 mt-3">
{children}
<div contentEditable={false} className="relative">
<img
alt={element.url}
src={element.url}
className="m-0 h-auto max-h-[300px] w-full rounded-md object-cover"
/>
<button
type="button"
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="shadow-mini-button absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center gap-0.5 rounded bg-zinc-800 text-base font-medium text-zinc-400 hover:bg-zinc-700"
>
<TrashIcon width={14} height={14} className="text-zinc-100" />
</button>
</div>
</figure>
);
};
export function Post() {
const publish = usePublish();
const editor = useMemo(() => withReact(withImages(withHistory(createEditor()))), []);
const [reply, clearReply, toggle] = useComposer((state) => [
state.reply,
state.clearReply,
state.toggleModal,
]);
const [content, setContent] = useState<Node[]>([
{
children: [
{
text: '',
},
],
},
]);
const serialize = useCallback((nodes: Node[]) => {
return nodes.map((n) => Node.string(n)).join('\n');
}, []);
const removeReply = () => {
clearReply();
};
const submit = async () => {
let tags: string[][] = [];
if (reply.id && reply.pubkey) {
if (reply.root && reply.root !== reply.id) {
tags = [
['e', reply.id, FULL_RELAYS[0], 'root'],
['e', reply.root, FULL_RELAYS[0], 'reply'],
['p', reply.pubkey],
];
} else {
tags = [
['e', reply.id, FULL_RELAYS[0], 'root'],
['p', reply.pubkey],
];
}
} else {
tags = [];
}
// serialize content
const serializedContent = serialize(content);
// publish message
await publish({ content: serializedContent, kind: 1, tags });
// close modal
toggle(false);
};
const renderElement = useCallback((props) => {
switch (props.element.type) {
case 'image':
if (props.element.url) {
return <ImagePreview {...props} />;
}
break;
default:
return <p {...props.attributes}>{props.children}</p>;
}
}, []);
return (
<Slate editor={editor} value={content} onChange={setContent}>
<div className="flex h-full flex-col px-4 pb-4">
<div className="flex h-full w-full gap-2">
<div className="flex w-8 shrink-0 items-center justify-center">
<div className="h-full w-[2px] bg-zinc-800" />
</div>
<div className="w-full">
<Editable
placeholder={
reply.id ? 'Share your thoughts on it' : "What's on your mind?"
}
spellCheck="false"
className={`${
reply.id ? '!min-h-42' : '!min-h-[86px]'
} markdown max-h-[500px] overflow-y-auto`}
renderElement={renderElement}
/>
{reply.id && (
<div className="relative">
<MentionNote id={reply.id} />
<button
type="button"
onClick={() => removeReply()}
className="absolute right-3 top-3 inline-flex h-6 w-max items-center justify-center gap-2 rounded bg-zinc-800 px-2 hover:bg-zinc-700"
>
<CancelIcon className="h-4 w-4 text-zinc-100" />
<span className="text-sm">Stop reply</span>
</button>
</div>
)}
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<ImageUploader />
<Button onClick={() => submit()} preset="publish">
Publish
</Button>
</div>
</div>
</Slate>
);
}

View File

@ -4,12 +4,12 @@ import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function User({ pubkey }: { pubkey: string }) {
export function ComposerUser({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
return (
<div className="flex items-center gap-2">
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
<div className="flex items-center gap-3">
<div className="h-8 w-8 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}

View File

@ -43,4 +43,7 @@ export * from './settings';
export * from './logout';
export * from './follow';
export * from './unfollow';
export * from './reaction';
export * from './thread';
export * from './strangers';
// @endindex

View File

@ -0,0 +1,37 @@
import { SVGProps } from 'react';
export function ReactionIcon(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 1a.75.75 0 01.75.75v2.5h2.5a.75.75 0 110 1.5h-2.5v2.5a.75.75 0 11-1.5 0v-2.5h-2.5a.75.75 0 110-1.5h2.5v-2.5A.75.75 0 0119 1z"
clipRule="evenodd"
></path>
<path
fill="currentColor"
d="M10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5zM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5z"
></path>
<path
fill="currentColor"
fillRule="evenodd"
d="M8.642 14.298a.75.75 0 011.06 0 3.25 3.25 0 004.597 0 .75.75 0 011.06 1.06 4.75 4.75 0 01-6.717 0 .75.75 0 010-1.06z"
clipRule="evenodd"
></path>
<path
fill="currentColor"
fillRule="evenodd"
d="M12 3.5a8.5 8.5 0 108.5 8.5.75.75 0 011.5 0c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2a.75.75 0 010 1.5z"
clipRule="evenodd"
></path>
</svg>
);
}

View File

@ -2,14 +2,20 @@ import { SVGProps } from 'react';
export function RepostIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
d="M17.25 21.25L20.25 18.25L17.25 15.25M6.75 2.75L3.75 5.75L6.75 8.75M5.25 5.75H20.25V10.75M3.75 13.75V18.25H18.75"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
strokeWidth="1.5"
d="M12 21.25c4.28 0 7.75-3.75 7.75-8.25 0-5.167-4.613-8.829-6.471-10.094-.426-.29-.988-.165-1.285.257L9.582 6.59a1.002 1.002 0 01-1.525.131c-.39-.387-1.026-.391-1.376.033C5.06 8.718 4.25 11.16 4.25 13c0 4.5 3.47 8.25 7.75 8.25zm0 0c1.657 0 3-1.533 3-3.424 0-2.084-1.663-3.601-2.513-4.24a.802.802 0 00-.974 0c-.85.639-2.513 2.156-2.513 4.24 0 1.89 1.343 3.424 3 3.424z"
></path>
</svg>
);
}

View File

@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function StrangersIcon(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>
);
}

View File

@ -11,12 +11,9 @@ export function ThreadIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGEleme
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M2.75 12h18.5M2.75 5.75h18.5m-18.5 12.5h8.75"
/>
fill="currentColor"
d="M12 19.25V20a.75.75 0 00.75-.75H12zm8.5-9a.75.75 0 001.5 0h-1.5zm-.75 3.5a.75.75 0 00-1.5 0h1.5zm-1.5 6.5a.75.75 0 001.5 0h-1.5zm-2.5-4a.75.75 0 000 1.5v-1.5zm6.5 1.5a.75.75 0 000-1.5v1.5zm-18.75.5V5.75H2v12.5h1.5zm8.5.25H3.75V20H12v-1.5zm8.5-12.75v4.5H22v-4.5h-1.5zM3.75 5.5H12V4H3.75v1.5zm8.25 0h8.25V4H12v1.5zm.75 13.75V4.75h-1.5v14.5h1.5zm5.5-5.5V17h1.5v-3.25h-1.5zm0 3.25v3.25h1.5V17h-1.5zm-2.5.75H19v-1.5h-3.25v1.5zm3.25 0h3.25v-1.5H19v1.5zm3-12A1.75 1.75 0 0020.25 4v1.5a.25.25 0 01.25.25H22zm-18.5 0a.25.25 0 01.25-.25V4A1.75 1.75 0 002 5.75h1.5zM2 18.25c0 .966.784 1.75 1.75 1.75v-1.5a.25.25 0 01-.25-.25H2z"
></path>
</svg>
);
}

View File

@ -5,7 +5,7 @@ import { twMerge } from 'tailwind-merge';
import { ChatsList } from '@app/chat/components/list';
import { AppHeader } from '@shared/appHeader';
import { Composer } from '@shared/composer/modal';
import { ComposerModal } from '@shared/composer/modal';
import { NavArrowDownIcon, SpaceIcon, TrendingIcon } from '@shared/icons';
import { LumeBar } from '@shared/lumeBar';
@ -15,7 +15,7 @@ export function Navigation() {
<AppHeader />
<div className="scrollbar-hide flex flex-col gap-5 overflow-y-auto pb-20">
<div className="inlin-lflex h-8 px-3.5">
<Composer />
<ComposerModal />
</div>
{/* Newsfeed */}
<div className="flex flex-col gap-0.5 px-1.5">

View File

@ -0,0 +1,64 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { ThreadIcon } from '@shared/icons';
import { NoteReaction } from '@shared/notes/actions/reaction';
import { NoteReply } from '@shared/notes/actions/reply';
import { NoteRepost } from '@shared/notes/actions/repost';
import { NoteZap } from '@shared/notes/actions/zap';
import { BLOCK_KINDS } from '@stores/constants';
import { useBlock } from '@utils/hooks/useBlock';
export function NoteActions({
id,
pubkey,
noOpenThread,
}: {
id: string;
pubkey: string;
noOpenThread?: boolean;
}) {
const { add } = useBlock();
return (
<Tooltip.Provider>
<div className="-ml-1 mt-2 inline-flex w-full items-center">
<div className="inline-flex items-center gap-2">
<NoteReply id={id} pubkey={pubkey} />
<NoteReaction id={id} pubkey={pubkey} />
<NoteRepost id={id} pubkey={pubkey} />
<NoteZap />
</div>
{!noOpenThread && (
<>
<div className="mx-2 block h-4 w-px bg-zinc-800" />
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() =>
add.mutate({
kind: BLOCK_KINDS.thread,
title: 'Thread',
content: id,
})
}
className="group inline-flex h-7 w-7 items-center justify-center"
>
<ThreadIcon className="h-5 w-5 text-zinc-300 group-hover:text-fuchsia-400" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 select-none rounded-md border-t border-zinc-600/50 bg-zinc-700 px-3.5 py-1.5 text-sm leading-none text-zinc-100 backdrop-blur-lg 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">
Open thread
<Tooltip.Arrow className="fill-zinc-700" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</>
)}
</div>
</Tooltip.Provider>
);
}

View File

@ -0,0 +1,141 @@
import * as Popover from '@radix-ui/react-popover';
import { useCallback, useEffect, useState } from 'react';
import { ReactionIcon } from '@shared/icons';
import { usePublish } from '@utils/hooks/usePublish';
const REACTIONS = [
{
content: '👏',
img: 'https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Hand%20gestures/Clapping%20Hands.png',
},
{
content: '🤪',
img: 'https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Smilies/Face%20with%20Tongue.png',
},
{
content: '😮',
img: 'https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Smilies/Face%20with%20Open%20Mouth.png',
},
{
content: '😢',
img: 'https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Smilies/Crying%20Face.png',
},
{
content: '🤡',
img: 'https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Smilies/Clown%20Face.png',
},
];
export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
const [open, setOpen] = useState(false);
const [reaction, setReaction] = useState<string | null>(null);
const publish = usePublish();
const getReactionImage = (content: string) => {
const reaction: { img: string } = REACTIONS.find((el) => el.content === content);
return reaction.img;
};
const react = async (content: string) => {
setReaction(content);
const event = await publish({
content: content,
kind: 7,
tags: [
['e', id],
['p', pubkey],
],
});
if (event) {
setOpen(false);
}
};
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"
>
{reaction ? (
<img src={getReactionImage(reaction)} alt={reaction} className="h-6 w-6" />
) : (
<ReactionIcon className="h-5 w-5 text-zinc-300 group-hover:text-red-400" />
)}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="select-none rounded-md border-t border-zinc-600/50 bg-zinc-700 px-1 py-1 text-sm leading-none text-zinc-100 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"
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 hover:bg-zinc-600"
>
<img
src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Hand%20gestures/Clapping%20Hands.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 hover:bg-zinc-600"
>
<img
src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Smilies/Face%20with%20Tongue.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 hover:bg-zinc-600"
>
<img
src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Smilies/Face%20with%20Open%20Mouth.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 hover:bg-zinc-600"
>
<img
src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Smilies/Crying%20Face.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 hover:bg-zinc-600"
>
<img
src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Smilies/Clown%20Face.png"
alt="Clown Face"
className="h-6 w-6"
/>
</button>
</div>
<Popover.Arrow className="fill-zinc-700" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@ -0,0 +1,29 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { ReplyIcon } from '@shared/icons';
import { useComposer } from '@stores/composer';
export function NoteReply({ id, pubkey }: { id: string; pubkey: string }) {
const setReply = useComposer((state) => state.setReply);
return (
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setReply(id, pubkey)}
className="group inline-flex h-7 w-7 items-center justify-center"
>
<ReplyIcon className="h-5 w-5 text-zinc-300 group-hover:text-green-500" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 select-none rounded-md border-t border-zinc-600/50 bg-zinc-700 px-3.5 py-1.5 text-sm leading-none text-zinc-100 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">
Quick reply
<Tooltip.Arrow className="fill-zinc-700" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}

View File

@ -0,0 +1,39 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { RepostIcon } from '@shared/icons';
import { FULL_RELAYS } from '@stores/constants';
import { usePublish } from '@utils/hooks/usePublish';
export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
const publish = usePublish();
const submit = async () => {
const tags = [
['e', id, FULL_RELAYS[0], 'root'],
['p', pubkey],
];
await publish({ content: '', kind: 6, tags: tags });
};
return (
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => submit()}
className="group inline-flex h-7 w-7 items-center justify-center"
>
<RepostIcon className="h-5 w-5 text-zinc-300 group-hover:text-blue-400" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 select-none rounded-md border-t border-zinc-600/50 bg-zinc-700 px-3.5 py-1.5 text-sm leading-none text-zinc-100 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">
Repost
<Tooltip.Arrow className="fill-zinc-700" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}

View File

@ -0,0 +1,24 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { ZapIcon } from '@shared/icons';
export function NoteZap() {
return (
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 w-7 items-center justify-center"
>
<ZapIcon className="h-5 w-5 text-zinc-300 group-hover:text-orange-400" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 select-none rounded-md border-t border-zinc-600/50 bg-zinc-700 px-3.5 py-1.5 text-sm leading-none text-zinc-100 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">
Tip
<Tooltip.Arrow className="fill-zinc-700" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}

View File

@ -0,0 +1,47 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
Hashtag,
ImagePreview,
LinkPreview,
MentionNote,
MentionUser,
VideoPreview,
} from '@shared/notes';
export function NoteContent({
content,
}: {
content: {
original: string;
parsed: string;
notes: string[];
images: string[];
videos: string[];
links: string[];
};
}) {
return (
<>
<ReactMarkdown
className="markdown"
remarkPlugins={[remarkGfm]}
components={{
del: ({ children }) => {
const key = children[0] as string;
if (key.startsWith('pub')) return <MentionUser pubkey={key.slice(3)} />;
if (key.startsWith('tag')) return <Hashtag tag={key.slice(3)} />;
},
}}
>
{content?.parsed}
</ReactMarkdown>
{content?.images?.length > 0 && <ImagePreview urls={content.images} />}
{content?.videos?.length > 0 && <VideoPreview urls={content.videos} />}
{content?.links?.length > 0 && <LinkPreview urls={content.links} />}
{content?.notes?.length > 0 &&
content?.notes.map((note: string) => <MentionNote key={note} id={note} />)}
</>
);
}

View File

@ -1,40 +0,0 @@
import { ReactNode } from 'react';
import { MentionNote } from '@shared/notes/mentions/note';
import { ImagePreview } from '@shared/notes/preview/image';
import { LinkPreview } from '@shared/notes/preview/link';
import { VideoPreview } from '@shared/notes/preview/video';
export function Kind1({
content,
truncate = false,
}: {
content: {
original: string;
parsed: ReactNode[];
notes: string[];
images: string[];
videos: string[];
links: string[];
};
truncate?: boolean;
}) {
return (
<>
<div
className={`select-text whitespace-pre-line break-words text-base text-zinc-100 ${
truncate ? 'line-clamp-3' : ''
}`}
>
{content.parsed}
</div>
{content.images.length > 0 && (
<ImagePreview urls={content.images} truncate={truncate} />
)}
{content.videos.length > 0 && <VideoPreview urls={content.videos} />}
{content.links.length > 0 && <LinkPreview urls={content.links} />}
{content.notes.length > 0 &&
content.notes.map((note: string) => <MentionNote key={note} id={note} />)}
</>
);
}

View File

@ -1,24 +0,0 @@
import { NDKTag } from '@nostr-dev-kit/ndk';
import { Image } from '@shared/image';
function isImage(url: string) {
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
}
export function Kind1063({ metadata }: { metadata: NDKTag[] }) {
const url = metadata[0][1];
return (
<div className="mt-3">
{isImage(url) && (
<Image
src={url}
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt="image"
className="h-auto w-full rounded-lg object-cover"
/>
)}
</div>
);
}

View File

@ -0,0 +1,23 @@
import { BLOCK_KINDS } from '@stores/constants';
import { useBlock } from '@utils/hooks/useBlock';
export function Hashtag({ tag }: { tag: string }) {
const { add } = useBlock();
return (
<button
type="button"
onClick={() =>
add.mutate({
kind: BLOCK_KINDS.hashtag,
title: tag,
content: tag.replace('#', ''),
})
}
className="rounded bg-zinc-800 px-2 py-px text-sm font-normal text-orange-400 no-underline hover:bg-zinc-700 hover:text-orange-500"
>
{tag}
</button>
);
}

View File

@ -0,0 +1,27 @@
export * from './actions/reaction';
export * from './actions/reply';
export * from './actions/repost';
export * from './actions/zap';
export * from './mentions/note';
export * from './mentions/user';
export * from './preview/image';
export * from './preview/link';
export * from './preview/video';
export * from './replies/form';
export * from './replies/item';
export * from './replies/list';
export * from './replies/sub';
export * from './kinds/kind1';
export * from './kinds/kind1063';
export * from './metadata';
export * from './users/mini';
export * from './users/repost';
export * from './users/thread';
export * from './kinds/thread';
export * from './kinds/repost';
export * from './kinds/sub';
export * from './skeleton';
export * from './actions';
export * from './content';
export * from './hashtag';
export * from './stats';

View File

@ -0,0 +1,39 @@
import { useMemo } from 'react';
import { NoteActions, NoteContent, NoteMetadata } from '@shared/notes';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function NoteKind_1({
event,
skipMetadata = false,
}: {
event: LumeEvent;
skipMetadata?: boolean;
}) {
const content = useMemo(() => parser(event), [event.id]);
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<div className="relative flex flex-col">
<User pubkey={event.pubkey} time={event.created_at} />
<div className="relative z-20 -mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="flex-1">
<NoteContent content={content} />
<NoteActions id={event.event_id || event.id} pubkey={event.pubkey} />
</div>
</div>
{!skipMetadata ? (
<NoteMetadata id={event.event_id || event.id} />
) : (
<div className="pb-3" />
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
import { Image } from '@shared/image';
import { NoteActions, NoteMetadata } from '@shared/notes';
import { User } from '@shared/user';
import { LumeEvent } from '@utils/types';
function isImage(url: string) {
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
}
export function NoteKind_1063({ event }: { event: LumeEvent }) {
const url = event.tags[0][1];
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<div className="flex flex-col">
<User pubkey={event.pubkey} time={event.created_at} />
<div className="relative z-20 -mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="flex-1">
{isImage(url) && (
<Image
src={url}
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt="image"
className="h-auto w-full rounded-lg object-cover"
/>
)}
<NoteActions id={event.event_id} pubkey={event.pubkey} />
</div>
</div>
<NoteMetadata id={event.event_id} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,54 @@
import {
NoteActions,
NoteContent,
NoteMetadata,
NoteSkeleton,
RepostUser,
} from '@shared/notes';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
import { getRepostID } from '@utils/transform';
import { LumeEvent } from '@utils/types';
export function Repost({ event }: { event: LumeEvent }) {
const repostID = getRepostID(event.tags);
const { status, data } = useEvent(repostID, event.content);
if (status === 'loading') {
return (
<div className="relative overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<NoteSkeleton />
</div>
);
}
if (status === 'error') {
return (
<div className="flex items-center justify-center overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<p className="text-zinc-400">Failed to fetch</p>
</div>
);
}
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<div className="flex flex-col">
<div className="isolate flex flex-col -space-y-4 overflow-hidden">
<RepostUser pubkey={event.pubkey} />
<User pubkey={data.pubkey} time={data.created_at} isRepost={true} />
</div>
<div className="relative z-20 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="flex-1">
<NoteContent content={data.content} />
<NoteActions id={repostID} pubkey={data.pubkey} />
</div>
</div>
<NoteMetadata id={repostID} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { NoteActions, NoteContent, NoteSkeleton } from '@shared/notes';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
export function SubNote({ id }: { id: string }) {
const { status, data } = useEvent(id);
if (status === 'loading') {
return (
<div className="relative mb-5 overflow-hidden rounded-xl bg-zinc-900 pt-3">
<NoteSkeleton />
</div>
);
}
if (status === 'error') {
return (
<div className="mb-5 flex overflow-hidden rounded-xl bg-zinc-800 px-3 py-3">
<p className="text-zinc-400">Failed to fetch</p>
</div>
);
}
return (
<>
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
<div className="mb-5 flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} />
<div className="relative z-20 -mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="flex-1">
<NoteContent content={data.content} />
<NoteActions id={data.event_id} pubkey={data.pubkey} />
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,39 @@
import { useMemo } from 'react';
import { NoteActions, NoteContent, NoteMetadata, SubNote } from '@shared/notes';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function NoteThread({
event,
root,
reply,
}: {
event: LumeEvent;
root: string;
reply: string;
}) {
const content = useMemo(() => parser(event), [event.id]);
return (
<div className="h-min w-full px-3 py-1.5">
<div className="overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<div className="relative">{root && <SubNote id={root} />}</div>
<div className="relative">{reply && <SubNote id={reply} />}</div>
<div className="relative flex flex-col">
<User pubkey={event.pubkey} time={event.created_at} />
<div className="relative z-20 -mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="flex-1">
<NoteContent content={content} />
<NoteActions id={event.event_id} pubkey={event.pubkey} />
</div>
</div>
<NoteMetadata id={event.event_id} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { NoteActions, NoteMetadata } from '@shared/notes';
import { User } from '@shared/user';
import { LumeEvent } from '@utils/types';
export function NoteKindUnsupport({ event }: { event: LumeEvent }) {
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<div className="flex flex-col">
<User pubkey={event.pubkey} time={event.created_at} />
<div className="relative z-20 -mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="flex-1">
<div className="mt-3 flex w-full flex-col gap-2">
<div className="inline-flex flex-col gap-1 rounded-md bg-zinc-800 px-2 py-2">
<span className="text-sm font-medium leading-none text-zinc-500">
Kind: {event.kind}
</span>
<p className="text-sm leading-none text-fuchsia-500">
Lume isn&apos;t fully support this kind
</p>
</div>
<div className="select-text whitespace-pre-line break-all text-zinc-100">
<p>{event.content.toString()}</p>
</div>
</div>
<NoteActions id={event.event_id} pubkey={event.pubkey} />
</div>
</div>
<NoteMetadata id={event.event_id} />
</div>
</div>
</div>
);
}

View File

@ -1,32 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { createBlock } from '@libs/storage';
import { Kind1 } from '@shared/notes/contents/kind1';
import { Kind1063 } from '@shared/notes/contents/kind1063';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { MentionUser, NoteSkeleton } from '@shared/notes';
import { User } from '@shared/user';
import { BLOCK_KINDS } from '@stores/constants';
import { useBlock } from '@utils/hooks/useBlock';
import { useEvent } from '@utils/hooks/useEvent';
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const queryClient = useQueryClient();
const { add } = useBlock();
const { status, data } = useEvent(id);
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const openThread = (event: any, thread: string) => {
const openThread = (event, thread: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
block.mutate({ kind: 2, title: 'Thread', content: thread });
add.mutate({ kind: BLOCK_KINDS.thread, title: 'Thread', content: thread });
} else {
event.stopPropagation();
}
@ -38,31 +29,37 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
onKeyDown={(e) => openThread(e, id)}
role="button"
tabIndex={0}
className="mt-3 rounded-lg border-t border-zinc-700/50 bg-zinc-800/50 px-3 py-3"
className="mb-2 mt-3 rounded-lg border-t border-zinc-700/50 bg-zinc-800/50 px-3 py-3"
>
{status === 'loading' ? (
<NoteSkeleton />
) : status === 'success' ? (
<>
<User pubkey={data.pubkey} time={data.created_at} size="small" />
<div>
{data.kind === 1 && <Kind1 content={data.content} truncate={true} />}
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
{data.kind !== 1 && data.kind !== 1063 && (
<div className="flex flex-col gap-2">
<div className="inline-flex flex-col gap-1 rounded-md bg-zinc-800 px-2 py-2">
<span className="text-sm font-medium leading-none text-zinc-500">
Kind: {data.kind}
</span>
<p className="text-sm leading-none text-fuchsia-500">
Lume isn&apos;t fully support this kind in newsfeed
</p>
</div>
<div className="select-text whitespace-pre-line break-words text-base text-zinc-100">
<p>{data.content}</p>
</div>
</div>
)}
<div className="mt-2">
<ReactMarkdown
className="markdown"
remarkPlugins={[remarkGfm]}
components={{
del: ({ children }) => {
const key = children[0] as string;
if (key.startsWith('pub')) return <MentionUser pubkey={key.slice(3)} />;
if (key.startsWith('tag'))
return (
<button
type="button"
className="font-normal text-orange-400 no-underline hover:text-orange-500"
>
{key.slice(3)}
</button>
);
},
}}
>
{data?.content?.parsed?.length > 200
? data.content.parsed.substring(0, 200) + '...'
: data.content.parsed}
</ReactMarkdown>
</div>
</>
) : (

View File

@ -1,17 +1,26 @@
import { Link } from 'react-router-dom';
import { BLOCK_KINDS } from '@stores/constants';
import { useBlock } from '@utils/hooks/useBlock';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function MentionUser({ pubkey }: { pubkey: string }) {
const { add } = useBlock();
const { user } = useProfile(pubkey);
return (
<Link
to={`/app/user/${pubkey}`}
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
<button
type="button"
onClick={() =>
add.mutate({
kind: BLOCK_KINDS.user,
title: user?.nip05 || user?.name || user?.displayNam,
content: pubkey,
})
}
className="break-words rounded bg-zinc-800 px-2 py-px text-sm font-normal text-blue-400 no-underline hover:bg-zinc-700 hover:text-blue-500"
>
@{user?.name || user?.displayName || shortenKey(pubkey)}
</Link>
{'@' + user?.name || user?.displayName || shortenKey(pubkey)}
</button>
);
}

View File

@ -1,165 +1,117 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { decode } from 'light-bolt11-decoder';
import { useNDK } from '@libs/ndk/provider';
import { createBlock, createReplyNote } from '@libs/storage';
import { createReplyNote } from '@libs/storage';
import { LoaderIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons';
import { ThreadIcon } from '@shared/icons/thread';
import { NoteReply } from '@shared/notes/metadata/reply';
import { NoteRepost } from '@shared/notes/metadata/repost';
import { NoteZap } from '@shared/notes/metadata/zap';
import { LoaderIcon } from '@shared/icons';
import { MiniUser } from '@shared/notes/users/mini';
export function NoteMetadata({
id,
rootID,
eventPubkey,
}: {
id: string;
rootID?: string;
eventPubkey: string;
}) {
const queryClient = useQueryClient();
import { BLOCK_KINDS } from '@stores/constants';
import { useBlock } from '@utils/hooks/useBlock';
import { compactNumber } from '@utils/number';
export function NoteMetadata({ id }: { id: string }) {
const { add } = useBlock();
const { ndk } = useNDK();
const { status, data } = useQuery(['note-metadata', id], async () => {
let replies = 0;
let reposts = 0;
let zap = 0;
const { status, data } = useQuery(
['note-metadata', id],
async () => {
let replies = 0;
let zap = 0;
const users = [];
const filter: NDKFilter = {
'#e': [id],
kinds: [1, 6, 9735],
};
const filter: NDKFilter = {
'#e': [id],
kinds: [1, 9735],
};
const events = await ndk.fetchEvents(filter);
events.forEach((event: NDKEvent) => {
switch (event.kind) {
case 1:
replies += 1;
createReplyNote(
id,
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at
);
break;
case 6:
reposts += 1;
break;
case 9735: {
const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1];
if (bolt11) {
const decoded = decode(bolt11);
const amount = decoded.sections.find((item) => item.name === 'amount');
const sats = amount.value / 1000;
zap += sats;
const events = await ndk.fetchEvents(filter);
events.forEach((event: NDKEvent) => {
switch (event.kind) {
case 1:
replies += 1;
if (users.length < 3) users.push(event.pubkey);
createReplyNote(
id,
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at
);
break;
case 9735: {
const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1];
if (bolt11) {
const decoded = decode(bolt11);
const amount = decoded.sections.find((item) => item.name === 'amount');
const sats = amount.value / 1000;
zap += sats;
}
break;
}
break;
default:
break;
}
default:
break;
}
});
});
return { replies, reposts, zap };
});
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
return { replies, users, zap };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const openThread = (thread: string) => {
block.mutate({ kind: 2, title: 'Thread', content: thread });
};
{ refetchOnWindowFocus: false, refetchOnReconnect: false }
);
if (status === 'loading') {
return (
<div className="mt-2 inline-flex h-12 w-full items-center">
<div className="group inline-flex w-20 items-center gap-1.5">
<ReplyIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-green-400"
/>
<LoaderIcon
width={12}
height={12}
className="animate-spin text-black dark:text-zinc-100"
/>
</div>
<div className="group inline-flex w-20 items-center gap-1.5">
<RepostIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-green-400"
/>
<LoaderIcon
width={12}
height={12}
className="animate-spin text-black dark:text-zinc-100"
/>
</div>
<div className="group inline-flex w-20 items-center gap-1.5">
<ZapIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-green-400"
/>
<LoaderIcon
width={12}
height={12}
className="animate-spin text-black dark:text-zinc-100"
/>
<div className="mb-3 flex items-center gap-3">
<div className="mt-2h-6 w-11 shrink-0"></div>
<div className="mt-2 inline-flex h-6 items-center">
<LoaderIcon className="h-4 w-4 animate-spin text-zinc-100" />
</div>
</div>
);
}
return (
<Tooltip.Provider>
<div className="mt-2 inline-flex h-12 w-full items-center justify-between">
<div className="inline-flex items-center justify-between">
<NoteReply
id={id}
rootID={rootID}
pubkey={eventPubkey}
replies={data.replies}
/>
<NoteRepost id={id} pubkey={eventPubkey} reposts={data.reposts} />
<NoteZap zaps={data.zap} />
</div>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => openThread(id)}
className="inline-flex h-6 w-6 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800 hover:bg-zinc-700"
>
<ThreadIcon className="h-4 w-4 text-zinc-400" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="-left-10 select-none rounded-md bg-zinc-800/80 px-3.5 py-1.5 text-sm leading-none text-zinc-100 backdrop-blur-lg 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"
sideOffset={5}
>
Open thread
<Tooltip.Arrow className="fill-zinc-800/80 backdrop-blur-lg" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</div>
</Tooltip.Provider>
<div>
{data.replies > 0 ? (
<>
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
<div className="relative z-10 flex items-center gap-3 bg-zinc-900 pb-3">
<div className="mt-2 inline-flex h-6 w-11 shrink-0 items-center justify-center">
<div className="isolate flex -space-x-1 overflow-hidden">
{data.users?.map((user, index) => (
<MiniUser key={user + index} pubkey={user} />
))}
</div>
</div>
<div className="mt-2 inline-flex h-6 items-center gap-2">
<button
type="button"
onClick={() =>
add.mutate({ kind: BLOCK_KINDS.thread, title: 'Thread', content: id })
}
className="text-zinc-500"
>
<span className="font-semibold text-zinc-300">{data.replies}</span>{' '}
replies
</button>
<span className="text-zinc-500">·</span>
<p className="text-zinc-500">
<span className="font-semibold text-zinc-300">
{compactNumber.format(data.zap)}
</span>{' '}
zaps
</p>
</div>
</div>
</>
) : (
<div className="pb-3" />
)}
</div>
);
}

View File

@ -1,49 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { ReplyIcon } from '@shared/icons';
import { useComposer } from '@stores/composer';
import { compactNumber } from '@utils/number';
export function NoteReply({
id,
rootID,
pubkey,
replies,
}: {
id: string;
rootID?: string;
pubkey: string;
replies: number;
}) {
const setReply = useComposer((state) => state.setReply);
return (
<Tooltip.Root delayDuration={150}>
<button
type="button"
onClick={() => setReply(id, rootID, pubkey)}
className="group group inline-flex h-6 w-20 items-center gap-1.5"
>
<Tooltip.Trigger asChild>
<span className="inline-flex h-6 w-6 items-center justify-center rounded group-hover:bg-zinc-800">
<ReplyIcon className="h-4 w-4 text-zinc-400 group-hover:text-green-500" />
</span>
</Tooltip.Trigger>
<span className="text-base leading-none text-zinc-400 group-hover:text-zinc-100">
{compactNumber.format(replies)}
</span>
</button>
<Tooltip.Portal>
<Tooltip.Content
className="-left-10 select-none rounded-md bg-zinc-800/80 px-3.5 py-1.5 text-sm leading-none text-zinc-100 backdrop-blur-lg 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"
sideOffset={5}
>
Quick reply
<Tooltip.Arrow className="fill-zinc-800/80 backdrop-blur-lg" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}

View File

@ -1,56 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { RepostIcon } from '@shared/icons';
import { FULL_RELAYS } from '@stores/constants';
import { usePublish } from '@utils/hooks/usePublish';
import { compactNumber } from '@utils/number';
export function NoteRepost({
id,
pubkey,
reposts,
}: {
id: string;
pubkey: string;
reposts: number;
}) {
const publish = usePublish();
const submit = async () => {
const tags = [
['e', id, FULL_RELAYS[0], 'root'],
['p', pubkey],
];
await publish({ content: '', kind: 6, tags: tags });
};
return (
<Tooltip.Root delayDuration={150}>
<button
type="button"
onClick={() => submit()}
className="group group inline-flex h-6 w-20 items-center gap-1.5"
>
<Tooltip.Trigger asChild>
<span className="inline-flex h-6 w-6 items-center justify-center rounded group-hover:bg-zinc-800">
<RepostIcon className="h-4 w-4 text-zinc-400 group-hover:text-blue-400" />
</span>
</Tooltip.Trigger>
<span className="text-base leading-none text-zinc-400 group-hover:text-zinc-100">
{compactNumber.format(reposts)}
</span>
</button>
<Tooltip.Portal>
<Tooltip.Content
className="-left-10 select-none rounded-md bg-zinc-800/80 px-3.5 py-1.5 text-sm leading-none text-zinc-100 backdrop-blur-lg 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"
sideOffset={5}
>
Repost
<Tooltip.Arrow className="fill-zinc-800/80 backdrop-blur-lg" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}

View File

@ -1,34 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { ZapIcon } from '@shared/icons';
import { compactNumber } from '@utils/number';
export function NoteZap({ zaps }: { zaps: number }) {
return (
<Tooltip.Root delayDuration={150}>
<button
type="button"
className="group group inline-flex h-6 w-20 items-center gap-1.5"
>
<Tooltip.Trigger asChild>
<span className="inline-flex h-6 w-6 items-center justify-center rounded group-hover:bg-zinc-800">
<ZapIcon className="h-4 w-4 text-zinc-400 group-hover:text-orange-400" />
</span>
</Tooltip.Trigger>
<span className="text-base leading-none text-zinc-400 group-hover:text-zinc-100">
{compactNumber.format(zaps)}
</span>
</button>
<Tooltip.Portal>
<Tooltip.Content
className="-left-10 select-none rounded-md bg-zinc-800/80 px-3.5 py-1.5 text-sm leading-none text-zinc-100 backdrop-blur-lg 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"
sideOffset={5}
>
Coming Soon
<Tooltip.Arrow className="fill-zinc-800/80 backdrop-blur-lg" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}

View File

@ -1,89 +0,0 @@
import { useMemo } from 'react';
import { Kind1 } from '@shared/notes/contents/kind1';
import { Kind1063 } from '@shared/notes/contents/kind1063';
import { NoteMetadata } from '@shared/notes/metadata';
import { NoteParent } from '@shared/notes/parent';
import { Repost } from '@shared/notes/repost';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
interface Note {
event: LumeEvent;
skipMetadata?: boolean;
}
export function Note({ event, skipMetadata = false }: Note) {
const isRepost = event.kind === 6;
const renderParent = useMemo(() => {
if (!isRepost && event.parent_id && event.parent_id !== event.event_id) {
return <NoteParent id={event.parent_id} />;
} else {
return null;
}
}, [event.parent_id]);
const renderRepost = useMemo(() => {
if (isRepost) {
return <Repost event={event} />;
} else {
return null;
}
}, [event.kind]);
const renderContent = useMemo(() => {
switch (event.kind) {
case 1: {
const content = parser(event);
return <Kind1 content={content} />;
}
case 6:
return null;
case 1063:
return <Kind1063 metadata={event.tags} />;
default:
return (
<div className="flex flex-col gap-2">
<div className="inline-flex flex-col gap-1 rounded-md bg-zinc-800 px-2 py-2">
<span className="text-sm font-medium leading-none text-zinc-500">
Kind: {event.kind}
</span>
<p className="text-sm leading-none text-fuchsia-500">
Lume isn&apos;t fully support this kind in newsfeed
</p>
</div>
<div className="select-text whitespace-pre-line break-words text-base text-zinc-100">
<p>{event.content}</p>
</div>
</div>
);
}
}, [event.kind]);
return (
<div className="h-min w-full px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
{renderParent}
<div className="flex flex-col">
<User pubkey={event.pubkey} time={event.created_at} repost={isRepost} />
<div className="-mt-6 pl-[49px]">
{renderContent}
{!isRepost && !skipMetadata ? (
<NoteMetadata
id={event.event_id}
rootID={event.parent_id}
eventPubkey={event.pubkey}
/>
) : (
<div className={isRepost ? 'h-0' : 'h-3'} />
)}
</div>
</div>
{renderRepost}
</div>
</div>
);
}

View File

@ -1,46 +0,0 @@
import { Kind1 } from '@shared/notes/contents/kind1';
import { Kind1063 } from '@shared/notes/contents/kind1063';
import { NoteMetadata } from '@shared/notes/metadata';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
export function NoteParent({ id }: { id: string }) {
const { status, data } = useEvent(id);
return (
<div className="relative flex flex-col pb-6">
<div className="absolute left-[18px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
{status === 'loading' ? (
<NoteSkeleton />
) : status === 'success' ? (
<>
<User pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-6 pl-[49px]">
{data.kind === 1 && <Kind1 content={data.content} />}
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
{data.kind !== 1 && data.kind !== 1063 && (
<div className="flex flex-col gap-2">
<div className="inline-flex flex-col gap-1 rounded-md bg-zinc-800 px-2 py-2">
<span className="text-sm font-medium leading-none text-zinc-500">
Kind: {data.kind}
</span>
<p className="text-sm leading-none text-fuchsia-500">
Lume isn&apos;t fully support this kind in newsfeed
</p>
</div>
<div className="select-text whitespace-pre-line break-words text-base text-zinc-100">
<p>{data.content || data.toString()}</p>
</div>
</div>
)}
<NoteMetadata id={data.event_id || data.id} eventPubkey={data.pubkey} />
</div>
</>
) : (
<p>Failed to fetch event</p>
)}
</div>
);
}

View File

@ -2,7 +2,7 @@ import { Image } from '@shared/image';
export function ImagePreview({ urls, truncate }: { urls: string[]; truncate?: boolean }) {
return (
<div className="mt-3 max-w-[420px] overflow-hidden">
<div className="mb-2 mt-3 max-w-[420px] overflow-hidden">
<div className="flex flex-col gap-2">
{urls.map((url) => (
<div key={url} className="relative min-w-0 shrink-0 grow-0 basis-full">

View File

@ -3,11 +3,11 @@ import { Image } from '@shared/image';
import { useOpenGraph } from '@utils/hooks/useOpenGraph';
export function LinkPreview({ urls }: { urls: string[] }) {
const domain = new URL(urls[0]);
const { status, data, error } = useOpenGraph(urls[0]);
const domain = new URL(urls[0]);
return (
<div className="mt-3 max-w-[420px] overflow-hidden rounded-lg bg-zinc-800">
<div className="mb-2 mt-3 max-w-[420px] overflow-hidden rounded-lg bg-zinc-800">
{status === 'loading' ? (
<div className="flex flex-col">
<div className="h-44 w-full animate-pulse bg-zinc-700" />
@ -21,7 +21,7 @@ export function LinkPreview({ urls }: { urls: string[] }) {
</div>
) : (
<a
className="flex flex-col rounded-lg border border-zinc-800/50"
className="flex flex-col rounded-lg border-t border-zinc-700/50"
href={urls[0]}
target="_blank"
rel="noreferrer"
@ -34,12 +34,14 @@ export function LinkPreview({ urls }: { urls: string[] }) {
</div>
) : (
<>
<Image
src={data.images?.[0] || 'https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW'}
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt={urls[0]}
className="h-44 w-full rounded-t-lg object-cover"
/>
{data.images?.[0] && (
<Image
src={data.images?.[0] || 'https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW'}
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt={urls[0]}
className="h-44 w-full rounded-t-lg object-cover"
/>
)}
<div className="flex flex-col gap-2 px-3 py-3">
<h5 className="line-clamp-1 font-medium leading-none text-zinc-200">
{data.title}

View File

@ -2,7 +2,7 @@ import ReactPlayer from 'react-player/es6';
export function VideoPreview({ urls }: { urls: string[] }) {
return (
<div className="relative mt-3 flex w-full max-w-[420px] flex-col gap-2">
<div className="relative mb-2 mt-3 flex w-full max-w-[420px] flex-col gap-2">
{urls.map((url) => (
<ReactPlayer
key={url}

View File

@ -7,21 +7,16 @@ import { DEFAULT_AVATAR, FULL_RELAYS } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { usePublish } from '@utils/hooks/usePublish';
import { shortenKey } from '@utils/shortenKey';
import { displayNpub } from '@utils/shortenKey';
export function NoteReplyForm({
rootID,
userPubkey,
}: {
rootID: string;
userPubkey: string;
}) {
export function NoteReplyForm({ id, pubkey }: { id: string; pubkey: string }) {
const publish = usePublish();
const { status, user } = useProfile(userPubkey);
const { status, user } = useProfile(pubkey);
const [value, setValue] = useState('');
const submit = () => {
const tags = [['e', rootID, FULL_RELAYS[0], 'root']];
const tags = [['e', id, FULL_RELAYS[0], 'reply']];
// publish event
publish({ content: value, kind: 1, tags });
@ -31,36 +26,36 @@ export function NoteReplyForm({
};
return (
<div className="flex flex-col">
<div className="flex flex-col rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="relative w-full flex-1 overflow-hidden">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this thread..."
className="relative h-20 w-full resize-none rounded-md bg-transparent px-5 py-3 text-base !outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
className=" relative h-24 w-full resize-none rounded-md bg-transparent px-3 py-3 text-base !outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
spellCheck={false}
/>
</div>
<div className="w-full border-t border-zinc-800 px-5 py-3">
<div className="w-full border-t border-zinc-800 px-3 py-3">
{status === 'loading' ? (
<div>
<p>Loading...</p>
</div>
) : (
<div className="flex w-full items-center justify-between">
<div className="inline-flex items-center gap-2">
<div className="relative h-9 w-9 shrink-0 rounded">
<div className="inline-flex items-center gap-3">
<div className="relative h-11 w-11 shrink-0 rounded">
<Image
src={user.image}
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={userPubkey}
className="h-9 w-9 rounded-md bg-white object-cover"
alt={pubkey}
className="h-11 w-11 rounded-lg bg-white object-cover"
/>
</div>
<div>
<p className="mb-px text-sm leading-none text-zinc-400">Reply as</p>
<p className="mb-1 text-sm leading-none text-zinc-400">Reply as</p>
<p className="text-sm font-medium leading-none text-zinc-100">
{user.nip05 || user.name || shortenKey(userPubkey)}
{user?.nip05 || user?.name || displayNpub(pubkey, 16)}
</p>
</div>
</div>

View File

@ -1,19 +1,33 @@
import { Kind1 } from '@shared/notes/contents/kind1';
import { NoteMetadata } from '@shared/notes/metadata';
import { useMemo } from 'react';
import { NoteActions, NoteContent, SubReply } from '@shared/notes';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function Reply({ data }: { data: any }) {
const content = parser(data);
export function Reply({ event }: { event: LumeEvent }) {
const content = useMemo(() => parser(event), [event]);
return (
<div className="mb-3 flex h-min min-h-min w-full select-text flex-col rounded-md bg-zinc-900 px-3 pt-5">
<div className="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[20px] pl-[50px]">
<Kind1 content={content} />
<NoteMetadata id={data.event_id} eventPubkey={data.pubkey} />
<div className="h-min w-full py-1.5">
<div className="relative overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<div className="relative flex flex-col">
<User pubkey={event.pubkey} time={event.created_at} />
<div className="relative z-20 -mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="flex-1">
<NoteContent content={content} />
<NoteActions id={event.event_id || event.id} pubkey={event.pubkey} />
</div>
</div>
<div>
{event.replies ? (
event.replies.map((sub) => <SubReply key={sub.id} event={sub} />)
) : (
<div className="pb-3" />
)}
</div>
</div>
</div>
</div>

View File

@ -1,34 +1,66 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { getReplies } from '@libs/storage';
import { useNDK } from '@libs/ndk/provider';
import { Reply } from '@shared/notes/replies/item';
import { NoteSkeleton, Reply } from '@shared/notes';
export function RepliesList({ parent_id }: { parent_id: string }) {
const { status, data } = useQuery(['replies', parent_id], async () => {
return await getReplies(parent_id);
import { LumeEvent } from '@utils/types';
export function RepliesList({ id }: { id: string }) {
const { relayUrls, fetcher } = useNDK();
const { status, data } = useQuery(['thread', id], async () => {
const events = (await fetcher.fetchAllEvents(
relayUrls,
{ kinds: [1], '#e': [id] },
{ since: 0 }
)) as unknown as LumeEvent[];
if (events.length > 0) {
const replies = new Set();
events.forEach((event) => {
const tags = event.tags.filter((el) => el[0] === 'e' && el[1] !== id);
if (tags.length > 0) {
tags.forEach((tag) => {
const rootIndex = events.findIndex((el) => el.id === tag[1]);
if (rootIndex) {
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;
});
if (status === 'loading') {
return (
<div className="mt-3">
<div className="flex flex-col">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
</div>
);
}
return (
<div className="mt-5">
<div className="mt-3">
<div className="mb-2">
<h5 className="text-lg font-semibold text-zinc-300">Replies</h5>
<h5 className="text-lg font-semibold text-zinc-300">{data.length} replies</h5>
</div>
<div className="flex flex-col">
{status === 'loading' ? (
<div className="flex gap-2 px-3 py-4">
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 flex-col justify-center gap-1">
<div className="flex items-baseline gap-2 text-base">
<div className="h-2.5 w-20 animate-pulse rounded-sm bg-zinc-800" />
</div>
<div className="h-4 w-44 animate-pulse rounded-sm bg-zinc-800" />
</div>
</div>
) : data.length === 0 ? (
{data?.length === 0 ? (
<div className="px=3">
<div className="flex w-full items-center justify-center rounded-md bg-zinc-900">
<div className="flex w-full items-center justify-center rounded-xl bg-zinc-900">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-zinc-400">Share your thought on it...</p>
@ -36,7 +68,7 @@ export function RepliesList({ parent_id }: { parent_id: string }) {
</div>
</div>
) : (
data.map((event: NDKEvent) => <Reply key={event.id} data={event} />)
data.reverse().map((event: NDKEvent) => <Reply key={event.id} event={event} />)
)}
</div>
</div>

View File

@ -0,0 +1,24 @@
import { useMemo } from 'react';
import { NoteActions, NoteContent } from '@shared/notes';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function SubReply({ event }: { event: LumeEvent }) {
const content = useMemo(() => parser(event), [event]);
return (
<div className="relative mb-3 mt-5 flex flex-col">
<User pubkey={event.pubkey} time={event.created_at} />
<div className="relative z-20 -mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="flex-1">
<NoteContent content={content} />
<NoteActions id={event.event_id || event.id} pubkey={event.pubkey} />
</div>
</div>
</div>
);
}

View File

@ -1,49 +0,0 @@
import { Kind1 } from '@shared/notes/contents/kind1';
import { Kind1063 } from '@shared/notes/contents/kind1063';
import { NoteMetadata } from '@shared/notes/metadata';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
import { getRepostID } from '@utils/transform';
import { LumeEvent } from '@utils/types';
export function Repost({ event }: { event: LumeEvent }) {
const repostID = getRepostID(event.tags);
const { status, data } = useEvent(repostID);
return (
<div className="relative mt-12 flex flex-col">
<div className="absolute -top-10 left-[18px] h-[50px] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
{status === 'loading' ? (
<NoteSkeleton />
) : status === 'success' ? (
<>
<User pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-6 pl-[49px]">
{data.kind === 1 && <Kind1 content={data.content} />}
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
{data.kind !== 1 && data.kind !== 1063 && (
<div className="flex flex-col gap-2">
<div className="inline-flex flex-col gap-1 rounded-md bg-zinc-800 px-2 py-2">
<span className="text-sm font-medium leading-none text-zinc-500">
Kind: {data.kind}
</span>
<p className="text-sm leading-none text-fuchsia-500">
Lume isn&apos;t fully support this kind in newsfeed
</p>
</div>
<div className="select-text whitespace-pre-line break-words text-base text-zinc-100">
<p>{data.content || data.toString()}</p>
</div>
</div>
)}
<NoteMetadata id={data.event_id || data.id} eventPubkey={data.pubkey} />
</div>
</>
) : (
<p>Failed to fetch event</p>
)}
</div>
);
}

View File

@ -2,16 +2,16 @@ export function NoteSkeleton() {
return (
<div className="flex h-min flex-col pb-3">
<div className="flex items-start gap-3">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="relative h-11 w-11 shrink overflow-hidden rounded-lg bg-zinc-700" />
<div className="flex flex-col gap-0.5">
<div className="h-4 w-20 rounded bg-zinc-700" />
<div className="h-3 w-20 rounded bg-zinc-700" />
</div>
</div>
<div className="-mt-5 animate-pulse pl-[49px]">
<div className="flex flex-col gap-1">
<div className="h-4 w-full rounded-sm bg-zinc-700" />
<div className="h-3 w-2/3 rounded-sm bg-zinc-700" />
<div className="h-3 w-1/2 rounded-sm bg-zinc-700" />
<div className="h-3 w-full rounded bg-zinc-700" />
<div className="h-3 w-2/3 rounded bg-zinc-700" />
<div className="h-3 w-1/2 rounded bg-zinc-700" />
</div>
</div>
</div>

View File

@ -0,0 +1,86 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { decode } from 'light-bolt11-decoder';
import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon } from '@shared/icons';
import { compactNumber } from '@utils/number';
export function NoteStats({ id }: { id: string }) {
const { ndk } = useNDK();
const { status, data } = useQuery(
['note-stats', id],
async () => {
let reactions = 0;
let reposts = 0;
let zaps = 0;
const filter: NDKFilter = {
'#e': [id],
kinds: [6, 7, 9735],
};
const events = await ndk.fetchEvents(filter);
events.forEach((event: NDKEvent) => {
switch (event.kind) {
case 6:
reposts += 1;
break;
case 7:
reactions += 1;
break;
case 9735: {
const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1];
if (bolt11) {
const decoded = decode(bolt11);
const amount = decoded.sections.find((item) => item.name === 'amount');
const sats = amount.value / 1000;
zaps += sats;
}
break;
}
default:
break;
}
});
return { reposts, reactions, zaps };
},
{ refetchOnWindowFocus: false, refetchOnReconnect: false }
);
if (status === 'loading') {
return (
<div className="flex h-11 items-center">
<LoaderIcon className="h-4 w-4 animate-spin text-zinc-100" />
</div>
);
}
return (
<div className="flex h-11 items-center gap-3">
<p className="text-zinc-500">
<span className="font-semibold text-zinc-300">
{compactNumber.format(data.reactions)}
</span>{' '}
reactions
</p>
<span className="text-zinc-500">·</span>
<p className="text-zinc-500">
<span className="font-semibold text-zinc-300">
{compactNumber.format(data.reposts)}
</span>{' '}
reposts
</p>
<span className="text-zinc-500">·</span>
<p className="text-zinc-500">
<span className="font-semibold text-zinc-300">
{compactNumber.format(data.zaps)}
</span>{' '}
zaps
</p>
</div>
);
}

View File

@ -0,0 +1,22 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function MiniUser({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return <div className="h-4 w-4 animate-pulse rounded bg-zinc-700"></div>;
}
return (
<Image
src={user?.picture || user?.image || DEFAULT_AVATAR}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="relative z-20 inline-block h-4 w-4 rounded bg-white ring-1 ring-zinc-800"
/>
);
}

View File

@ -0,0 +1,34 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function RepostUser({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return <div className="h-4 w-4 animate-pulse rounded bg-zinc-700"></div>;
}
return (
<div className="flex gap-2 pl-6">
<Image
src={user?.picture || user?.image || DEFAULT_AVATAR}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="relative z-20 inline-block h-6 w-6 rounded bg-white ring-1 ring-zinc-800"
/>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[18rem] truncate text-zinc-400">
{user?.nip05?.toLowerCase() ||
user?.name ||
user?.display_name ||
shortenKey(pubkey)}
</h5>
<span className="text-zinc-400">reposted</span>
</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
import { VerticalDotsIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { formatCreatedAt } from '@utils/createdAt';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ThreadUser({ pubkey, time }: { pubkey: string; time: number }) {
const { status, user } = useProfile(pubkey);
const createdAt = formatCreatedAt(time);
if (status === 'loading') {
return <div className="h-4 w-4 animate-pulse rounded bg-zinc-700"></div>;
}
return (
<div className="flex items-center gap-3">
<Image
src={user?.picture || user?.image || DEFAULT_AVATAR}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="relative z-20 inline-block h-11 w-11 rounded-lg"
/>
<div className="lex flex-1 items-baseline justify-between">
<div className="inline-flex w-full items-center justify-between">
<h5 className="truncate font-semibold leading-none text-zinc-100">
{user?.nip05?.toLowerCase() || user?.name || user?.display_name}
</h5>
<button
type="button"
className="inline-flex h-5 w-max items-center justify-center rounded px-1 hover:bg-zinc-800"
>
<VerticalDotsIcon className="h-4 w-4 rotate-90 transform text-zinc-200" />
</button>
</div>
<div className="mt-1 inline-flex items-center gap-2">
<span className="leading-none text-zinc-500">{createdAt}</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">{displayNpub(pubkey, 16)}</span>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { Fragment, useRef, useState } from 'react';
import { Fragment, useState } from 'react';
import { useNDK } from '@libs/ndk/provider';
@ -9,29 +9,26 @@ import { BellIcon, CancelIcon, LoaderIcon } from '@shared/icons';
import { NotificationUser } from '@shared/notification/user';
import { User } from '@shared/user';
import { dateToUnix, getHourAgo } from '@utils/date';
import { nHoursAgo } from '@utils/date';
import { LumeEvent } from '@utils/types';
export function NotificationModal({ pubkey }: { pubkey: string }) {
const now = useRef(new Date());
const [isOpen, setIsOpen] = useState(false);
const { ndk } = useNDK();
const { fetcher, relayUrls } = useNDK();
const { status, data } = useQuery(
['user-notification', pubkey],
['notification', pubkey],
async () => {
const filter: NDKFilter = {
'#p': [pubkey],
kinds: [1, 6, 7, 9735],
since: dateToUnix(getHourAgo(48, now.current)),
};
const events = await ndk.fetchEvents(filter);
return [...events];
const events = await fetcher.fetchAllEvents(
relayUrls,
{ '#p': [pubkey], kinds: [1, 6, 7, 9735] },
{ since: nHoursAgo(48) },
{ sort: true }
);
return events as unknown as LumeEvent[];
},
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
}
);

View File

@ -6,7 +6,7 @@ import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
export function Protected({ children }: { children: ReactNode }) {
const password = useStronghold((state) => state.password);
const privkey = useStronghold((state) => state.privkey);
const { status, account } = useAccount();
if (status === 'success' && !account) {
@ -17,7 +17,7 @@ export function Protected({ children }: { children: ReactNode }) {
return <Navigate to="/auth/migrate" replace />;
}
if (status === 'success' && account && !password) {
if (status === 'success' && account && !privkey) {
return <Navigate to="/auth/unlock" replace />;
}

View File

@ -1,12 +1,10 @@
import { CancelIcon } from '@shared/icons';
export function TitleBar({
title,
onClick = undefined,
}: {
title: string;
onClick?: () => void;
}) {
import { useBlock } from '@utils/hooks/useBlock';
export function TitleBar({ id, title }: { id?: string; title: string }) {
const { remove } = useBlock();
return (
<div
data-tauri-drag-region
@ -14,10 +12,10 @@ export function TitleBar({
>
<div className="w-6" />
<h3 className="text-sm font-medium text-zinc-200">{title}</h3>
{onClick ? (
{id ? (
<button
type="button"
onClick={onClick}
onClick={() => remove.mutate(id)}
className="inline-flex h-6 w-6 shrink translate-y-8 transform items-center justify-center rounded transition-transform duration-150 ease-in-out hover:bg-zinc-900 group-hover:translate-y-0"
>
<CancelIcon width={12} height={12} className="text-zinc-300" />

View File

@ -2,6 +2,7 @@ import { Popover, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { Link } from 'react-router-dom';
import { VerticalDotsIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
@ -14,13 +15,13 @@ export function User({
pubkey,
time,
size,
repost,
isRepost = false,
isChat = false,
}: {
pubkey: string;
time: number;
size?: string;
repost?: boolean;
isRepost?: boolean;
isChat?: boolean;
}) {
const { status, user } = useProfile(pubkey);
@ -50,9 +51,7 @@ export function User({
return (
<Popover
className={`relative flex ${
size === 'small' ? 'items-center gap-2' : 'items-start gap-3'
}`}
className={`flex ${size === 'small' ? 'items-center gap-2' : 'items-start gap-3'}`}
>
<Popover.Button
className={`${avatarWidth} ${avatarHeight} relative z-10 shrink-0 overflow-hidden bg-zinc-900`}
@ -61,24 +60,35 @@ export function User({
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className={`${avatarWidth} ${avatarHeight} ${
className={`${
isRepost ? 'ring-1 ring-zinc-800' : ''
} ${avatarWidth} ${avatarHeight} ${
size === 'small' ? 'rounded' : 'rounded-lg'
} object-cover`}
/>
</Popover.Button>
<div className="flex flex-wrap items-baseline gap-1">
<div
className={`${isRepost ? 'mt-4' : ''} flex flex-1 items-baseline justify-between`}
>
<h5
className={`truncate font-semibold leading-none text-zinc-100 ${
size === 'small' ? 'max-w-[8rem]' : 'max-w-[12rem]'
size === 'small' ? 'max-w-[10rem]' : 'max-w-[15rem]'
}`}
>
{user?.nip05 || user?.name || user?.displayName || shortenKey(pubkey)}
{user?.nip05?.toLowerCase() ||
user?.name ||
user?.display_name ||
shortenKey(pubkey)}
</h5>
{repost && (
<span className="font-semibold leading-none text-fuchsia-500"> reposted</span>
)}
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">{createdAt}</span>
<div className="inline-flex items-center gap-2">
<span className="leading-none text-zinc-500">{createdAt}</span>
<button
type="button"
className="inline-flex h-5 w-max items-center justify-center rounded px-1 hover:bg-zinc-800"
>
<VerticalDotsIcon className="h-4 w-4 rotate-90 transform text-zinc-200" />
</button>
</div>
</div>
<Transition
as={Fragment}

115
src/shared/userProfile.tsx Normal file
View File

@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { UserMetadata } from '@app/user/components/metadata';
import { ZapIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { useSocial } from '@utils/hooks/useSocial';
import { displayNpub } from '@utils/shortenKey';
export function UserProfile({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
const { status, userFollows, follow, unfollow } = useSocial();
const [followed, setFollowed] = useState(false);
const followUser = (pubkey: string) => {
try {
follow(pubkey);
// update state
setFollowed(true);
} catch (error) {
console.log(error);
}
};
const unfollowUser = (pubkey: string) => {
try {
unfollow(pubkey);
// update state
setFollowed(false);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
if (status === 'success' && userFollows) {
if (userFollows.includes(pubkey)) {
setFollowed(true);
}
}
}, [status]);
return (
<div>
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-14 w-14 rounded-md ring-2 ring-black"
/>
<div className="mt-2 flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-2">
<h5 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name || 'No name'}
</h5>
<span className="max-w-[15rem] truncate text-sm leading-none text-zinc-500">
{user?.nip05 || displayNpub(pubkey, 16)}
</span>
</div>
<div className="flex flex-col gap-4">
<p className="mt-2 max-w-[500px] select-text break-words text-zinc-100">
{user?.about}
</p>
<UserMetadata pubkey={pubkey} />
</div>
<div className="mt-4 inline-flex items-center gap-2">
{status === 'loading' ? (
<button
type="button"
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
>
Loading...
</button>
) : followed ? (
<button
type="button"
onClick={() => unfollowUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={() => followUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
>
Follow
</button>
)}
<Link
to={`/app/chat/${pubkey}`}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
>
Message
</Link>
<button
type="button"
className="group inline-flex h-10 w-10 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-orange-500"
>
<ZapIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
}

View File

@ -2,9 +2,9 @@ import { create } from 'zustand';
interface ComposerState {
open: boolean;
reply: { id: string; root: string; pubkey: string };
reply: { id: string; pubkey: string; root?: string };
toggleModal: (status: boolean) => void;
setReply: (id: string, root: string, pubkey: string) => void;
setReply: (id: string, pubkey: string) => void;
clearReply: () => void;
}
@ -14,7 +14,7 @@ export const useComposer = create<ComposerState>((set) => ({
toggleModal: (status: boolean) => {
set({ open: status });
},
setReply: (id: string, root: string, pubkey: string) => {
setReply: (id: string, pubkey: string, root?: string) => {
set({ reply: { id: id, root: root, pubkey: pubkey } });
set({ open: true });
},

View File

@ -1,4 +1,4 @@
export const APP_VERSION = '1.0.1';
export const APP_VERSION = '1.1.0';
export const DEFAULT_AVATAR = 'https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih';
@ -70,3 +70,12 @@ export const FULL_RELAYS = [
'wss://relay.nostr.band/all',
'wss://nostr.mutinywallet.com',
];
export const BLOCK_KINDS = {
image: 0,
feed: 1,
thread: 2,
hashtag: 3,
exchange_rate: 4,
user: 5,
};

View File

@ -3,11 +3,8 @@ import { create } from 'zustand';
interface OnboardingState {
profile: { [x: string]: string };
pubkey: string;
privkey: string;
createProfile: (data: { [x: string]: string }) => void;
setPubkey: (pubkey: string) => void;
setPrivkey: (privkey: string) => void;
clearPrivkey: (privkey: string) => void;
}
export const useOnboarding = create<OnboardingState>((set) => ({
@ -20,10 +17,4 @@ export const useOnboarding = create<OnboardingState>((set) => ({
setPubkey: (pubkey: string) => {
set({ pubkey: pubkey });
},
setPrivkey: (privkey: string) => {
set({ privkey: privkey });
},
clearPrivkey: () => {
set({ privkey: '' });
},
}));

Some files were not shown because too many files have changed in this diff Show More