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", "name": "lume",
"private": true, "private": true,
"version": "1.0.1", "version": "1.1.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
@ -17,67 +17,78 @@
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.15", "@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-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6", "@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.29.19", "@tanstack/react-query": "^4.32.0",
"@tanstack/react-query-devtools": "^4.29.19", "@tanstack/react-query-devtools": "^4.32.0",
"@tanstack/react-virtual": "3.0.0-beta.54", "@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.4.0", "@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", "cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.9", "dayjs": "^1.11.9",
"destr": "^1.2.2", "destr": "^1.2.2",
"framer-motion": "^10.12.18", "framer-motion": "^10.13.0",
"get-urls": "^11.0.0", "get-urls": "^11.0.0",
"html-to-text": "^9.0.5",
"immer": "^10.0.2", "immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0", "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": "^18.2.0",
"react-dom": "^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-hotkeys-hook": "^4.4.1",
"react-markdown": "^8.0.7",
"react-player": "^2.12.0", "react-player": "^2.12.0",
"react-router-dom": "^6.14.1", "react-router-dom": "^6.14.2",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"react-virtuoso": "^4.3.11", "react-virtuoso": "^4.4.1",
"slate": "^0.94.1", "remark-gfm": "^3.0.1",
"slate-history": "^0.93.0", "tailwind-merge": "^1.14.0",
"slate-react": "^0.94.2",
"tailwind-merge": "^1.13.2",
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1", "tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql", "tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1", "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" "zustand": "^4.3.9"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.4.0", "@tauri-apps/cli": "^1.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.1.1", "@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/node": "^18.16.19", "@types/node": "^18.16.20",
"@types/react": "^18.2.14", "@types/react": "^18.2.15",
"@types/react-dom": "^18.2.6", "@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7", "@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.61.0", "@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react-swc": "^3.3.2", "@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"csstype": "^3.1.2", "csstype": "^3.1.2",
"encoding": "^0.1.13", "encoding": "^0.1.13",
"eslint": "^8.44.0", "eslint": "^8.45.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-jsx-a11y": "^6.7.1", "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", "eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^13.2.3", "lint-staged": "^13.2.3",
"postcss": "^8.4.25", "postcss": "^8.4.27",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0", "prettier-plugin-tailwindcss": "^0.3.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.3",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vite": "^4.4.2", "vite": "^4.4.6",
"vite-plugin-top-level-await": "^1.3.1", "vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0" "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] [package]
name = "lume" name = "lume"
version = "1.0.1" version = "1.1.0"
description = "nostr client" description = "nostr client"
authors = ["Ren Amamiya"] authors = ["Ren Amamiya"]
license = "" license = ""
@ -16,10 +16,11 @@ tauri-build = { version = "1.2", features = [] }
[dependencies] [dependencies]
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } 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-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-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-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"] } sqlx-cli = {version = "0.7.0", default-features = false, features = ["sqlite"] }
rust-argon2 = "1.0" rust-argon2 = "1.0"
rand = "0.8.5" 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"), sql: include_str!("../migrations/20230619082415_add_replies.sql"),
kind: MigrationKind::Up, kind: MigrationKind::Up,
}, },
Migration {
version: 20230718072634,
description: "clean up",
sql: include_str!("../migrations/20230718072634_clean_up_old_tables.sql"),
kind: MigrationKind::Up,
},
], ],
) )
.build(), .build(),
@ -115,8 +121,8 @@ fn main() {
tauri_plugin_stronghold::Builder::new(|password| { tauri_plugin_stronghold::Builder::new(|password| {
let config = argon2::Config { let config = argon2::Config {
lanes: 2, lanes: 2,
mem_cost: 50_000, mem_cost: 10_000,
time_cost: 30, time_cost: 10,
thread_mode: argon2::ThreadMode::from_threads(2), thread_mode: argon2::ThreadMode::from_threads(2),
variant: argon2::Variant::Argon2id, variant: argon2::Variant::Argon2id,
..Default::default() ..Default::default()
@ -144,6 +150,7 @@ fn main() {
.emit_all("single-instance", Payload { args: argv, cwd }) .emit_all("single-instance", Payload { args: argv, cwd })
.unwrap(); .unwrap();
})) }))
.plugin(tauri_plugin_upload::init())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@ -8,7 +8,7 @@
}, },
"package": { "package": {
"productName": "Lume", "productName": "Lume",
"version": "1.0.1" "version": "1.1.0"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {
@ -28,6 +28,7 @@
"all": false, "all": false,
"readFile": true, "readFile": true,
"readDir": true, "readDir": true,
"writeFile": true,
"scope": [ "scope": [
"$APPDATA/*", "$APPDATA/*",
"$DATA/*", "$DATA/*",
@ -62,7 +63,8 @@
}, },
"window": { "window": {
"startDragging": true, "startDragging": true,
"close": true "close": true,
"create": true
}, },
"process": { "process": {
"all": false, "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="flex items-center gap-2">
<div className="relative h-10 w-10 shrink rounded-md"> <div className="relative h-10 w-10 shrink rounded-md">
<Image <Image
src={user.picture || user.image} src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
className="h-10 w-10 rounded-md object-cover" className="h-10 w-10 rounded-md object-cover"
@ -32,10 +32,10 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
</div> </div>
<div className="flex w-full flex-1 flex-col items-start text-start"> <div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-100"> <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>
<span className="text-base leading-tight text-zinc-400"> <span className="text-base leading-tight text-zinc-400">
{user.nip05?.toLowerCase() || shortenKey(pubkey)} {user?.nip05?.toLowerCase() || shortenKey(pubkey)}
</span> </span>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { BaseDirectory, writeTextFile } from '@tauri-apps/api/fs';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'; import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -19,6 +20,7 @@ export function CreateStep1Screen() {
const [privkeyInput, setPrivkeyInput] = useState('password'); const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []); const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey); 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({ const account = useMutation({
mutationFn: (data: { mutationFn: (data: {
npub: string; npub: string;
@ -68,9 +81,7 @@ export function CreateStep1Screen() {
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100"> <h1 className="text-xl font-semibold text-zinc-100">Save your access key!</h1>
Lume is auto-generated key for you
</h1>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -110,14 +121,26 @@ export function CreateStep1Screen() {
)} )}
</button> </button>
</div> </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> </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>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -21,8 +21,7 @@ export function OnboardingScreen() {
// publish event // publish event
publish({ publish({
content: content: 'Running Lume, join with me: https://lume.nu',
'Running Lume, fighting for better future, join us here: https://lume.nu',
kind: 1, kind: 1,
tags: [], tags: [],
}); });
@ -37,15 +36,16 @@ export function OnboardingScreen() {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md"> <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"> <h1 className="mb-2 text-xl font-semibold text-zinc-100">
👋 Hello, welcome you to Lume 👋 Hello, welcome you to Lume
</h1> </h1>
<p className="text-sm text-zinc-300"> <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>
<p className="text-sm text-zinc-300"> <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> </p>
</div> </div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900"> <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)} /> <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"> <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>Running Lume, join with me</p>
<p> <a
join us here:{' '} href="https://lume.nu"
<a className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
href="https://lume.nu" target="_blank"
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600" rel="noreferrer"
target="_blank" >
rel="noreferrer" https://lume.nu
> </a>
https://lume.nu
</a>
</p>
</div> </div>
</div> </div>
</div> </div>
@ -84,16 +81,16 @@ export function OnboardingScreen() {
) : ( ) : (
<> <>
<span className="w-5" /> <span className="w-5" />
<span>Publish</span> <span>Spread</span>
<ArrowRightCircleIcon className="h-5 w-5" /> <ArrowRightCircleIcon className="h-5 w-5" />
</> </>
)} )}
</button> </button>
<Link <Link
to="/" 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> </Link>
</div> </div>
</div> </div>

View File

@ -29,13 +29,10 @@ const resolver: Resolver<FormValues> = async (values) => {
export function UnlockScreen() { export function UnlockScreen() {
const navigate = useNavigate(); const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const [passwordInput, setPasswordInput] = useState('password'); const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [setPrivkey, setPassword] = useStronghold((state) => [
state.setPrivkey,
state.setPassword,
]);
const { account } = useAccount(); const { account } = useAccount();
const { load } = useSecureStorage(); const { load } = useSecureStorage();
@ -59,9 +56,6 @@ export function UnlockScreen() {
const onSubmit = async (data: { [x: string]: string }) => { const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true); setLoading(true);
if (data.password.length > 3) { if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
// load private in secure storage // load private in secure storage
try { try {
const privkey = await load(account.pubkey, data.password); const privkey = await load(account.pubkey, data.password);
@ -99,7 +93,7 @@ export function UnlockScreen() {
<input <input
{...register('password', { required: true })} {...register('password', { required: true })}
type={passwordInput} 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 <button
type="button" type="button"

View File

@ -6,7 +6,10 @@ import { ChatsListSelfItem } from '@app/chat/components/self';
import { getChats } from '@libs/storage'; import { getChats } from '@libs/storage';
import { StrangersIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount'; import { useAccount } from '@utils/hooks/useAccount';
import { compactNumber } from '@utils/number';
export function ChatsList() { export function ChatsList() {
const { account } = useAccount(); const { account } = useAccount();
@ -15,11 +18,7 @@ export function ChatsList() {
data: chats, data: chats,
isFetching, isFetching,
} = useQuery(['chats'], async () => { } = useQuery(['chats'], async () => {
const chats = await getChats(); return await getChats();
const sorted = chats.sort(
(a, b) => parseInt(a.new_messages) - parseInt(b.new_messages)
);
return sorted;
}); });
if (status === 'loading') { if (status === 'loading') {
@ -39,7 +38,6 @@ export function ChatsList() {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<NewMessageModal />
{account ? ( {account ? (
<ChatsListSelfItem data={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 className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </div>
)} )}
{chats.map((item) => { {chats.follows.map((item) => {
if (account.pubkey !== item.sender_pubkey) { if (account.pubkey !== item.sender_pubkey) {
return <ChatsListItem key={item.sender_pubkey} data={item} />; 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 && ( {isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <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" /> <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" 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"> <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>
<div> <div>
<h5 className="font-medium text-zinc-400">New chat</h5> <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 { useParams } from 'react-router-dom';
import { useLiveThread } from '@app/space/hooks/useLiveThread'; 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 { NoteMetadata } from '@shared/notes/metadata';
import { NoteReplyForm } from '@shared/notes/replies/form'; import { NoteReplyForm } from '@shared/notes/replies/form';
import { RepliesList } from '@shared/notes/replies/list'; import { RepliesList } from '@shared/notes/replies/list';
@ -14,16 +9,12 @@ import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { useAccount } from '@utils/hooks/useAccount'; import { useAccount } from '@utils/hooks/useAccount';
import { parser } from '@utils/parser'; import { useEvent } from '@utils/hooks/useEvent';
export function NoteScreen() { export function NoteScreen() {
const { id } = useParams(); const { id } = useParams();
const { account } = useAccount(); const { account } = useAccount();
const { status, data } = useQuery(['thread', id], async () => { const { status, data } = useEvent(id);
const res = await getNoteByID(id);
res['content'] = parser(res);
return res;
});
useLiveThread(id); useLiveThread(id);
@ -41,9 +32,7 @@ export function NoteScreen() {
<div className="rounded-md bg-zinc-900 px-5 pt-5"> <div className="rounded-md bg-zinc-900 px-5 pt-5">
<User pubkey={data.pubkey} time={data.created_at} /> <User pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3"> <div className="mt-3">
{data.kind === 1 && <Kind1 content={data.content} />} <NoteMetadata id={data.event_id || id} />
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
<NoteMetadata id={data.event_id || id} eventPubkey={data.pubkey} />
</div> </div>
</div> </div>
<div className="mt-3 rounded-md bg-zinc-900"> <div className="mt-3 rounded-md bg-zinc-900">
@ -52,7 +41,7 @@ export function NoteScreen() {
</div> </div>
)} )}
<div className="px-3"> <div className="px-3">
<RepliesList parent_id={id} /> <RepliesList id={id} />
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { open } from '@tauri-apps/api/dialog'; import { open } from '@tauri-apps/api/dialog';
import { Body, fetch } from '@tauri-apps/api/http'; 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 { useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useNDK } from '@libs/ndk/provider';
import { createBlock } from '@libs/storage'; import { createBlock } from '@libs/storage';
import { CancelIcon, CommandIcon } from '@shared/icons'; import { CancelIcon, CommandIcon } from '@shared/icons';
import { Image } from '@shared/image'; 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 { ADD_IMAGEBLOCK_SHORTCUT } from '@stores/shortcuts';
import { createBlobFromFile } from '@utils/createBlobFromFile'; import { createBlobFromFile } from '@utils/createBlobFromFile';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
import { usePublish } from '@utils/hooks/usePublish'; import { usePublish } from '@utils/hooks/usePublish';
export function AddImageBlock() { export function AddImageBlock() {
@ -29,9 +25,6 @@ export function AddImageBlock() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState(''); const [image, setImage] = useState('');
const { ndk } = useNDK();
const { account } = useAccount();
const tags = useRef(null); const tags = useRef(null);
const openModal = () => { const openModal = () => {
@ -101,7 +94,7 @@ export function AddImageBlock() {
}; };
const block = useMutation({ const block = useMutation({
mutationFn: (data: any) => { mutationFn: (data: { kind: number; title: string; content: string }) => {
return createBlock(data.kind, data.title, data.content); return createBlock(data.kind, data.title, data.content);
}, },
onSuccess: () => { 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); setLoading(true);
// publish file metedata // publish file metedata
await publish({ content: data.title, kind: 1063, tags: tags.current }); await publish({ content: data.title, kind: 1063, tags: tags.current });
// mutate // 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); setLoading(false);
// reset form // 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 { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
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 { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar'; import { TitleBar } from '@shared/titleBar';
import { Block, LumeEvent } from '@utils/types';
const ITEM_PER_PAGE = 10; const ITEM_PER_PAGE = 10;
export function FeedBlock({ params }: { params: any }) { export function FeedBlock({ params }: { params: Block }) {
const queryClient = useQueryClient();
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } = const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ['newsfeed', params.content], queryKey: ['newsfeed', params.content],
@ -22,9 +23,9 @@ export function FeedBlock({ params }: { params: any }) {
getNextPageParam: (lastPage) => lastPage.nextCursor, 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 parentRef = useRef();
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length, count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
@ -46,29 +47,74 @@ export function FeedBlock({ params }: { params: any }) {
} }
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]); }, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const block = useMutation({ const renderItem = useCallback(
mutationFn: (id: string) => { (index: string | number) => {
return removeBlock(id); 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: () => { [notes]
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>
);
};
return ( return (
<div className="w-[400px] shrink-0 border-r border-zinc-900"> <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 <div
ref={parentRef} 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" 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)`, }px)`,
}} }}
> >
{rowVirtualizer {itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
</div> </div>
</div> </div>
)} )}

View File

@ -1,18 +1,21 @@
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useNewsfeed } from '@app/space/hooks/useNewsfeed'; import { useNewsfeed } from '@app/space/hooks/useNewsfeed';
import { getNotes } from '@libs/storage'; 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 { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar'; import { TitleBar } from '@shared/titleBar';
import { useNote } from '@stores/note'; import { useNote } from '@stores/note';
import { LumeEvent } from '@utils/types';
const ITEM_PER_PAGE = 10; const ITEM_PER_PAGE = 10;
export function FollowingBlock() { export function FollowingBlock() {
@ -33,7 +36,7 @@ export function FollowingBlock() {
getNextPageParam: (lastPage) => lastPage.nextCursor, 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 parentRef = useRef();
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
@ -66,19 +69,76 @@ export function FollowingBlock() {
toggleHasNewNote(false); toggleHasNewNote(false);
}; };
const renderItem = (index: string | number) => { const renderItem = useCallback(
const note = notes[index]; (index: string | number) => {
if (!note) return; const note: LumeEvent = notes[index];
return ( if (!note) return;
<div switch (note.kind) {
key={note.event_id || note.id} case 1: {
data-index={index} let root: string;
ref={rowVirtualizer.measureElement} let reply: string;
> if (note.tags?.[0]?.[0] === 'e' && !note.tags?.[0]?.[3]) {
<Note event={note} /> root = note.tags[0][1];
</div> } 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 ( return (
<div className="relative w-[400px] shrink-0 border-r border-zinc-900"> <div className="relative w-[400px] shrink-0 border-r border-zinc-900">
@ -138,9 +198,7 @@ export function FollowingBlock() {
}px)`, }px)`,
}} }}
> >
{rowVirtualizer {itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
</div> </div>
</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 { CancelIcon } from '@shared/icons';
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants'; import { DEFAULT_AVATAR } from '@stores/constants';
export function ImageBlock({ params }: { params: any }) { import { useBlock } from '@utils/hooks/useBlock';
const queryClient = useQueryClient(); import { Block } from '@utils/types';
const block = useMutation({ export function ImageBlock({ params }: { params: Block }) {
mutationFn: (id: string) => { const { remove } = useBlock();
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
return ( return (
<div className="flex h-full w-[350px] shrink-0 flex-col justify-between border-r border-zinc-900"> <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> <h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
<button <button
type="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" 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" /> <CancelIcon width={16} height={16} className="text-white" />

View File

@ -1,76 +1,53 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; // import { useLiveThread } from '@app/space/hooks/useLiveThread';
import { Link } from 'react-router-dom'; import {
NoteActions,
import { useLiveThread } from '@app/space/hooks/useLiveThread'; NoteContent,
NoteReplyForm,
import { getNoteByID, removeBlock } from '@libs/storage'; NoteStats,
ThreadUser,
import { Kind1 } from '@shared/notes/contents/kind1'; } from '@shared/notes';
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'; import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton'; import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar'; import { TitleBar } from '@shared/titleBar';
import { User } from '@shared/user';
import { useAccount } from '@utils/hooks/useAccount'; import { useAccount } from '@utils/hooks/useAccount';
import { parser } from '@utils/parser'; import { useEvent } from '@utils/hooks/useEvent';
import { Block } from '@utils/types';
export function ThreadBlock({ params }: { params: any }) {
useLiveThread(params.content);
const queryClient = useQueryClient();
export function ThreadBlock({ params }: { params: Block }) {
const { status, data } = useEvent(params.content);
const { account } = useAccount(); 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({ // subscribe to live reply
mutationFn: (id: string) => { // useLiveThread(params.content);
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
return ( return (
<div className="w-[400px] shrink-0 border-r border-zinc-900"> <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 className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pb-20 pt-1.5"> <div className="scrollbar-hide flex h-full w-full flex-col gap-3 overflow-y-auto pb-20 pt-1.5">
{status === 'loading' ? ( {status === 'loading' ? (
<div className="px-3 py-1.5"> <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 /> <NoteSkeleton />
</div> </div>
</div> </div>
) : ( ) : (
<div className="h-min w-full px-3 py-1.5"> <div className="h-min w-full px-3 pt-1.5">
<div className="rounded-md bg-zinc-900 px-5 pt-5"> <div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
<User pubkey={data.pubkey} time={data.created_at} /> <ThreadUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3"> <div className="mt-2">
{data.kind === 1 && <Kind1 content={data.content} />} <NoteContent content={data.content} />
{data.kind === 1063 && <Kind1063 metadata={data.tags} />} </div>
<NoteMetadata <div>
id={data.event_id || params.content} <NoteActions id={data.id} pubkey={data.pubkey} noOpenThread={true} />
eventPubkey={data.pubkey} <NoteStats id={data.id} />
/>
<Link to={`/app/note/${params.content}`}>Focus</Link>
</div> </div>
</div>
<div className="mt-3 rounded-md bg-zinc-900">
{account && (
<NoteReplyForm rootID={params.content} userPubkey={account.pubkey} />
)}
</div> </div>
</div> </div>
)} )}
<div className="px-3"> <div className="px-3">
<RepliesList parent_id={params.content} /> <NoteReplyForm id={params.content} pubkey={account.pubkey} />
<RepliesList id={params.content} />
</div> </div>
</div> </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(() => { useEffect(() => {
if (status === 'success' && account) { if (status === 'success' && account) {
const follows = account ? JSON.parse(account.follows) : []; const follows = account ? JSON.parse(account.follows as string) : [];
const filter: NDKFilter = { const filter: NDKFilter = {
kinds: [1, 6], kinds: [1, 6],
@ -30,7 +30,6 @@ export function useNewsfeed() {
sub.current = ndk.subscribe(filter, { closeOnEose: false }); sub.current = ndk.subscribe(filter, { closeOnEose: false });
sub.current.addListener('event', (event: NDKEvent) => { sub.current.addListener('event', (event: NDKEvent) => {
console.log('new note: ', event);
// add to db // add to db
createNote( createNote(
event.id, event.id,
@ -46,7 +45,9 @@ export function useNewsfeed() {
} }
return () => { return () => {
sub.current.stop(); if (sub.current) {
sub.current.stop();
}
}; };
}, [status]); }, [status]);
} }

View File

@ -1,8 +1,11 @@
import { useQuery } from '@tanstack/react-query'; 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 { AddBlock } from '@app/space/components/add';
import { FeedBlock } from '@app/space/components/blocks/feed'; import { FeedBlock } from '@app/space/components/blocks/feed';
import { FollowingBlock } from '@app/space/components/blocks/following'; 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 { ImageBlock } from '@app/space/components/blocks/image';
import { ThreadBlock } from '@app/space/components/blocks/thread'; import { ThreadBlock } from '@app/space/components/blocks/thread';
@ -10,6 +13,8 @@ import { getBlocks } from '@libs/storage';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { Block } from '@utils/types';
export function SpaceScreen() { export function SpaceScreen() {
const { const {
status, 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 ( return (
<div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden"> <div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
<FollowingBlock /> <FollowingBlock />
@ -43,18 +68,7 @@ export function SpaceScreen() {
</div> </div>
</div> </div>
) : ( ) : (
blocks.map((block: { kind: number; id: string }) => { blocks.map((block: Block) => renderBlock(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} />;
default:
break;
}
})
)} )}
{isFetching && ( {isFetching && (
<div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900"> <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 { useQuery } from '@tanstack/react-query';
import { Note } from '@shared/notes/note'; import { NoteKind_1 } from '@shared/notes';
import { NoteSkeleton } from '@shared/notes/skeleton'; import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar'; import { TitleBar } from '@shared/titleBar';
@ -27,7 +27,7 @@ export function TrendingNotes() {
) : ( ) : (
<div className="relative flex w-full flex-col pt-1.5"> <div className="relative flex w-full flex-col pt-1.5">
{data.notes.map((item) => ( {data.notes.map((item) => (
<Note key={item.id} event={item.event} skipMetadata={true} /> <NoteKind_1 key={item.id} event={item.event} skipMetadata={true} />
))} ))}
</div> </div>
)} )}

View File

@ -1,34 +1,84 @@
import { NDKFilter } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useNDK } from '@libs/ndk/provider'; 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'; import { LumeEvent } from '@utils/types';
export function UserFeed({ pubkey }: { pubkey: string }) { 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 { status, data } = useQuery(['user-feed', pubkey], async () => {
const now = new Date(); const events = await fetcher.fetchAllEvents(
const filter: NDKFilter = { relayUrls,
kinds: [1], { kinds: [1], authors: [pubkey] },
authors: [pubkey], { since: nHoursAgo(48) },
since: dateToUnix(getHourAgo(48, now)), { sort: true }
}; );
const events = await ndk.fetchEvents(filter); return events as unknown as LumeEvent[];
return [...events];
}); });
const rowVirtualizer = useVirtualizer({
count: data ? data.length : 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
return ( 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' ? ( {status === 'loading' ? (
<div className="px-3"> <div className="px-3 py-1.5">
<p>Loading...</p> <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>
) : ( ) : (
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> </div>
); );

View File

@ -19,13 +19,13 @@ export function UserMetadata({ pubkey }: { pubkey: string }) {
<div className="flex w-full items-center gap-10"> <div className="flex w-full items-center gap-10">
<div className="inline-flex flex-col gap-1"> <div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100"> <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>
<span className="text-sm leading-none text-zinc-400">Followers</span> <span className="text-sm leading-none text-zinc-400">Followers</span>
</div> </div>
<div className="inline-flex flex-col gap-1"> <div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100"> <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>
<span className="text-sm leading-none text-zinc-400">Following</span> <span className="text-sm leading-none text-zinc-400">Following</span>
</div> </div>

View File

@ -15,7 +15,19 @@ button {
} }
.markdown { .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) */ /* For Webkit-based browsers (Chrome, Safari and Opera) */

View File

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

View File

@ -1,5 +1,6 @@
// source: https://github.com/nostr-dev-kit/ndk-react/ // source: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk'; import NDK from '@nostr-dev-kit/ndk';
import { NostrFetcher } from 'nostr-fetch';
import { PropsWithChildren, createContext, useContext } from 'react'; import { PropsWithChildren, createContext, useContext } from 'react';
import { NDKInstance } from '@libs/ndk/instance'; import { NDKInstance } from '@libs/ndk/instance';
@ -7,17 +8,19 @@ import { NDKInstance } from '@libs/ndk/instance';
interface NDKContext { interface NDKContext {
ndk: NDK; ndk: NDK;
relayUrls: string[]; relayUrls: string[];
fetcher: NostrFetcher;
loadNdk: (_: string[]) => void; loadNdk: (_: string[]) => void;
} }
const NDKContext = createContext<NDKContext>({ const NDKContext = createContext<NDKContext>({
ndk: new NDK({}), ndk: new NDK({}),
relayUrls: [], relayUrls: [],
fetcher: undefined,
loadNdk: undefined, loadNdk: undefined,
}); });
const NDKProvider = ({ children }: PropsWithChildren<object>) => { const NDKProvider = ({ children }: PropsWithChildren<object>) => {
const { ndk, relayUrls, loadNdk } = NDKInstance(); const { ndk, relayUrls, fetcher, loadNdk } = NDKInstance();
if (ndk) if (ndk)
return ( return (
@ -25,6 +28,7 @@ const NDKProvider = ({ children }: PropsWithChildren<object>) => {
value={{ value={{
ndk, ndk,
relayUrls, relayUrls,
fetcher,
loadNdk, 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 Database from 'tauri-plugin-sql-api';
import { parser } from '@utils/parser';
import { getParentID } from '@utils/transform'; import { getParentID } from '@utils/transform';
import { Account, Block, Chats, LumeEvent, Profile, Settings } from '@utils/types';
let db: null | Database = null; let db: null | Database = null;
@ -18,7 +20,9 @@ export async function connect(): Promise<Database> {
// get active account // get active account
export async function getActiveAccount() { export async function getActiveAccount() {
const db = await connect(); 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) { if (result.length > 0) {
return result[0]; return result[0];
} else { } else {
@ -29,9 +33,10 @@ export async function getActiveAccount() {
// get all accounts // get all accounts
export async function getAccounts() { export async function getAccounts() {
const db = await connect(); 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;' 'SELECT * FROM accounts WHERE is_active = 0 ORDER BY created_at DESC;'
); );
return result;
} }
// create account // create account
@ -49,7 +54,7 @@ export async function createAccount(
if (res) { if (res) {
await createBlock( await createBlock(
0, 0,
'Preserve your freedom', 'Have fun together!',
'https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv' 'https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv'
); );
} }
@ -80,7 +85,7 @@ export async function countTotalChannels() {
// count total notes // count total notes
export async function countTotalNotes() { export async function countTotalNotes() {
const db = await connect(); 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);' 'SELECT COUNT(*) AS "total" FROM notes WHERE kind IN (1, 6);'
); );
return parseInt(result[0].total); return parseInt(result[0].total);
@ -92,11 +97,19 @@ export async function getNotes(limit: number, offset: number) {
const totalNotes = await countTotalNotes(); const totalNotes = await countTotalNotes();
const nextCursor = offset + limit; const nextCursor = offset + limit;
const notes: any = { data: null, nextCursor: 0 }; const notes: { data: LumeEvent[] | null; nextCursor: number } = {
const query: any = await db.select( 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}";` `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['data'] = query;
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined; 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 // get all notes by pubkey
export async function getNotesByPubkey(pubkey: string) { export async function getNotesByPubkey(pubkey: string) {
const db = await connect(); 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;` `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 // get all notes by authors
@ -121,11 +139,19 @@ export async function getNotesByAuthors(authors: string, limit: number, offset:
const array = JSON.parse(authors); const array = JSON.parse(authors);
const finalArray = `'${array.join("','")}'`; const finalArray = `'${array.join("','")}'`;
const notes: any = { data: null, nextCursor: 0 }; const notes: { data: LumeEvent[] | null; nextCursor: number } = {
const query: any = await db.select( 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}";` `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['data'] = query;
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined; 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 // get note by id
export async function getNoteByID(event_id: string) { export async function getNoteByID(event_id: string) {
const db = await connect(); const db = await connect();
const result = await db.select(`SELECT * FROM notes WHERE event_id = "${event_id}";`); const result: LumeEvent[] = await db.select(
return result[0]; `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 // create note
@ -144,7 +178,7 @@ export async function createNote(
event_id: string, event_id: string,
pubkey: string, pubkey: string,
kind: number, kind: number,
tags: any, tags: string[][],
content: string, content: string,
created_at: number created_at: number
) { ) {
@ -161,7 +195,7 @@ export async function createNote(
// get note replies // get note replies
export async function getReplies(parent_id: string) { export async function getReplies(parent_id: string) {
const db = await connect(); 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;` `SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`
); );
return result; return result;
@ -173,7 +207,7 @@ export async function createReplyNote(
event_id: string, event_id: string,
pubkey: string, pubkey: string,
kind: number, kind: number,
tags: any, tags: string[][],
content: string, content: string,
created_at: number created_at: number
) { ) {
@ -272,11 +306,30 @@ export async function getChannelUsers(channel_id: string) {
export async function getChats() { export async function getChats() {
const db = await connect(); const db = await connect();
const account = await getActiveAccount(); 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;` `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 // get chat messages
@ -284,7 +337,7 @@ export async function getChatMessages(receiver_pubkey: string, sender_pubkey: st
const db = await connect(); const db = await connect();
let receiver = []; 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}";` `SELECT * FROM chats WHERE sender_pubkey = "${sender_pubkey}" AND receiver_pubkey = "${receiver_pubkey}";`
); );
@ -321,7 +374,9 @@ export async function createChat(
// get setting // get setting
export async function getSetting(key: string) { export async function getSetting(key: string) {
const db = await connect(); 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; return result[0]?.value;
} }
@ -334,7 +389,9 @@ export async function updateSetting(key: string, value: string | number) {
// get last login // get last login
export async function getLastLogin() { export async function getLastLogin() {
const db = await connect(); 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]) { if (result[0]) {
return parseInt(result[0].value); return parseInt(result[0].value);
} else { } 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 // get all blocks
export async function getBlocks() { export async function getBlocks() {
const db = await connect(); const db = await connect();
const activeAccount = await getActiveAccount(); const account = await getActiveAccount();
const result: any = await db.select( const result: Array<Block> = await db.select(
`SELECT * FROM blocks WHERE account_id = "${activeAccount.id}" ORDER BY created_at DESC;` `SELECT * FROM blocks WHERE account_id = "${account.id}" ORDER BY created_at DESC;`
); );
return result; return result;
} }
// create block // 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 db = await connect();
const activeAccount = await getActiveAccount(); const activeAccount = await getActiveAccount();
return await db.execute( 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) { export async function getUserMetadata(pubkey: string) {
const db = await connect(); 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]) { if (result[0]) {
return JSON.parse(result[0].content); return JSON.parse(result[0].content) as Profile;
} else { } else {
return null; return null;
} }

View File

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

View File

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

View File

@ -7,7 +7,7 @@ export function Button({
disabled = false, disabled = false,
onClick = undefined, onClick = undefined,
}: { }: {
preset: 'small' | 'publish' | 'large'; preset: 'small' | 'publish' | 'large' | 'large-alt';
children: ReactNode; children: ReactNode;
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
@ -26,6 +26,10 @@ export function Button({
preClass = preClass =
'h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600'; 'h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600';
break; 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: default:
break; 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 { useHotkeys } from 'react-hotkeys-hook';
import { Button } from '@shared/button'; import { Button } from '@shared/button';
import { Post } from '@shared/composer/types/post'; import { Composer, ComposerUser } from '@shared/composer';
import { User } from '@shared/composer/user';
import { import {
CancelIcon, CancelIcon,
ChevronDownIcon, ChevronDownIcon,
@ -17,9 +16,8 @@ import { COMPOSE_SHORTCUT } from '@stores/shortcuts';
import { useAccount } from '@utils/hooks/useAccount'; import { useAccount } from '@utils/hooks/useAccount';
export function Composer() { export function ComposerModal() {
const { account } = useAccount(); const { account } = useAccount();
const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]); const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]);
const closeModal = () => { const closeModal = () => {
@ -31,7 +29,7 @@ export function Composer() {
return ( return (
<> <>
<Button onClick={() => toggle(true)} preset="small"> <Button onClick={() => toggle(true)} preset="small">
<ComposeIcon width={14} height={14} /> <ComposeIcon className="h-4 w-4" />
Compose Compose
</Button> </Button>
<Transition appear show={open} as={Fragment}> <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"> <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 justify-between px-4 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div>{account && <User pubkey={account.pubkey} />}</div> {account && <ComposerUser pubkey={account.pubkey} />}
<span> <span>
<ChevronRightIcon <ChevronRightIcon
width={14} width={14}
@ -70,20 +68,18 @@ export function Composer() {
</span> </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"> <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 New Post
<ChevronDownIcon width={14} height={14} /> <ChevronDownIcon className="h-4 w-4" />
</div> </div>
</div> </div>
<div <button
onClick={closeModal} onClick={closeModal}
onKeyDown={closeModal} type="button"
role="button"
tabIndex={0}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800" 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" /> <CancelIcon width={16} height={16} className="text-zinc-500" />
</div> </button>
</div> </div>
{account && <Post />} <Composer />
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
</div> </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'; import { useProfile } from '@utils/hooks/useProfile';
export function User({ pubkey }: { pubkey: string }) { export function ComposerUser({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900"> <div className="h-8 w-8 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image <Image
src={user?.picture || user?.image} src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}

View File

@ -43,4 +43,7 @@ export * from './settings';
export * from './logout'; export * from './logout';
export * from './follow'; export * from './follow';
export * from './unfollow'; export * from './unfollow';
export * from './reaction';
export * from './thread';
export * from './strangers';
// @endindex // @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>) { export function RepostIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return ( 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 <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" stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="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> </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} {...props}
> >
<path <path
stroke="currentColor" fill="currentColor"
strokeLinecap="round" 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"
strokeLinejoin="round" ></path>
strokeWidth="1.5"
d="M2.75 12h18.5M2.75 5.75h18.5m-18.5 12.5h8.75"
/>
</svg> </svg>
); );
} }

View File

@ -5,7 +5,7 @@ import { twMerge } from 'tailwind-merge';
import { ChatsList } from '@app/chat/components/list'; import { ChatsList } from '@app/chat/components/list';
import { AppHeader } from '@shared/appHeader'; 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 { NavArrowDownIcon, SpaceIcon, TrendingIcon } from '@shared/icons';
import { LumeBar } from '@shared/lumeBar'; import { LumeBar } from '@shared/lumeBar';
@ -15,7 +15,7 @@ export function Navigation() {
<AppHeader /> <AppHeader />
<div className="scrollbar-hide flex flex-col gap-5 overflow-y-auto pb-20"> <div className="scrollbar-hide flex flex-col gap-5 overflow-y-auto pb-20">
<div className="inlin-lflex h-8 px-3.5"> <div className="inlin-lflex h-8 px-3.5">
<Composer /> <ComposerModal />
</div> </div>
{/* Newsfeed */} {/* Newsfeed */}
<div className="flex flex-col gap-0.5 px-1.5"> <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 { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { createBlock } from '@libs/storage'; import { MentionUser, NoteSkeleton } from '@shared/notes';
import { Kind1 } from '@shared/notes/contents/kind1';
import { Kind1063 } from '@shared/notes/contents/kind1063';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { BLOCK_KINDS } from '@stores/constants';
import { useBlock } from '@utils/hooks/useBlock';
import { useEvent } from '@utils/hooks/useEvent'; import { useEvent } from '@utils/hooks/useEvent';
export const MentionNote = memo(function MentionNote({ id }: { id: string }) { export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const queryClient = useQueryClient(); const { add } = useBlock();
const { status, data } = useEvent(id); const { status, data } = useEvent(id);
const block = useMutation({ const openThread = (event, thread: string) => {
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const openThread = (event: any, thread: string) => {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.toString().length === 0) { if (selection.toString().length === 0) {
block.mutate({ kind: 2, title: 'Thread', content: thread }); add.mutate({ kind: BLOCK_KINDS.thread, title: 'Thread', content: thread });
} else { } else {
event.stopPropagation(); event.stopPropagation();
} }
@ -38,31 +29,37 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
onKeyDown={(e) => openThread(e, id)} onKeyDown={(e) => openThread(e, id)}
role="button" role="button"
tabIndex={0} 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' ? ( {status === 'loading' ? (
<NoteSkeleton /> <NoteSkeleton />
) : status === 'success' ? ( ) : status === 'success' ? (
<> <>
<User pubkey={data.pubkey} time={data.created_at} size="small" /> <User pubkey={data.pubkey} time={data.created_at} size="small" />
<div> <div className="mt-2">
{data.kind === 1 && <Kind1 content={data.content} truncate={true} />} <ReactMarkdown
{data.kind === 1063 && <Kind1063 metadata={data.tags} />} className="markdown"
{data.kind !== 1 && data.kind !== 1063 && ( remarkPlugins={[remarkGfm]}
<div className="flex flex-col gap-2"> components={{
<div className="inline-flex flex-col gap-1 rounded-md bg-zinc-800 px-2 py-2"> del: ({ children }) => {
<span className="text-sm font-medium leading-none text-zinc-500"> const key = children[0] as string;
Kind: {data.kind} if (key.startsWith('pub')) return <MentionUser pubkey={key.slice(3)} />;
</span> if (key.startsWith('tag'))
<p className="text-sm leading-none text-fuchsia-500"> return (
Lume isn&apos;t fully support this kind in newsfeed <button
</p> type="button"
</div> className="font-normal text-orange-400 no-underline hover:text-orange-500"
<div className="select-text whitespace-pre-line break-words text-base text-zinc-100"> >
<p>{data.content}</p> {key.slice(3)}
</div> </button>
</div> );
)} },
}}
>
{data?.content?.parsed?.length > 200
? data.content.parsed.substring(0, 200) + '...'
: data.content.parsed}
</ReactMarkdown>
</div> </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 { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from '@utils/shortenKey';
export function MentionUser({ pubkey }: { pubkey: string }) { export function MentionUser({ pubkey }: { pubkey: string }) {
const { add } = useBlock();
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
return ( return (
<Link <button
to={`/app/user/${pubkey}`} type="button"
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600" 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)} {'@' + user?.name || user?.displayName || shortenKey(pubkey)}
</Link> </button>
); );
} }

View File

@ -1,165 +1,117 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import * as Tooltip from '@radix-ui/react-tooltip'; import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { decode } from 'light-bolt11-decoder'; import { decode } from 'light-bolt11-decoder';
import { useNDK } from '@libs/ndk/provider'; 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 { LoaderIcon } from '@shared/icons';
import { ThreadIcon } from '@shared/icons/thread'; import { MiniUser } from '@shared/notes/users/mini';
import { NoteReply } from '@shared/notes/metadata/reply';
import { NoteRepost } from '@shared/notes/metadata/repost';
import { NoteZap } from '@shared/notes/metadata/zap';
export function NoteMetadata({ import { BLOCK_KINDS } from '@stores/constants';
id,
rootID,
eventPubkey,
}: {
id: string;
rootID?: string;
eventPubkey: string;
}) {
const queryClient = useQueryClient();
import { useBlock } from '@utils/hooks/useBlock';
import { compactNumber } from '@utils/number';
export function NoteMetadata({ id }: { id: string }) {
const { add } = useBlock();
const { ndk } = useNDK(); const { ndk } = useNDK();
const { status, data } = useQuery(['note-metadata', id], async () => { const { status, data } = useQuery(
let replies = 0; ['note-metadata', id],
let reposts = 0; async () => {
let zap = 0; let replies = 0;
let zap = 0;
const users = [];
const filter: NDKFilter = { const filter: NDKFilter = {
'#e': [id], '#e': [id],
kinds: [1, 6, 9735], kinds: [1, 9735],
}; };
const events = await ndk.fetchEvents(filter); const events = await ndk.fetchEvents(filter);
events.forEach((event: NDKEvent) => { events.forEach((event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case 1: case 1:
replies += 1; replies += 1;
createReplyNote( if (users.length < 3) users.push(event.pubkey);
id, createReplyNote(
event.id, id,
event.pubkey, event.id,
event.kind, event.pubkey,
event.tags, event.kind,
event.content, event.tags,
event.created_at event.content,
); event.created_at
break; );
case 6: break;
reposts += 1; case 9735: {
break; const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1];
case 9735: { if (bolt11) {
const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1]; const decoded = decode(bolt11);
if (bolt11) { const amount = decoded.sections.find((item) => item.name === 'amount');
const decoded = decode(bolt11); const sats = amount.value / 1000;
const amount = decoded.sections.find((item) => item.name === 'amount'); zap += sats;
const sats = amount.value / 1000; }
zap += sats; break;
} }
break; default:
break;
} }
default: });
break;
}
});
return { replies, reposts, zap }; return { replies, users, zap };
});
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
}, },
onSuccess: () => { { refetchOnWindowFocus: false, refetchOnReconnect: false }
queryClient.invalidateQueries({ queryKey: ['blocks'] }); );
},
});
const openThread = (thread: string) => {
block.mutate({ kind: 2, title: 'Thread', content: thread });
};
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="mt-2 inline-flex h-12 w-full items-center"> <div className="mb-3 flex items-center gap-3">
<div className="group inline-flex w-20 items-center gap-1.5"> <div className="mt-2h-6 w-11 shrink-0"></div>
<ReplyIcon <div className="mt-2 inline-flex h-6 items-center">
width={16} <LoaderIcon className="h-4 w-4 animate-spin text-zinc-100" />
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> </div>
</div> </div>
); );
} }
return ( return (
<Tooltip.Provider> <div>
<div className="mt-2 inline-flex h-12 w-full items-center justify-between"> {data.replies > 0 ? (
<div className="inline-flex items-center justify-between"> <>
<NoteReply <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" />
id={id} <div className="relative z-10 flex items-center gap-3 bg-zinc-900 pb-3">
rootID={rootID} <div className="mt-2 inline-flex h-6 w-11 shrink-0 items-center justify-center">
pubkey={eventPubkey} <div className="isolate flex -space-x-1 overflow-hidden">
replies={data.replies} {data.users?.map((user, index) => (
/> <MiniUser key={user + index} pubkey={user} />
<NoteRepost id={id} pubkey={eventPubkey} reposts={data.reposts} /> ))}
<NoteZap zaps={data.zap} /> </div>
</div> </div>
<Tooltip.Root delayDuration={150}> <div className="mt-2 inline-flex h-6 items-center gap-2">
<Tooltip.Trigger asChild> <button
<button type="button"
type="button" onClick={() =>
onClick={() => openThread(id)} add.mutate({ kind: BLOCK_KINDS.thread, title: 'Thread', content: 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" }
> className="text-zinc-500"
<ThreadIcon className="h-4 w-4 text-zinc-400" /> >
</button> <span className="font-semibold text-zinc-300">{data.replies}</span>{' '}
</Tooltip.Trigger> replies
<Tooltip.Portal> </button>
<Tooltip.Content <span className="text-zinc-500">·</span>
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" <p className="text-zinc-500">
sideOffset={5} <span className="font-semibold text-zinc-300">
> {compactNumber.format(data.zap)}
Open thread </span>{' '}
<Tooltip.Arrow className="fill-zinc-800/80 backdrop-blur-lg" /> zaps
</Tooltip.Content> </p>
</Tooltip.Portal> </div>
</Tooltip.Root> </div>
</div> </>
</Tooltip.Provider> ) : (
<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 }) { export function ImagePreview({ urls, truncate }: { urls: string[]; truncate?: boolean }) {
return ( 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"> <div className="flex flex-col gap-2">
{urls.map((url) => ( {urls.map((url) => (
<div key={url} className="relative min-w-0 shrink-0 grow-0 basis-full"> <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'; import { useOpenGraph } from '@utils/hooks/useOpenGraph';
export function LinkPreview({ urls }: { urls: string[] }) { export function LinkPreview({ urls }: { urls: string[] }) {
const domain = new URL(urls[0]);
const { status, data, error } = useOpenGraph(urls[0]); const { status, data, error } = useOpenGraph(urls[0]);
const domain = new URL(urls[0]);
return ( 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' ? ( {status === 'loading' ? (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="h-44 w-full animate-pulse bg-zinc-700" /> <div className="h-44 w-full animate-pulse bg-zinc-700" />
@ -21,7 +21,7 @@ export function LinkPreview({ urls }: { urls: string[] }) {
</div> </div>
) : ( ) : (
<a <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]} href={urls[0]}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@ -34,12 +34,14 @@ export function LinkPreview({ urls }: { urls: string[] }) {
</div> </div>
) : ( ) : (
<> <>
<Image {data.images?.[0] && (
src={data.images?.[0] || 'https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW'} <Image
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW" src={data.images?.[0] || 'https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW'}
alt={urls[0]} fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
className="h-44 w-full rounded-t-lg object-cover" alt={urls[0]}
/> className="h-44 w-full rounded-t-lg object-cover"
/>
)}
<div className="flex flex-col gap-2 px-3 py-3"> <div className="flex flex-col gap-2 px-3 py-3">
<h5 className="line-clamp-1 font-medium leading-none text-zinc-200"> <h5 className="line-clamp-1 font-medium leading-none text-zinc-200">
{data.title} {data.title}

View File

@ -2,7 +2,7 @@ import ReactPlayer from 'react-player/es6';
export function VideoPreview({ urls }: { urls: string[] }) { export function VideoPreview({ urls }: { urls: string[] }) {
return ( 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) => ( {urls.map((url) => (
<ReactPlayer <ReactPlayer
key={url} key={url}

View File

@ -7,21 +7,16 @@ import { DEFAULT_AVATAR, FULL_RELAYS } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
import { usePublish } from '@utils/hooks/usePublish'; import { usePublish } from '@utils/hooks/usePublish';
import { shortenKey } from '@utils/shortenKey'; import { displayNpub } from '@utils/shortenKey';
export function NoteReplyForm({ export function NoteReplyForm({ id, pubkey }: { id: string; pubkey: string }) {
rootID,
userPubkey,
}: {
rootID: string;
userPubkey: string;
}) {
const publish = usePublish(); const publish = usePublish();
const { status, user } = useProfile(userPubkey);
const { status, user } = useProfile(pubkey);
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const submit = () => { const submit = () => {
const tags = [['e', rootID, FULL_RELAYS[0], 'root']]; const tags = [['e', id, FULL_RELAYS[0], 'reply']];
// publish event // publish event
publish({ content: value, kind: 1, tags }); publish({ content: value, kind: 1, tags });
@ -31,36 +26,36 @@ export function NoteReplyForm({
}; };
return ( 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"> <div className="relative w-full flex-1 overflow-hidden">
<textarea <textarea
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this thread..." 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} spellCheck={false}
/> />
</div> </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' ? ( {status === 'loading' ? (
<div> <div>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
) : ( ) : (
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-3">
<div className="relative h-9 w-9 shrink-0 rounded"> <div className="relative h-11 w-11 shrink-0 rounded">
<Image <Image
src={user.image} src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={userPubkey} alt={pubkey}
className="h-9 w-9 rounded-md bg-white object-cover" className="h-11 w-11 rounded-lg bg-white object-cover"
/> />
</div> </div>
<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"> <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> </p>
</div> </div>
</div> </div>

View File

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

View File

@ -1,34 +1,66 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; 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 }) { import { LumeEvent } from '@utils/types';
const { status, data } = useQuery(['replies', parent_id], async () => {
return await getReplies(parent_id); 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 ( return (
<div className="mt-5"> <div className="mt-3">
<div className="mb-2"> <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>
<div className="flex flex-col"> <div className="flex flex-col">
{status === 'loading' ? ( {data?.length === 0 ? (
<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 ? (
<div className="px=3"> <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"> <div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3> <h3 className="text-3xl">👋</h3>
<p className="leading-none text-zinc-400">Share your thought on it...</p> <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>
</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>
</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 ( return (
<div className="flex h-min flex-col pb-3"> <div className="flex h-min flex-col pb-3">
<div className="flex items-start gap-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="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> </div>
<div className="-mt-5 animate-pulse pl-[49px]"> <div className="-mt-5 animate-pulse pl-[49px]">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="h-4 w-full rounded-sm bg-zinc-700" /> <div className="h-3 w-full rounded bg-zinc-700" />
<div className="h-3 w-2/3 rounded-sm bg-zinc-700" /> <div className="h-3 w-2/3 rounded bg-zinc-700" />
<div className="h-3 w-1/2 rounded-sm bg-zinc-700" /> <div className="h-3 w-1/2 rounded bg-zinc-700" />
</div> </div>
</div> </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 { 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 { useQuery } from '@tanstack/react-query';
import { Fragment, useRef, useState } from 'react'; import { Fragment, useState } from 'react';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
@ -9,29 +9,26 @@ import { BellIcon, CancelIcon, LoaderIcon } from '@shared/icons';
import { NotificationUser } from '@shared/notification/user'; import { NotificationUser } from '@shared/notification/user';
import { User } from '@shared/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 }) { export function NotificationModal({ pubkey }: { pubkey: string }) {
const now = useRef(new Date());
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { ndk } = useNDK(); const { fetcher, relayUrls } = useNDK();
const { status, data } = useQuery( const { status, data } = useQuery(
['user-notification', pubkey], ['notification', pubkey],
async () => { async () => {
const filter: NDKFilter = { const events = await fetcher.fetchAllEvents(
'#p': [pubkey], relayUrls,
kinds: [1, 6, 7, 9735], { '#p': [pubkey], kinds: [1, 6, 7, 9735] },
since: dateToUnix(getHourAgo(48, now.current)), { since: nHoursAgo(48) },
}; { sort: true }
const events = await ndk.fetchEvents(filter); );
return [...events]; return events as unknown as LumeEvent[];
}, },
{ {
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTime: Infinity,
} }
); );

View File

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

View File

@ -1,12 +1,10 @@
import { CancelIcon } from '@shared/icons'; import { CancelIcon } from '@shared/icons';
export function TitleBar({ import { useBlock } from '@utils/hooks/useBlock';
title,
onClick = undefined, export function TitleBar({ id, title }: { id?: string; title: string }) {
}: { const { remove } = useBlock();
title: string;
onClick?: () => void;
}) {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
@ -14,10 +12,10 @@ export function TitleBar({
> >
<div className="w-6" /> <div className="w-6" />
<h3 className="text-sm font-medium text-zinc-200">{title}</h3> <h3 className="text-sm font-medium text-zinc-200">{title}</h3>
{onClick ? ( {id ? (
<button <button
type="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" 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" /> <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 { Fragment } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { VerticalDotsIcon } from '@shared/icons';
import { Image } from '@shared/image'; import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants'; import { DEFAULT_AVATAR } from '@stores/constants';
@ -14,13 +15,13 @@ export function User({
pubkey, pubkey,
time, time,
size, size,
repost, isRepost = false,
isChat = false, isChat = false,
}: { }: {
pubkey: string; pubkey: string;
time: number; time: number;
size?: string; size?: string;
repost?: boolean; isRepost?: boolean;
isChat?: boolean; isChat?: boolean;
}) { }) {
const { status, user } = useProfile(pubkey); const { status, user } = useProfile(pubkey);
@ -50,9 +51,7 @@ export function User({
return ( return (
<Popover <Popover
className={`relative flex ${ className={`flex ${size === 'small' ? 'items-center gap-2' : 'items-start gap-3'}`}
size === 'small' ? 'items-center gap-2' : 'items-start gap-3'
}`}
> >
<Popover.Button <Popover.Button
className={`${avatarWidth} ${avatarHeight} relative z-10 shrink-0 overflow-hidden bg-zinc-900`} 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} src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
className={`${avatarWidth} ${avatarHeight} ${ className={`${
isRepost ? 'ring-1 ring-zinc-800' : ''
} ${avatarWidth} ${avatarHeight} ${
size === 'small' ? 'rounded' : 'rounded-lg' size === 'small' ? 'rounded' : 'rounded-lg'
} object-cover`} } object-cover`}
/> />
</Popover.Button> </Popover.Button>
<div className="flex flex-wrap items-baseline gap-1"> <div
className={`${isRepost ? 'mt-4' : ''} flex flex-1 items-baseline justify-between`}
>
<h5 <h5
className={`truncate font-semibold leading-none text-zinc-100 ${ 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> </h5>
{repost && ( <div className="inline-flex items-center gap-2">
<span className="font-semibold leading-none text-fuchsia-500"> reposted</span> <span className="leading-none text-zinc-500">{createdAt}</span>
)} <button
<span className="leading-none text-zinc-500">·</span> type="button"
<span className="leading-none text-zinc-500">{createdAt}</span> 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> </div>
<Transition <Transition
as={Fragment} 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 { interface ComposerState {
open: boolean; open: boolean;
reply: { id: string; root: string; pubkey: string }; reply: { id: string; pubkey: string; root?: string };
toggleModal: (status: boolean) => void; toggleModal: (status: boolean) => void;
setReply: (id: string, root: string, pubkey: string) => void; setReply: (id: string, pubkey: string) => void;
clearReply: () => void; clearReply: () => void;
} }
@ -14,7 +14,7 @@ export const useComposer = create<ComposerState>((set) => ({
toggleModal: (status: boolean) => { toggleModal: (status: boolean) => {
set({ open: status }); 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({ reply: { id: id, root: root, pubkey: pubkey } });
set({ open: true }); 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'; export const DEFAULT_AVATAR = 'https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih';
@ -70,3 +70,12 @@ export const FULL_RELAYS = [
'wss://relay.nostr.band/all', 'wss://relay.nostr.band/all',
'wss://nostr.mutinywallet.com', '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 { interface OnboardingState {
profile: { [x: string]: string }; profile: { [x: string]: string };
pubkey: string; pubkey: string;
privkey: string;
createProfile: (data: { [x: string]: string }) => void; createProfile: (data: { [x: string]: string }) => void;
setPubkey: (pubkey: string) => void; setPubkey: (pubkey: string) => void;
setPrivkey: (privkey: string) => void;
clearPrivkey: (privkey: string) => void;
} }
export const useOnboarding = create<OnboardingState>((set) => ({ export const useOnboarding = create<OnboardingState>((set) => ({
@ -20,10 +17,4 @@ export const useOnboarding = create<OnboardingState>((set) => ({
setPubkey: (pubkey: string) => { setPubkey: (pubkey: string) => {
set({ pubkey: pubkey }); 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