mirror of
https://github.com/lumehq/lume.git
synced 2025-03-28 02:31:49 +01:00
commit
b66e11433f
59
package.json
59
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@ -17,67 +17,78 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@nostr-dev-kit/ndk": "^0.7.5",
|
||||
"@nostr-dev-kit/ndk": "^0.7.7",
|
||||
"@nostr-fetch/adapter-ndk": "^0.11.0",
|
||||
"@radix-ui/react-popover": "^1.0.6",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@tanstack/react-query": "^4.29.19",
|
||||
"@tanstack/react-query-devtools": "^4.29.19",
|
||||
"@tanstack/react-query": "^4.32.0",
|
||||
"@tanstack/react-query-devtools": "^4.32.0",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||
"@tauri-apps/api": "^1.4.0",
|
||||
"@tiptap/extension-image": "^2.0.4",
|
||||
"@tiptap/extension-mention": "^2.0.4",
|
||||
"@tiptap/extension-placeholder": "^2.0.4",
|
||||
"@tiptap/pm": "^2.0.4",
|
||||
"@tiptap/react": "^2.0.4",
|
||||
"@tiptap/starter-kit": "^2.0.4",
|
||||
"@tiptap/suggestion": "^2.0.4",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"dayjs": "^1.11.9",
|
||||
"destr": "^1.2.2",
|
||||
"framer-motion": "^10.12.18",
|
||||
"framer-motion": "^10.13.0",
|
||||
"get-urls": "^11.0.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"immer": "^10.0.2",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"nostr-tools": "^1.12.1",
|
||||
"nostr-fetch": "^0.12.1",
|
||||
"nostr-tools": "^1.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.45.1",
|
||||
"react-hook-form": "^7.45.2",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-player": "^2.12.0",
|
||||
"react-router-dom": "^6.14.1",
|
||||
"react-router-dom": "^6.14.2",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-virtuoso": "^4.3.11",
|
||||
"slate": "^0.94.1",
|
||||
"slate-history": "^0.93.0",
|
||||
"slate-react": "^0.94.2",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"react-virtuoso": "^4.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
|
||||
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
|
||||
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
|
||||
"tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"zustand": "^4.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tauri-apps/cli": "^1.4.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
||||
"@types/node": "^18.16.19",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||
"@types/node": "^18.16.20",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/youtube-player": "^5.5.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||
"@typescript-eslint/parser": "^5.61.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"cross-env": "^7.0.3",
|
||||
"csstype": "^3.1.2",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react": "^7.33.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^13.2.3",
|
||||
"postcss": "^8.4.25",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^4.4.2",
|
||||
"vite": "^4.4.6",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-tsconfig-paths": "^4.2.0"
|
||||
}
|
||||
|
2754
pnpm-lock.yaml
generated
2754
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
463
src-tauri/Cargo.lock
generated
463
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lume"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
description = "nostr client"
|
||||
authors = ["Ren Amamiya"]
|
||||
license = ""
|
||||
@ -16,10 +16,11 @@ tauri-build = { version = "1.2", features = [] }
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = [ "path-all", "fs-read-dir", "fs-read-file", "clipboard-read-text", "clipboard-write-text", "dialog-open", "http-all", "http-multipart", "notification-all", "os-all", "process-relaunch", "shell-open", "system-tray", "updater", "window-close", "window-start-dragging"] }
|
||||
tauri = { version = "1.2", features = [ "fs-write-file", "window-create", "path-all", "fs-read-dir", "fs-read-file", "clipboard-read-text", "clipboard-write-text", "dialog-open", "http-all", "http-multipart", "notification-all", "os-all", "process-relaunch", "shell-open", "system-tray", "updater", "window-close", "window-start-dragging"] }
|
||||
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
sqlx-cli = {version = "0.7.0", default-features = false, features = ["sqlite"] }
|
||||
rust-argon2 = "1.0"
|
||||
rand = "0.8.5"
|
||||
|
@ -0,0 +1,6 @@
|
||||
-- Add migration script here
|
||||
DROP TABLE IF EXISTS blacklist;
|
||||
|
||||
DROP TABLE IF EXISTS channel_messages;
|
||||
|
||||
DROP TABLE IF EXISTS channels;
|
@ -107,6 +107,12 @@ fn main() {
|
||||
sql: include_str!("../migrations/20230619082415_add_replies.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 20230718072634,
|
||||
description: "clean up",
|
||||
sql: include_str!("../migrations/20230718072634_clean_up_old_tables.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
@ -115,8 +121,8 @@ fn main() {
|
||||
tauri_plugin_stronghold::Builder::new(|password| {
|
||||
let config = argon2::Config {
|
||||
lanes: 2,
|
||||
mem_cost: 50_000,
|
||||
time_cost: 30,
|
||||
mem_cost: 10_000,
|
||||
time_cost: 10,
|
||||
thread_mode: argon2::ThreadMode::from_threads(2),
|
||||
variant: argon2::Variant::Argon2id,
|
||||
..Default::default()
|
||||
@ -144,6 +150,7 @@ fn main() {
|
||||
.emit_all("single-instance", Payload { args: argv, cwd })
|
||||
.unwrap();
|
||||
}))
|
||||
.plugin(tauri_plugin_upload::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Lume",
|
||||
"version": "1.0.1"
|
||||
"version": "1.1.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
@ -28,6 +28,7 @@
|
||||
"all": false,
|
||||
"readFile": true,
|
||||
"readDir": true,
|
||||
"writeFile": true,
|
||||
"scope": [
|
||||
"$APPDATA/*",
|
||||
"$DATA/*",
|
||||
@ -62,7 +63,8 @@
|
||||
},
|
||||
"window": {
|
||||
"startDragging": true,
|
||||
"close": true
|
||||
"close": true,
|
||||
"create": true
|
||||
},
|
||||
"process": {
|
||||
"all": false,
|
||||
|
@ -24,7 +24,7 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-10 w-10 shrink rounded-md">
|
||||
<Image
|
||||
src={user.picture || user.image}
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-10 w-10 rounded-md object-cover"
|
||||
@ -32,10 +32,10 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-100">
|
||||
{user.name || user.displayName || user.display_name}
|
||||
{user?.name || user?.displayName || user?.display_name}
|
||||
</span>
|
||||
<span className="text-base leading-tight text-zinc-400">
|
||||
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
|
||||
{user?.nip05?.toLowerCase() || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { BaseDirectory, writeTextFile } from '@tauri-apps/api/fs';
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@ -19,6 +20,7 @@ export function CreateStep1Screen() {
|
||||
|
||||
const [privkeyInput, setPrivkeyInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
|
||||
const privkey = useMemo(() => generatePrivateKey(), []);
|
||||
const pubkey = getPublicKey(privkey);
|
||||
@ -34,6 +36,17 @@ export function CreateStep1Screen() {
|
||||
}
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
await writeTextFile(
|
||||
'lume-keys.txt',
|
||||
`Public key: ${pubkey}\nPrivate key: ${privkey}`,
|
||||
{
|
||||
dir: BaseDirectory.Download,
|
||||
}
|
||||
);
|
||||
setDownloaded(true);
|
||||
};
|
||||
|
||||
const account = useMutation({
|
||||
mutationFn: (data: {
|
||||
npub: string;
|
||||
@ -68,9 +81,7 @@ export function CreateStep1Screen() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">
|
||||
Lume is auto-generated key for you
|
||||
</h1>
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Save your access key!</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
@ -110,14 +121,26 @@ export function CreateStep1Screen() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-zinc-500">
|
||||
<p>
|
||||
Your private key is your password. If you lose this key, you will lose
|
||||
access to your account! Copy it and keep it in a safe place. There is no way
|
||||
to reset your private key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button preset="large" onClick={() => submit()}>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'I have saved my key, continue →'
|
||||
)}
|
||||
</Button>
|
||||
<Button preset="large-alt" onClick={() => download()}>
|
||||
{downloaded ? 'Saved in Download folder' : 'Download'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button preset="large" onClick={() => submit()}>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
||||
) : (
|
||||
'Continue →'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -32,12 +32,9 @@ export function CreateStep2Screen() {
|
||||
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [privkey, setPassword] = useStronghold((state) => [
|
||||
state.privkey,
|
||||
state.setPassword,
|
||||
]);
|
||||
|
||||
const pubkey = useOnboarding((state) => state.privkey);
|
||||
const privkey = useStronghold((state) => state.privkey);
|
||||
const pubkey = useOnboarding((state) => state.pubkey);
|
||||
|
||||
const { save } = useSecureStorage();
|
||||
|
||||
@ -60,9 +57,6 @@ export function CreateStep2Screen() {
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
setLoading(true);
|
||||
if (data.password.length > 3) {
|
||||
// add password to local state
|
||||
setPassword(data.password);
|
||||
|
||||
// save privkey to secure storage
|
||||
await save(pubkey, privkey, data.password);
|
||||
|
||||
|
@ -137,7 +137,7 @@ export function CreateStep5Screen() {
|
||||
};
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (follows: any) => {
|
||||
mutationFn: (follows: string[]) => {
|
||||
return updateAccount('follows', follows, account.pubkey);
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
@ -32,11 +32,8 @@ export function ImportStep2Screen() {
|
||||
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [privkey, setPassword] = useStronghold((state) => [
|
||||
state.privkey,
|
||||
state.setPassword,
|
||||
]);
|
||||
|
||||
const privkey = useStronghold((state) => state.privkey);
|
||||
const pubkey = useOnboarding((state) => state.pubkey);
|
||||
|
||||
const { save } = useSecureStorage();
|
||||
@ -60,9 +57,6 @@ export function ImportStep2Screen() {
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
setLoading(true);
|
||||
if (data.password.length > 3) {
|
||||
// add password to local state
|
||||
setPassword(data.password);
|
||||
|
||||
// save privkey to secure storage
|
||||
await save(pubkey, privkey, data.password);
|
||||
|
||||
@ -115,9 +109,9 @@ export function ImportStep2Screen() {
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
<p>
|
||||
Password is use to secure your key store in local machine, when you move
|
||||
to other clients, you just need to copy your private key as nsec or
|
||||
hexstring
|
||||
Password is use to unlock app and secure your key store in local machine.
|
||||
When you move to other clients, you just need to copy your private key as
|
||||
nsec or hexstring
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">
|
||||
|
@ -33,13 +33,10 @@ const resolver: Resolver<FormValues> = async (values) => {
|
||||
export function MigrateScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [setPassword, setPrivkey] = useStronghold((state) => [
|
||||
state.setPassword,
|
||||
state.setPrivkey,
|
||||
]);
|
||||
|
||||
const { account } = useAccount();
|
||||
const { save } = useSecureStorage();
|
||||
@ -63,9 +60,6 @@ export function MigrateScreen() {
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
setLoading(true);
|
||||
if (data.password.length > 3) {
|
||||
// add password to local state
|
||||
setPassword(data.password);
|
||||
|
||||
// load private in secure storage
|
||||
try {
|
||||
// save privkey to secure storage
|
||||
|
@ -21,8 +21,7 @@ export function OnboardingScreen() {
|
||||
|
||||
// publish event
|
||||
publish({
|
||||
content:
|
||||
'Running Lume, fighting for better future, join us here: https://lume.nu',
|
||||
content: 'Running Lume, join with me: https://lume.nu',
|
||||
kind: 1,
|
||||
tags: [],
|
||||
});
|
||||
@ -37,15 +36,16 @@ export function OnboardingScreen() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mb-4 text-center">
|
||||
<h1 className="mb-2 text-xl font-semibold text-zinc-100">
|
||||
👋 Hello, welcome you to Lume
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-300">
|
||||
You're a part of better future that we're fighting
|
||||
You're a part of Nostr community now
|
||||
</p>
|
||||
<p className="text-sm text-zinc-300">
|
||||
If Lume gets your attention, please help us spread via button below
|
||||
If Lume gets your attention, please help us spread it and don't forget
|
||||
invite your friend join with you, we can have fun togother
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||
@ -54,18 +54,15 @@ export function OnboardingScreen() {
|
||||
<User pubkey={account.pubkey} time={Math.floor(Date.now() / 1000)} />
|
||||
)}
|
||||
<div className="-mt-6 select-text whitespace-pre-line break-words pl-[49px] text-base text-zinc-100">
|
||||
<p>Running Lume, fighting for better future</p>
|
||||
<p>
|
||||
join us here:{' '}
|
||||
<a
|
||||
href="https://lume.nu"
|
||||
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
https://lume.nu
|
||||
</a>
|
||||
</p>
|
||||
<p>Running Lume, join with me</p>
|
||||
<a
|
||||
href="https://lume.nu"
|
||||
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
https://lume.nu
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -84,16 +81,16 @@ export function OnboardingScreen() {
|
||||
) : (
|
||||
<>
|
||||
<span className="w-5" />
|
||||
<span>Publish</span>
|
||||
<span>Spread</span>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-zinc-800 px-6 font-medium text-zinc-300 hover:bg-zinc-900"
|
||||
>
|
||||
Skip for now
|
||||
Skip
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -29,13 +29,10 @@ const resolver: Resolver<FormValues> = async (values) => {
|
||||
|
||||
export function UnlockScreen() {
|
||||
const navigate = useNavigate();
|
||||
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [setPrivkey, setPassword] = useStronghold((state) => [
|
||||
state.setPrivkey,
|
||||
state.setPassword,
|
||||
]);
|
||||
|
||||
const { account } = useAccount();
|
||||
const { load } = useSecureStorage();
|
||||
@ -59,9 +56,6 @@ export function UnlockScreen() {
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
setLoading(true);
|
||||
if (data.password.length > 3) {
|
||||
// add password to local state
|
||||
setPassword(data.password);
|
||||
|
||||
// load private in secure storage
|
||||
try {
|
||||
const privkey = await load(account.pubkey, data.password);
|
||||
@ -99,7 +93,7 @@ export function UnlockScreen() {
|
||||
<input
|
||||
{...register('password', { required: true })}
|
||||
type={passwordInput}
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 text-center text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -6,7 +6,10 @@ import { ChatsListSelfItem } from '@app/chat/components/self';
|
||||
|
||||
import { getChats } from '@libs/storage';
|
||||
|
||||
import { StrangersIcon } from '@shared/icons';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { compactNumber } from '@utils/number';
|
||||
|
||||
export function ChatsList() {
|
||||
const { account } = useAccount();
|
||||
@ -15,11 +18,7 @@ export function ChatsList() {
|
||||
data: chats,
|
||||
isFetching,
|
||||
} = useQuery(['chats'], async () => {
|
||||
const chats = await getChats();
|
||||
const sorted = chats.sort(
|
||||
(a, b) => parseInt(a.new_messages) - parseInt(b.new_messages)
|
||||
);
|
||||
return sorted;
|
||||
return await getChats();
|
||||
});
|
||||
|
||||
if (status === 'loading') {
|
||||
@ -39,7 +38,6 @@ export function ChatsList() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<NewMessageModal />
|
||||
{account ? (
|
||||
<ChatsListSelfItem data={account} />
|
||||
) : (
|
||||
@ -48,11 +46,25 @@ export function ChatsList() {
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
)}
|
||||
{chats.map((item) => {
|
||||
{chats.follows.map((item) => {
|
||||
if (account.pubkey !== item.sender_pubkey) {
|
||||
return <ChatsListItem key={item.sender_pubkey} data={item} />;
|
||||
}
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
|
||||
>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<StrangersIcon className="h-3 w-3 text-zinc-200" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-medium text-zinc-400">
|
||||
{compactNumber.format(chats.unknown)} strangers
|
||||
</h5>
|
||||
</div>
|
||||
</button>
|
||||
<NewMessageModal />
|
||||
{isFetching && (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
|
@ -36,7 +36,7 @@ export function NewMessageModal() {
|
||||
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
|
||||
>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<PlusIcon width={12} height={12} className="text-zinc-500" />
|
||||
<PlusIcon className="h-3 w-3 text-zinc-200" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-medium text-zinc-400">New chat</h5>
|
||||
|
@ -1,12 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { useLiveThread } from '@app/space/hooks/useLiveThread';
|
||||
|
||||
import { getNoteByID } from '@libs/storage';
|
||||
|
||||
import { Kind1 } from '@shared/notes/contents/kind1';
|
||||
import { Kind1063 } from '@shared/notes/contents/kind1063';
|
||||
import { NoteMetadata } from '@shared/notes/metadata';
|
||||
import { NoteReplyForm } from '@shared/notes/replies/form';
|
||||
import { RepliesList } from '@shared/notes/replies/list';
|
||||
@ -14,16 +9,12 @@ import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { parser } from '@utils/parser';
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export function NoteScreen() {
|
||||
const { id } = useParams();
|
||||
const { account } = useAccount();
|
||||
const { status, data } = useQuery(['thread', id], async () => {
|
||||
const res = await getNoteByID(id);
|
||||
res['content'] = parser(res);
|
||||
return res;
|
||||
});
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
useLiveThread(id);
|
||||
|
||||
@ -41,9 +32,7 @@ export function NoteScreen() {
|
||||
<div className="rounded-md bg-zinc-900 px-5 pt-5">
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3">
|
||||
{data.kind === 1 && <Kind1 content={data.content} />}
|
||||
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
|
||||
<NoteMetadata id={data.event_id || id} eventPubkey={data.pubkey} />
|
||||
<NoteMetadata id={data.event_id || id} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md bg-zinc-900">
|
||||
@ -52,7 +41,7 @@ export function NoteScreen() {
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3">
|
||||
<RepliesList parent_id={id} />
|
||||
<RepliesList id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
102
src/app/root.tsx
102
src/app/root.tsx
@ -1,53 +1,67 @@
|
||||
import { NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { NDKUser } from '@nostr-dev-kit/ndk';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { prefetchEvents } from '@libs/ndk/utils';
|
||||
import {
|
||||
countTotalNotes,
|
||||
createChat,
|
||||
createNote,
|
||||
getLastLogin,
|
||||
updateAccount,
|
||||
updateLastLogin,
|
||||
} from '@libs/storage';
|
||||
|
||||
import { LoaderIcon, LumeIcon } from '@shared/icons';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { dateToUnix, getHourAgo } from '@utils/date';
|
||||
import { nHoursAgo } from '@utils/date';
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
|
||||
const totalNotes = await countTotalNotes();
|
||||
const lastLogin = await getLastLogin();
|
||||
|
||||
export function Root() {
|
||||
const now = useRef(new Date());
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { ndk } = useNDK();
|
||||
const { ndk, relayUrls, fetcher } = useNDK();
|
||||
const { status, account } = useAccount();
|
||||
|
||||
async function getFollows() {
|
||||
const authors: string[] = [];
|
||||
|
||||
const user = ndk.getUser({ hexpubkey: account.pubkey });
|
||||
const follows = await user.follows();
|
||||
|
||||
follows.forEach((follow: NDKUser) => {
|
||||
authors.push(nip19.decode(follow.npub).data as string);
|
||||
});
|
||||
|
||||
// update follows in db
|
||||
await updateAccount('follows', authors, account.pubkey);
|
||||
|
||||
return authors;
|
||||
}
|
||||
|
||||
async function fetchNotes() {
|
||||
try {
|
||||
const follows = JSON.parse(account.follows);
|
||||
const follows = await getFollows();
|
||||
|
||||
if (follows.length > 0) {
|
||||
let since: number;
|
||||
|
||||
if (totalNotes === 0 || lastLogin === 0) {
|
||||
since = dateToUnix(getHourAgo(48, now.current));
|
||||
since = nHoursAgo(48);
|
||||
} else {
|
||||
since = lastLogin;
|
||||
}
|
||||
|
||||
const filter: NDKFilter = {
|
||||
kinds: [1, 6],
|
||||
authors: follows,
|
||||
since: since,
|
||||
};
|
||||
|
||||
const events = await prefetchEvents(ndk, filter);
|
||||
for (const event of events) {
|
||||
const events = fetcher.allEventsIterator(
|
||||
relayUrls,
|
||||
{ kinds: [1], authors: follows },
|
||||
{ since: since },
|
||||
{ skipVerification: true }
|
||||
);
|
||||
for await (const event of events) {
|
||||
await createNote(
|
||||
event.id,
|
||||
event.pubkey,
|
||||
@ -67,20 +81,23 @@ export function Root() {
|
||||
|
||||
async function fetchChats() {
|
||||
try {
|
||||
const sendFilter: NDKFilter = {
|
||||
kinds: [4],
|
||||
authors: [account.pubkey],
|
||||
since: lastLogin,
|
||||
};
|
||||
const sendMessages = await fetcher.fetchAllEvents(
|
||||
relayUrls,
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [account.pubkey],
|
||||
},
|
||||
{ since: lastLogin }
|
||||
);
|
||||
|
||||
const receiveFilter: NDKFilter = {
|
||||
kinds: [4],
|
||||
'#p': [account.pubkey],
|
||||
since: lastLogin,
|
||||
};
|
||||
|
||||
const sendMessages = await prefetchEvents(ndk, sendFilter);
|
||||
const receiveMessages = await prefetchEvents(ndk, receiveFilter);
|
||||
const receiveMessages = await fetcher.fetchAllEvents(
|
||||
relayUrls,
|
||||
{
|
||||
kinds: [4],
|
||||
'#p': [account.pubkey],
|
||||
},
|
||||
{ since: lastLogin }
|
||||
);
|
||||
|
||||
const events = [...sendMessages, ...receiveMessages];
|
||||
for (const event of events) {
|
||||
@ -158,27 +175,24 @@ export function Root() {
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent"
|
||||
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
/>
|
||||
<div className="relative flex h-full flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<LumeIcon className="h-16 w-16 text-black dark:text-zinc-100" />
|
||||
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-zinc-100" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
|
||||
Here's an interesting fact:
|
||||
<h3 className="text-lg font-semibold leading-tight text-zinc-100">
|
||||
Prefetching data...
|
||||
</h3>
|
||||
<p className="font-medium text-zinc-300 dark:text-zinc-600">
|
||||
Bitcoin and Nostr can be used by anyone, and no one can stop you!
|
||||
<p className="text-zinc-600">
|
||||
This may take a few seconds, please don't close app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -12,7 +12,7 @@ import { createBlock } from '@libs/storage';
|
||||
|
||||
import { CancelIcon, CheckCircleIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { ADD_FEEDBLOCK_SHORTCUT } from '@stores/shortcuts';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
@ -38,7 +38,7 @@ export function AddFeedBlock() {
|
||||
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => openModal());
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
mutationFn: (data: { kind: number; title: string; content: string }) => {
|
||||
return createBlock(data.kind, data.title, data.content);
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -53,7 +53,7 @@ export function AddFeedBlock() {
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
const onSubmit = (data: { kind: number; title: string; content: string }) => {
|
||||
setLoading(true);
|
||||
|
||||
selected.forEach((item, index) => {
|
||||
@ -64,7 +64,7 @@ export function AddFeedBlock() {
|
||||
|
||||
// insert to database
|
||||
block.mutate({
|
||||
kind: 1,
|
||||
kind: BLOCK_KINDS.feed,
|
||||
title: data.title,
|
||||
content: JSON.stringify(selected),
|
||||
});
|
||||
@ -205,7 +205,7 @@ export function AddFeedBlock() {
|
||||
{status === 'loading' ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
JSON.parse(account.follows).map((follow) => (
|
||||
JSON.parse(account.follows as string).map((follow) => (
|
||||
<Combobox.Option
|
||||
key={follow}
|
||||
value={follow}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import { Body, fetch } from '@tauri-apps/api/http';
|
||||
@ -7,18 +6,15 @@ import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { createBlock } from '@libs/storage';
|
||||
|
||||
import { CancelIcon, CommandIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { ADD_IMAGEBLOCK_SHORTCUT } from '@stores/shortcuts';
|
||||
|
||||
import { createBlobFromFile } from '@utils/createBlobFromFile';
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { usePublish } from '@utils/hooks/usePublish';
|
||||
|
||||
export function AddImageBlock() {
|
||||
@ -29,9 +25,6 @@ export function AddImageBlock() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState('');
|
||||
|
||||
const { ndk } = useNDK();
|
||||
const { account } = useAccount();
|
||||
|
||||
const tags = useRef(null);
|
||||
|
||||
const openModal = () => {
|
||||
@ -101,7 +94,7 @@ export function AddImageBlock() {
|
||||
};
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
mutationFn: (data: { kind: number; title: string; content: string }) => {
|
||||
return createBlock(data.kind, data.title, data.content);
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -109,14 +102,14 @@ export function AddImageBlock() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
const onSubmit = async (data: { kind: number; title: string; content: string }) => {
|
||||
setLoading(true);
|
||||
|
||||
// publish file metedata
|
||||
await publish({ content: data.title, kind: 1063, tags: tags.current });
|
||||
|
||||
// mutate
|
||||
block.mutate({ kind: 0, title: data.title, content: data.content });
|
||||
block.mutate({ kind: BLOCK_KINDS.image, title: data.title, content: data.content });
|
||||
|
||||
setLoading(false);
|
||||
// reset form
|
||||
|
@ -1,18 +1,19 @@
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { getNotesByAuthors, removeBlock } from '@libs/storage';
|
||||
import { getNotesByAuthors } from '@libs/storage';
|
||||
|
||||
import { Note } from '@shared/notes/note';
|
||||
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
|
||||
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { Block, LumeEvent } from '@utils/types';
|
||||
|
||||
const ITEM_PER_PAGE = 10;
|
||||
|
||||
export function FeedBlock({ params }: { params: any }) {
|
||||
const queryClient = useQueryClient();
|
||||
export function FeedBlock({ params }: { params: Block }) {
|
||||
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ['newsfeed', params.content],
|
||||
@ -22,9 +23,9 @@ export function FeedBlock({ params }: { params: any }) {
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
|
||||
const parentRef = useRef();
|
||||
const notes = data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : [];
|
||||
|
||||
const parentRef = useRef();
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: hasNextPage ? notes.length + 1 : notes.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
@ -46,29 +47,74 @@ export function FeedBlock({ params }: { params: any }) {
|
||||
}
|
||||
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
return removeBlock(id);
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const note: LumeEvent = notes[index];
|
||||
if (!note) return;
|
||||
switch (note.kind) {
|
||||
case 1: {
|
||||
const root = note.tags.find((el) => el[3] === 'root')?.[1];
|
||||
const reply = note.tags.find((el) => el[3] === 'reply')?.[1];
|
||||
if (root || reply) {
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteThread event={note} root={root} reply={reply} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1 event={note} skipMetadata={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
case 6:
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<Repost key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
case 1063:
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1063 key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKindUnsupport key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||
},
|
||||
});
|
||||
|
||||
const renderItem = (index: string | number) => {
|
||||
const note = notes[index];
|
||||
|
||||
if (!note) return;
|
||||
return (
|
||||
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
|
||||
<Note event={note} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
[notes]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-[400px] shrink-0 border-r border-zinc-900">
|
||||
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
|
||||
@ -105,9 +151,7 @@ export function FeedBlock({ params }: { params: any }) {
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer
|
||||
.getVirtualItems()
|
||||
.map((virtualRow) => renderItem(virtualRow.index))}
|
||||
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,18 +1,21 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useNewsfeed } from '@app/space/hooks/useNewsfeed';
|
||||
|
||||
import { getNotes } from '@libs/storage';
|
||||
|
||||
import { Note } from '@shared/notes/note';
|
||||
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
|
||||
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { useNote } from '@stores/note';
|
||||
|
||||
import { LumeEvent } from '@utils/types';
|
||||
|
||||
const ITEM_PER_PAGE = 10;
|
||||
|
||||
export function FollowingBlock() {
|
||||
@ -33,7 +36,7 @@ export function FollowingBlock() {
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
|
||||
const notes = data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : [];
|
||||
const parentRef = useRef();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
@ -66,19 +69,76 @@ export function FollowingBlock() {
|
||||
toggleHasNewNote(false);
|
||||
};
|
||||
|
||||
const renderItem = (index: string | number) => {
|
||||
const note = notes[index];
|
||||
if (!note) return;
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<Note event={note} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const note: LumeEvent = notes[index];
|
||||
if (!note) return;
|
||||
switch (note.kind) {
|
||||
case 1: {
|
||||
let root: string;
|
||||
let reply: string;
|
||||
if (note.tags?.[0]?.[0] === 'e' && !note.tags?.[0]?.[3]) {
|
||||
root = note.tags[0][1];
|
||||
} else {
|
||||
root = note.tags.find((el) => el[3] === 'root')?.[1];
|
||||
reply = note.tags.find((el) => el[3] === 'reply')?.[1];
|
||||
}
|
||||
if (root || reply) {
|
||||
return (
|
||||
<div
|
||||
key={(root || reply) + (note.event_id || note.id)}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteThread event={note} root={root} reply={reply} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1 event={note} skipMetadata={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
case 6:
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<Repost key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
case 1063:
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1063 key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKindUnsupport key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
[notes]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-[400px] shrink-0 border-r border-zinc-900">
|
||||
@ -138,9 +198,7 @@ export function FollowingBlock() {
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer
|
||||
.getVirtualItems()
|
||||
.map((virtualRow) => renderItem(virtualRow.index))}
|
||||
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
87
src/app/space/components/blocks/hashtag.tsx
Normal file
87
src/app/space/components/blocks/hashtag.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,23 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { removeBlock } from '@libs/storage';
|
||||
|
||||
import { CancelIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
export function ImageBlock({ params }: { params: any }) {
|
||||
const queryClient = useQueryClient();
|
||||
import { useBlock } from '@utils/hooks/useBlock';
|
||||
import { Block } from '@utils/types';
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
return removeBlock(id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||
},
|
||||
});
|
||||
export function ImageBlock({ params }: { params: Block }) {
|
||||
const { remove } = useBlock();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[350px] shrink-0 flex-col justify-between border-r border-zinc-900">
|
||||
@ -27,7 +17,7 @@ export function ImageBlock({ params }: { params: any }) {
|
||||
<h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => block.mutate(params.id)}
|
||||
onClick={() => remove.mutate(params.id)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white/30 backdrop-blur-lg"
|
||||
>
|
||||
<CancelIcon width={16} height={16} className="text-white" />
|
||||
|
@ -1,76 +1,53 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useLiveThread } from '@app/space/hooks/useLiveThread';
|
||||
|
||||
import { getNoteByID, removeBlock } from '@libs/storage';
|
||||
|
||||
import { Kind1 } from '@shared/notes/contents/kind1';
|
||||
import { Kind1063 } from '@shared/notes/contents/kind1063';
|
||||
import { NoteMetadata } from '@shared/notes/metadata';
|
||||
import { NoteReplyForm } from '@shared/notes/replies/form';
|
||||
// import { useLiveThread } from '@app/space/hooks/useLiveThread';
|
||||
import {
|
||||
NoteActions,
|
||||
NoteContent,
|
||||
NoteReplyForm,
|
||||
NoteStats,
|
||||
ThreadUser,
|
||||
} from '@shared/notes';
|
||||
import { RepliesList } from '@shared/notes/replies/list';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { parser } from '@utils/parser';
|
||||
|
||||
export function ThreadBlock({ params }: { params: any }) {
|
||||
useLiveThread(params.content);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
import { Block } from '@utils/types';
|
||||
|
||||
export function ThreadBlock({ params }: { params: Block }) {
|
||||
const { status, data } = useEvent(params.content);
|
||||
const { account } = useAccount();
|
||||
const { status, data } = useQuery(['thread', params.content], async () => {
|
||||
const res = await getNoteByID(params.content);
|
||||
res['content'] = parser(res);
|
||||
return res;
|
||||
});
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
return removeBlock(id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||
},
|
||||
});
|
||||
// subscribe to live reply
|
||||
// useLiveThread(params.content);
|
||||
|
||||
return (
|
||||
<div className="w-[400px] shrink-0 border-r border-zinc-900">
|
||||
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
|
||||
<div className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pb-20 pt-1.5">
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div className="scrollbar-hide flex h-full w-full flex-col gap-3 overflow-y-auto pb-20 pt-1.5">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
|
||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-min w-full px-3 py-1.5">
|
||||
<div className="rounded-md bg-zinc-900 px-5 pt-5">
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3">
|
||||
{data.kind === 1 && <Kind1 content={data.content} />}
|
||||
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
|
||||
<NoteMetadata
|
||||
id={data.event_id || params.content}
|
||||
eventPubkey={data.pubkey}
|
||||
/>
|
||||
<Link to={`/app/note/${params.content}`}>Focus</Link>
|
||||
<div className="h-min w-full px-3 pt-1.5">
|
||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
|
||||
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-2">
|
||||
<NoteContent content={data.content} />
|
||||
</div>
|
||||
<div>
|
||||
<NoteActions id={data.id} pubkey={data.pubkey} noOpenThread={true} />
|
||||
<NoteStats id={data.id} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md bg-zinc-900">
|
||||
{account && (
|
||||
<NoteReplyForm rootID={params.content} userPubkey={account.pubkey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3">
|
||||
<RepliesList parent_id={params.content} />
|
||||
<NoteReplyForm id={params.content} pubkey={account.pubkey} />
|
||||
<RepliesList id={params.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
102
src/app/space/components/blocks/user.tsx
Normal file
102
src/app/space/components/blocks/user.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -19,7 +19,7 @@ export function useNewsfeed() {
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'success' && account) {
|
||||
const follows = account ? JSON.parse(account.follows) : [];
|
||||
const follows = account ? JSON.parse(account.follows as string) : [];
|
||||
|
||||
const filter: NDKFilter = {
|
||||
kinds: [1, 6],
|
||||
@ -30,7 +30,6 @@ export function useNewsfeed() {
|
||||
sub.current = ndk.subscribe(filter, { closeOnEose: false });
|
||||
|
||||
sub.current.addListener('event', (event: NDKEvent) => {
|
||||
console.log('new note: ', event);
|
||||
// add to db
|
||||
createNote(
|
||||
event.id,
|
||||
@ -46,7 +45,9 @@ export function useNewsfeed() {
|
||||
}
|
||||
|
||||
return () => {
|
||||
sub.current.stop();
|
||||
if (sub.current) {
|
||||
sub.current.stop();
|
||||
}
|
||||
};
|
||||
}, [status]);
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { UserBlock } from '@app/space//components/blocks/user';
|
||||
import { AddBlock } from '@app/space/components/add';
|
||||
import { FeedBlock } from '@app/space/components/blocks/feed';
|
||||
import { FollowingBlock } from '@app/space/components/blocks/following';
|
||||
import { HashtagBlock } from '@app/space/components/blocks/hashtag';
|
||||
import { ImageBlock } from '@app/space/components/blocks/image';
|
||||
import { ThreadBlock } from '@app/space/components/blocks/thread';
|
||||
|
||||
@ -10,6 +13,8 @@ import { getBlocks } from '@libs/storage';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { Block } from '@utils/types';
|
||||
|
||||
export function SpaceScreen() {
|
||||
const {
|
||||
status,
|
||||
@ -28,6 +33,26 @@ export function SpaceScreen() {
|
||||
}
|
||||
);
|
||||
|
||||
const renderBlock = useCallback(
|
||||
(block: Block) => {
|
||||
switch (block.kind) {
|
||||
case 0:
|
||||
return <ImageBlock key={block.id} params={block} />;
|
||||
case 1:
|
||||
return <FeedBlock key={block.id} params={block} />;
|
||||
case 2:
|
||||
return <ThreadBlock key={block.id} params={block} />;
|
||||
case 3:
|
||||
return <HashtagBlock key={block.id} params={block} />;
|
||||
case 5:
|
||||
return <UserBlock key={block.id} params={block} />;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[blocks]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
|
||||
<FollowingBlock />
|
||||
@ -43,18 +68,7 @@ export function SpaceScreen() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
blocks.map((block: { kind: number; id: string }) => {
|
||||
switch (block.kind) {
|
||||
case 0:
|
||||
return <ImageBlock key={block.id} params={block} />;
|
||||
case 1:
|
||||
return <FeedBlock key={block.id} params={block} />;
|
||||
case 2:
|
||||
return <ThreadBlock key={block.id} params={block} />;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
})
|
||||
blocks.map((block: Block) => renderBlock(block))
|
||||
)}
|
||||
{isFetching && (
|
||||
<div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Note } from '@shared/notes/note';
|
||||
import { NoteKind_1 } from '@shared/notes';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
@ -27,7 +27,7 @@ export function TrendingNotes() {
|
||||
) : (
|
||||
<div className="relative flex w-full flex-col pt-1.5">
|
||||
{data.notes.map((item) => (
|
||||
<Note key={item.id} event={item.event} skipMetadata={true} />
|
||||
<NoteKind_1 key={item.id} event={item.event} skipMetadata={true} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,34 +1,84 @@
|
||||
import { NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { Note } from '@shared/notes/note';
|
||||
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
|
||||
|
||||
import { dateToUnix, getHourAgo } from '@utils/date';
|
||||
import { nHoursAgo } from '@utils/date';
|
||||
import { LumeEvent } from '@utils/types';
|
||||
|
||||
export function UserFeed({ pubkey }: { pubkey: string }) {
|
||||
const { ndk } = useNDK();
|
||||
const parentRef = useRef();
|
||||
|
||||
const { fetcher, relayUrls } = useNDK();
|
||||
const { status, data } = useQuery(['user-feed', pubkey], async () => {
|
||||
const now = new Date();
|
||||
const filter: NDKFilter = {
|
||||
kinds: [1],
|
||||
authors: [pubkey],
|
||||
since: dateToUnix(getHourAgo(48, now)),
|
||||
};
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
return [...events];
|
||||
const events = await fetcher.fetchAllEvents(
|
||||
relayUrls,
|
||||
{ kinds: [1], authors: [pubkey] },
|
||||
{ since: nHoursAgo(48) },
|
||||
{ sort: true }
|
||||
);
|
||||
return events as unknown as LumeEvent[];
|
||||
});
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: data ? data.length : 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 400,
|
||||
});
|
||||
|
||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[400px] px-2 pb-10">
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
|
||||
style={{ contain: 'strict' }}
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3">
|
||||
<p>Loading...</p>
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : itemsVirtualizer.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm text-zinc-300">
|
||||
No new posts about this hashtag in 48 hours ago
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((note: LumeEvent) => <Note key={note.id} event={note} />)
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{itemsVirtualizer.map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1 event={data[virtualRow.index]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -19,13 +19,13 @@ export function UserMetadata({ pubkey }: { pubkey: string }) {
|
||||
<div className="flex w-full items-center gap-10">
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="font-semibold leading-none text-zinc-100">
|
||||
{data.stats[pubkey].followers_pubkey_count ?? 0}
|
||||
{compactNumber.format(data.stats[pubkey].followers_pubkey_count) ?? 0}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-zinc-400">Followers</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="font-semibold leading-none text-zinc-100">
|
||||
{data.stats[pubkey].pub_following_pubkey_count ?? 0}
|
||||
{compactNumber.format(data.stats[pubkey].pub_following_pubkey_count) ?? 0}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-zinc-400">Following</span>
|
||||
</div>
|
||||
|
@ -15,7 +15,19 @@ button {
|
||||
}
|
||||
|
||||
.markdown {
|
||||
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:leading-tight prose-p:last:mb-0 prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 hover:prose-a:text-fuchsia-600 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-hr:mx-0 prose-hr:my-2;
|
||||
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:last:mb-0 prose-a:break-all prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-400 hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-img:mt-3 prose-img:mb-2 prose-hr:mx-0 prose-hr:my-2;
|
||||
}
|
||||
|
||||
.ProseMirror p.is-empty::before {
|
||||
@apply text-zinc-500;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ProseMirror img.ProseMirror-selectednode {
|
||||
@apply outline-fuchsia-500;
|
||||
}
|
||||
|
||||
/* For Webkit-based browsers (Chrome, Safari and Opera) */
|
||||
|
@ -1,15 +1,18 @@
|
||||
// source: https://github.com/nostr-dev-kit/ndk-react/
|
||||
import NDK from '@nostr-dev-kit/ndk';
|
||||
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
|
||||
import { NostrFetcher, normalizeRelayUrlSet } from 'nostr-fetch';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getSetting } from '@libs/storage';
|
||||
|
||||
const setting = await getSetting('relays');
|
||||
const relays = JSON.parse(setting);
|
||||
const relays = normalizeRelayUrlSet(JSON.parse(setting));
|
||||
|
||||
export const NDKInstance = () => {
|
||||
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
|
||||
const [relayUrls, setRelayUrls] = useState<string[]>(relays);
|
||||
const [fetcher, setFetcher] = useState<NostrFetcher>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
loadNdk(relays);
|
||||
@ -26,11 +29,13 @@ export const NDKInstance = () => {
|
||||
|
||||
setNDK(ndkInstance);
|
||||
setRelayUrls(explicitRelayUrls);
|
||||
setFetcher(NostrFetcher.withCustomPool(ndkAdapter(ndkInstance)));
|
||||
}
|
||||
|
||||
return {
|
||||
ndk,
|
||||
relayUrls,
|
||||
fetcher,
|
||||
loadNdk,
|
||||
};
|
||||
};
|
||||
|
@ -1,5 +1,6 @@
|
||||
// source: https://github.com/nostr-dev-kit/ndk-react/
|
||||
import NDK from '@nostr-dev-kit/ndk';
|
||||
import { NostrFetcher } from 'nostr-fetch';
|
||||
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||
|
||||
import { NDKInstance } from '@libs/ndk/instance';
|
||||
@ -7,17 +8,19 @@ import { NDKInstance } from '@libs/ndk/instance';
|
||||
interface NDKContext {
|
||||
ndk: NDK;
|
||||
relayUrls: string[];
|
||||
fetcher: NostrFetcher;
|
||||
loadNdk: (_: string[]) => void;
|
||||
}
|
||||
|
||||
const NDKContext = createContext<NDKContext>({
|
||||
ndk: new NDK({}),
|
||||
relayUrls: [],
|
||||
fetcher: undefined,
|
||||
loadNdk: undefined,
|
||||
});
|
||||
|
||||
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
|
||||
const { ndk, relayUrls, loadNdk } = NDKInstance();
|
||||
const { ndk, relayUrls, fetcher, loadNdk } = NDKInstance();
|
||||
|
||||
if (ndk)
|
||||
return (
|
||||
@ -25,6 +28,7 @@ const NDKProvider = ({ children }: PropsWithChildren<object>) => {
|
||||
value={{
|
||||
ndk,
|
||||
relayUrls,
|
||||
fetcher,
|
||||
loadNdk,
|
||||
}}
|
||||
>
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
|
||||
import destr from 'destr';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
import { parser } from '@utils/parser';
|
||||
import { getParentID } from '@utils/transform';
|
||||
import { Account, Block, Chats, LumeEvent, Profile, Settings } from '@utils/types';
|
||||
|
||||
let db: null | Database = null;
|
||||
|
||||
@ -18,7 +20,9 @@ export async function connect(): Promise<Database> {
|
||||
// get active account
|
||||
export async function getActiveAccount() {
|
||||
const db = await connect();
|
||||
const result: any = await db.select('SELECT * FROM accounts WHERE is_active = 1;');
|
||||
const result: Array<Account> = await db.select(
|
||||
'SELECT * FROM accounts WHERE is_active = 1;'
|
||||
);
|
||||
if (result.length > 0) {
|
||||
return result[0];
|
||||
} else {
|
||||
@ -29,9 +33,10 @@ export async function getActiveAccount() {
|
||||
// get all accounts
|
||||
export async function getAccounts() {
|
||||
const db = await connect();
|
||||
return await db.select(
|
||||
const result: Array<Account> = await db.select(
|
||||
'SELECT * FROM accounts WHERE is_active = 0 ORDER BY created_at DESC;'
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// create account
|
||||
@ -49,7 +54,7 @@ export async function createAccount(
|
||||
if (res) {
|
||||
await createBlock(
|
||||
0,
|
||||
'Preserve your freedom',
|
||||
'Have fun together!',
|
||||
'https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv'
|
||||
);
|
||||
}
|
||||
@ -80,7 +85,7 @@ export async function countTotalChannels() {
|
||||
// count total notes
|
||||
export async function countTotalNotes() {
|
||||
const db = await connect();
|
||||
const result = await db.select(
|
||||
const result: Array<{ total: string }> = await db.select(
|
||||
'SELECT COUNT(*) AS "total" FROM notes WHERE kind IN (1, 6);'
|
||||
);
|
||||
return parseInt(result[0].total);
|
||||
@ -92,11 +97,19 @@ export async function getNotes(limit: number, offset: number) {
|
||||
const totalNotes = await countTotalNotes();
|
||||
const nextCursor = offset + limit;
|
||||
|
||||
const notes: any = { data: null, nextCursor: 0 };
|
||||
const query: any = await db.select(
|
||||
const notes: { data: LumeEvent[] | null; nextCursor: number } = {
|
||||
data: null,
|
||||
nextCursor: 0,
|
||||
};
|
||||
|
||||
const query: LumeEvent[] = await db.select(
|
||||
`SELECT * FROM notes WHERE kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`
|
||||
);
|
||||
|
||||
query.forEach(
|
||||
(el) => (el.tags = typeof el.tags === 'string' ? destr(el.tags) : el.tags)
|
||||
);
|
||||
|
||||
notes['data'] = query;
|
||||
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
|
||||
|
||||
@ -106,11 +119,16 @@ export async function getNotes(limit: number, offset: number) {
|
||||
// get all notes by pubkey
|
||||
export async function getNotesByPubkey(pubkey: string) {
|
||||
const db = await connect();
|
||||
const res: any = await db.select(
|
||||
|
||||
const query: LumeEvent[] = await db.select(
|
||||
`SELECT * FROM notes WHERE pubkey == "${pubkey}" AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC;`
|
||||
);
|
||||
|
||||
return res;
|
||||
query.forEach(
|
||||
(el) => (el.tags = typeof el.tags === 'string' ? destr(el.tags) : el.tags)
|
||||
);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// get all notes by authors
|
||||
@ -121,11 +139,19 @@ export async function getNotesByAuthors(authors: string, limit: number, offset:
|
||||
const array = JSON.parse(authors);
|
||||
const finalArray = `'${array.join("','")}'`;
|
||||
|
||||
const notes: any = { data: null, nextCursor: 0 };
|
||||
const query: any = await db.select(
|
||||
const notes: { data: LumeEvent[] | null; nextCursor: number } = {
|
||||
data: null,
|
||||
nextCursor: 0,
|
||||
};
|
||||
|
||||
const query: LumeEvent[] = await db.select(
|
||||
`SELECT * FROM notes WHERE pubkey IN (${finalArray}) AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`
|
||||
);
|
||||
|
||||
query.forEach(
|
||||
(el) => (el.tags = typeof el.tags === 'string' ? destr(el.tags) : el.tags)
|
||||
);
|
||||
|
||||
notes['data'] = query;
|
||||
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
|
||||
|
||||
@ -135,8 +161,16 @@ export async function getNotesByAuthors(authors: string, limit: number, offset:
|
||||
// get note by id
|
||||
export async function getNoteByID(event_id: string) {
|
||||
const db = await connect();
|
||||
const result = await db.select(`SELECT * FROM notes WHERE event_id = "${event_id}";`);
|
||||
return result[0];
|
||||
const result: LumeEvent[] = await db.select(
|
||||
`SELECT * FROM notes WHERE event_id = "${event_id}";`
|
||||
);
|
||||
if (result[0]) {
|
||||
// @ts-expect-error, todo
|
||||
if (result[0].kind === 1) result[0]['content'] = parser(result[0]);
|
||||
return result[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// create note
|
||||
@ -144,7 +178,7 @@ export async function createNote(
|
||||
event_id: string,
|
||||
pubkey: string,
|
||||
kind: number,
|
||||
tags: any,
|
||||
tags: string[][],
|
||||
content: string,
|
||||
created_at: number
|
||||
) {
|
||||
@ -161,7 +195,7 @@ export async function createNote(
|
||||
// get note replies
|
||||
export async function getReplies(parent_id: string) {
|
||||
const db = await connect();
|
||||
const result: any = await db.select(
|
||||
const result: Array<LumeEvent> = await db.select(
|
||||
`SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`
|
||||
);
|
||||
return result;
|
||||
@ -173,7 +207,7 @@ export async function createReplyNote(
|
||||
event_id: string,
|
||||
pubkey: string,
|
||||
kind: number,
|
||||
tags: any,
|
||||
tags: string[][],
|
||||
content: string,
|
||||
created_at: number
|
||||
) {
|
||||
@ -272,11 +306,30 @@ export async function getChannelUsers(channel_id: string) {
|
||||
export async function getChats() {
|
||||
const db = await connect();
|
||||
const account = await getActiveAccount();
|
||||
const result: any = await db.select(
|
||||
const follows =
|
||||
typeof account.follows === 'string' ? JSON.parse(account.follows) : account.follows;
|
||||
|
||||
const chats: { follows: Array<Chats> | null; unknown: number } = {
|
||||
follows: [],
|
||||
unknown: 0,
|
||||
};
|
||||
|
||||
let result: Array<Chats> = await db.select(
|
||||
`SELECT DISTINCT sender_pubkey FROM chats WHERE receiver_pubkey = "${account.pubkey}" ORDER BY created_at DESC;`
|
||||
);
|
||||
const newArr: any = result.map((v) => ({ ...v, new_messages: 0 }));
|
||||
return newArr;
|
||||
|
||||
result = result.map((v) => ({ ...v, new_messages: 0 }));
|
||||
result = result.sort((a, b) => a.new_messages - b.new_messages);
|
||||
|
||||
chats.follows = result.filter((el) => {
|
||||
return follows.some((i) => {
|
||||
return i === el.sender_pubkey;
|
||||
});
|
||||
});
|
||||
|
||||
chats.unknown = result.length - chats.follows.length;
|
||||
|
||||
return chats;
|
||||
}
|
||||
|
||||
// get chat messages
|
||||
@ -284,7 +337,7 @@ export async function getChatMessages(receiver_pubkey: string, sender_pubkey: st
|
||||
const db = await connect();
|
||||
let receiver = [];
|
||||
|
||||
const sender: any = await db.select(
|
||||
const sender: Array<Chats> = await db.select(
|
||||
`SELECT * FROM chats WHERE sender_pubkey = "${sender_pubkey}" AND receiver_pubkey = "${receiver_pubkey}";`
|
||||
);
|
||||
|
||||
@ -321,7 +374,9 @@ export async function createChat(
|
||||
// get setting
|
||||
export async function getSetting(key: string) {
|
||||
const db = await connect();
|
||||
const result = await db.select(`SELECT value FROM settings WHERE key = "${key}";`);
|
||||
const result: Array<Settings> = await db.select(
|
||||
`SELECT value FROM settings WHERE key = "${key}";`
|
||||
);
|
||||
return result[0]?.value;
|
||||
}
|
||||
|
||||
@ -334,7 +389,9 @@ export async function updateSetting(key: string, value: string | number) {
|
||||
// get last login
|
||||
export async function getLastLogin() {
|
||||
const db = await connect();
|
||||
const result = await db.select(`SELECT value FROM settings WHERE key = "last_login";`);
|
||||
const result: Array<Settings> = await db.select(
|
||||
`SELECT value FROM settings WHERE key = "last_login";`
|
||||
);
|
||||
if (result[0]) {
|
||||
return parseInt(result[0].value);
|
||||
} else {
|
||||
@ -350,56 +407,22 @@ export async function updateLastLogin(value: number) {
|
||||
);
|
||||
}
|
||||
|
||||
// get blacklist by kind and account id
|
||||
export async function getBlacklist(account_id: number, kind: number) {
|
||||
const db = await connect();
|
||||
return await db.select(
|
||||
`SELECT * FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}";`
|
||||
);
|
||||
}
|
||||
|
||||
// get active blacklist by kind and account id
|
||||
export async function getActiveBlacklist(account_id: number, kind: number) {
|
||||
const db = await connect();
|
||||
return await db.select(
|
||||
`SELECT content FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}" AND status = 1;`
|
||||
);
|
||||
}
|
||||
|
||||
// add to blacklist
|
||||
export async function addToBlacklist(
|
||||
account_id: number,
|
||||
content: string,
|
||||
kind: number,
|
||||
status?: number
|
||||
) {
|
||||
const db = await connect();
|
||||
return await db.execute(
|
||||
'INSERT OR IGNORE INTO blacklist (account_id, content, kind, status) VALUES (?, ?, ?, ?);',
|
||||
[account_id, content, kind, status || 1]
|
||||
);
|
||||
}
|
||||
|
||||
// update item in blacklist
|
||||
export async function updateItemInBlacklist(content: string, status: number) {
|
||||
const db = await connect();
|
||||
return await db.execute(
|
||||
`UPDATE blacklist SET status = "${status}" WHERE content = "${content}";`
|
||||
);
|
||||
}
|
||||
|
||||
// get all blocks
|
||||
export async function getBlocks() {
|
||||
const db = await connect();
|
||||
const activeAccount = await getActiveAccount();
|
||||
const result: any = await db.select(
|
||||
`SELECT * FROM blocks WHERE account_id = "${activeAccount.id}" ORDER BY created_at DESC;`
|
||||
const account = await getActiveAccount();
|
||||
const result: Array<Block> = await db.select(
|
||||
`SELECT * FROM blocks WHERE account_id = "${account.id}" ORDER BY created_at DESC;`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// create block
|
||||
export async function createBlock(kind: number, title: string, content: any) {
|
||||
export async function createBlock(
|
||||
kind: number,
|
||||
title: string,
|
||||
content: string | string[]
|
||||
) {
|
||||
const db = await connect();
|
||||
const activeAccount = await getActiveAccount();
|
||||
return await db.execute(
|
||||
@ -437,12 +460,29 @@ export async function createMetadata(id: string, pubkey: string, content: string
|
||||
);
|
||||
}
|
||||
|
||||
// get metadata
|
||||
export async function getAllMetadata() {
|
||||
const db = await connect();
|
||||
const result: LumeEvent[] = await db.select(`SELECT * FROM metadata;`);
|
||||
const users: Profile[] = result.map((el) => {
|
||||
const profile: Profile = destr(el.content);
|
||||
return {
|
||||
pubkey: el.pubkey,
|
||||
ident: profile.name || profile.display_name || profile.username || 'anon',
|
||||
picture:
|
||||
profile.picture ||
|
||||
profile.image ||
|
||||
'https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih.jpg',
|
||||
};
|
||||
});
|
||||
return users;
|
||||
}
|
||||
|
||||
// get user metadata
|
||||
export async function getUserMetadata(pubkey: string) {
|
||||
const db = await connect();
|
||||
const result = await db.select(`SELECT content FROM metadata WHERE id = "${pubkey}";`);
|
||||
const result = await db.select(`SELECT * FROM metadata WHERE pubkey = "${pubkey}";`);
|
||||
if (result[0]) {
|
||||
return JSON.parse(result[0].content);
|
||||
return JSON.parse(result[0].content) as Profile;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { NDKProvider } from '@libs/ndk/provider';
|
||||
@ -25,6 +24,5 @@ root.render(
|
||||
<NDKProvider>
|
||||
<App />
|
||||
</NDKProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
@ -94,7 +94,7 @@ export function ActiveAccount({ data }: { data: any }) {
|
||||
return (
|
||||
<Link to={`/app/user/${data.pubkey}`} className="relative inline-block h-9 w-9">
|
||||
<Image
|
||||
src={user.image}
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={data.npub}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
|
@ -7,7 +7,7 @@ export function Button({
|
||||
disabled = false,
|
||||
onClick = undefined,
|
||||
}: {
|
||||
preset: 'small' | 'publish' | 'large';
|
||||
preset: 'small' | 'publish' | 'large' | 'large-alt';
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
@ -26,6 +26,10 @@ export function Button({
|
||||
preClass =
|
||||
'h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600';
|
||||
break;
|
||||
case 'large-alt':
|
||||
preClass =
|
||||
'h-11 w-full bg-zinc-800 rounded-md font-medium text-zinc-300 border-t border-zinc-700/50 hover:bg-zinc-900';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
164
src/shared/composer/composer.tsx
Normal file
164
src/shared/composer/composer.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
6
src/shared/composer/index.tsx
Normal file
6
src/shared/composer/index.tsx
Normal 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';
|
31
src/shared/composer/mention/item.tsx
Normal file
31
src/shared/composer/mention/item.tsx
Normal 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>
|
||||
);
|
||||
}
|
75
src/shared/composer/mention/list.tsx
Normal file
75
src/shared/composer/mention/list.tsx
Normal 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';
|
71
src/shared/composer/mention/suggestion.tsx
Normal file
71
src/shared/composer/mention/suggestion.tsx
Normal 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();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
@ -3,8 +3,7 @@ import { Fragment } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { Button } from '@shared/button';
|
||||
import { Post } from '@shared/composer/types/post';
|
||||
import { User } from '@shared/composer/user';
|
||||
import { Composer, ComposerUser } from '@shared/composer';
|
||||
import {
|
||||
CancelIcon,
|
||||
ChevronDownIcon,
|
||||
@ -17,9 +16,8 @@ import { COMPOSE_SHORTCUT } from '@stores/shortcuts';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
|
||||
export function Composer() {
|
||||
export function ComposerModal() {
|
||||
const { account } = useAccount();
|
||||
|
||||
const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]);
|
||||
|
||||
const closeModal = () => {
|
||||
@ -31,7 +29,7 @@ export function Composer() {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => toggle(true)} preset="small">
|
||||
<ComposeIcon width={14} height={14} />
|
||||
<ComposeIcon className="h-4 w-4" />
|
||||
Compose
|
||||
</Button>
|
||||
<Transition appear show={open} as={Fragment}>
|
||||
@ -60,7 +58,7 @@ export function Composer() {
|
||||
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{account && <User pubkey={account.pubkey} />}</div>
|
||||
{account && <ComposerUser pubkey={account.pubkey} />}
|
||||
<span>
|
||||
<ChevronRightIcon
|
||||
width={14}
|
||||
@ -70,20 +68,18 @@ export function Composer() {
|
||||
</span>
|
||||
<div className="inline-flex h-7 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-sm font-medium text-zinc-400">
|
||||
New Post
|
||||
<ChevronDownIcon width={14} height={14} />
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
<button
|
||||
onClick={closeModal}
|
||||
onKeyDown={closeModal}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<CancelIcon width={16} height={16} className="text-zinc-500" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{account && <Post />}
|
||||
<Composer />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -4,12 +4,12 @@ import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
|
||||
export function User({ pubkey }: { pubkey: string }) {
|
||||
export function ComposerUser({ pubkey }: { pubkey: string }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
|
@ -43,4 +43,7 @@ export * from './settings';
|
||||
export * from './logout';
|
||||
export * from './follow';
|
||||
export * from './unfollow';
|
||||
export * from './reaction';
|
||||
export * from './thread';
|
||||
export * from './strangers';
|
||||
// @endindex
|
||||
|
37
src/shared/icons/reaction.tsx
Normal file
37
src/shared/icons/reaction.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -2,14 +2,20 @@ import { SVGProps } from 'react';
|
||||
|
||||
export function RepostIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M17.25 21.25L20.25 18.25L17.25 15.25M6.75 2.75L3.75 5.75L6.75 8.75M5.25 5.75H20.25V10.75M3.75 13.75V18.25H18.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
strokeWidth="1.5"
|
||||
d="M12 21.25c4.28 0 7.75-3.75 7.75-8.25 0-5.167-4.613-8.829-6.471-10.094-.426-.29-.988-.165-1.285.257L9.582 6.59a1.002 1.002 0 01-1.525.131c-.39-.387-1.026-.391-1.376.033C5.06 8.718 4.25 11.16 4.25 13c0 4.5 3.47 8.25 7.75 8.25zm0 0c1.657 0 3-1.533 3-3.424 0-2.084-1.663-3.601-2.513-4.24a.802.802 0 00-.974 0c-.85.639-2.513 2.156-2.513 4.24 0 1.89 1.343 3.424 3 3.424z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
22
src/shared/icons/strangers.tsx
Normal file
22
src/shared/icons/strangers.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -11,12 +11,9 @@ export function ThreadIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGEleme
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M2.75 12h18.5M2.75 5.75h18.5m-18.5 12.5h8.75"
|
||||
/>
|
||||
fill="currentColor"
|
||||
d="M12 19.25V20a.75.75 0 00.75-.75H12zm8.5-9a.75.75 0 001.5 0h-1.5zm-.75 3.5a.75.75 0 00-1.5 0h1.5zm-1.5 6.5a.75.75 0 001.5 0h-1.5zm-2.5-4a.75.75 0 000 1.5v-1.5zm6.5 1.5a.75.75 0 000-1.5v1.5zm-18.75.5V5.75H2v12.5h1.5zm8.5.25H3.75V20H12v-1.5zm8.5-12.75v4.5H22v-4.5h-1.5zM3.75 5.5H12V4H3.75v1.5zm8.25 0h8.25V4H12v1.5zm.75 13.75V4.75h-1.5v14.5h1.5zm5.5-5.5V17h1.5v-3.25h-1.5zm0 3.25v3.25h1.5V17h-1.5zm-2.5.75H19v-1.5h-3.25v1.5zm3.25 0h3.25v-1.5H19v1.5zm3-12A1.75 1.75 0 0020.25 4v1.5a.25.25 0 01.25.25H22zm-18.5 0a.25.25 0 01.25-.25V4A1.75 1.75 0 002 5.75h1.5zM2 18.25c0 .966.784 1.75 1.75 1.75v-1.5a.25.25 0 01-.25-.25H2z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { twMerge } from 'tailwind-merge';
|
||||
import { ChatsList } from '@app/chat/components/list';
|
||||
|
||||
import { AppHeader } from '@shared/appHeader';
|
||||
import { Composer } from '@shared/composer/modal';
|
||||
import { ComposerModal } from '@shared/composer/modal';
|
||||
import { NavArrowDownIcon, SpaceIcon, TrendingIcon } from '@shared/icons';
|
||||
import { LumeBar } from '@shared/lumeBar';
|
||||
|
||||
@ -15,7 +15,7 @@ export function Navigation() {
|
||||
<AppHeader />
|
||||
<div className="scrollbar-hide flex flex-col gap-5 overflow-y-auto pb-20">
|
||||
<div className="inlin-lflex h-8 px-3.5">
|
||||
<Composer />
|
||||
<ComposerModal />
|
||||
</div>
|
||||
{/* Newsfeed */}
|
||||
<div className="flex flex-col gap-0.5 px-1.5">
|
||||
|
64
src/shared/notes/actions.tsx
Normal file
64
src/shared/notes/actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
141
src/shared/notes/actions/reaction.tsx
Normal file
141
src/shared/notes/actions/reaction.tsx
Normal 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>
|
||||
);
|
||||
}
|
29
src/shared/notes/actions/reply.tsx
Normal file
29
src/shared/notes/actions/reply.tsx
Normal 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>
|
||||
);
|
||||
}
|
39
src/shared/notes/actions/repost.tsx
Normal file
39
src/shared/notes/actions/repost.tsx
Normal 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>
|
||||
);
|
||||
}
|
24
src/shared/notes/actions/zap.tsx
Normal file
24
src/shared/notes/actions/zap.tsx
Normal 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>
|
||||
);
|
||||
}
|
47
src/shared/notes/content.tsx
Normal file
47
src/shared/notes/content.tsx
Normal 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} />)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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} />)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
23
src/shared/notes/hashtag.tsx
Normal file
23
src/shared/notes/hashtag.tsx
Normal 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>
|
||||
);
|
||||
}
|
27
src/shared/notes/index.tsx
Normal file
27
src/shared/notes/index.tsx
Normal 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';
|
39
src/shared/notes/kinds/kind1.tsx
Normal file
39
src/shared/notes/kinds/kind1.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
src/shared/notes/kinds/kind1063.tsx
Normal file
38
src/shared/notes/kinds/kind1063.tsx
Normal 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>
|
||||
);
|
||||
}
|
54
src/shared/notes/kinds/repost.tsx
Normal file
54
src/shared/notes/kinds/repost.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
src/shared/notes/kinds/sub.tsx
Normal file
40
src/shared/notes/kinds/sub.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
39
src/shared/notes/kinds/thread.tsx
Normal file
39
src/shared/notes/kinds/thread.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
src/shared/notes/kinds/unsupport.tsx
Normal file
36
src/shared/notes/kinds/unsupport.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
@ -1,32 +1,23 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { memo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { createBlock } from '@libs/storage';
|
||||
|
||||
import { Kind1 } from '@shared/notes/contents/kind1';
|
||||
import { Kind1063 } from '@shared/notes/contents/kind1063';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { MentionUser, NoteSkeleton } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { BLOCK_KINDS } from '@stores/constants';
|
||||
|
||||
import { useBlock } from '@utils/hooks/useBlock';
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { add } = useBlock();
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
return createBlock(data.kind, data.title, data.content);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||
},
|
||||
});
|
||||
|
||||
const openThread = (event: any, thread: string) => {
|
||||
const openThread = (event, thread: string) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
block.mutate({ kind: 2, title: 'Thread', content: thread });
|
||||
add.mutate({ kind: BLOCK_KINDS.thread, title: 'Thread', content: thread });
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
@ -38,31 +29,37 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
|
||||
onKeyDown={(e) => openThread(e, id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mt-3 rounded-lg border-t border-zinc-700/50 bg-zinc-800/50 px-3 py-3"
|
||||
className="mb-2 mt-3 rounded-lg border-t border-zinc-700/50 bg-zinc-800/50 px-3 py-3"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<NoteSkeleton />
|
||||
) : status === 'success' ? (
|
||||
<>
|
||||
<User pubkey={data.pubkey} time={data.created_at} size="small" />
|
||||
<div>
|
||||
{data.kind === 1 && <Kind1 content={data.content} truncate={true} />}
|
||||
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
|
||||
{data.kind !== 1 && data.kind !== 1063 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="inline-flex flex-col gap-1 rounded-md bg-zinc-800 px-2 py-2">
|
||||
<span className="text-sm font-medium leading-none text-zinc-500">
|
||||
Kind: {data.kind}
|
||||
</span>
|
||||
<p className="text-sm leading-none text-fuchsia-500">
|
||||
Lume isn't fully support this kind in newsfeed
|
||||
</p>
|
||||
</div>
|
||||
<div className="select-text whitespace-pre-line break-words text-base text-zinc-100">
|
||||
<p>{data.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<ReactMarkdown
|
||||
className="markdown"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
del: ({ children }) => {
|
||||
const key = children[0] as string;
|
||||
if (key.startsWith('pub')) return <MentionUser pubkey={key.slice(3)} />;
|
||||
if (key.startsWith('tag'))
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="font-normal text-orange-400 no-underline hover:text-orange-500"
|
||||
>
|
||||
{key.slice(3)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{data?.content?.parsed?.length > 200
|
||||
? data.content.parsed.substring(0, 200) + '...'
|
||||
: data.content.parsed}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
@ -1,17 +1,26 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BLOCK_KINDS } from '@stores/constants';
|
||||
|
||||
import { useBlock } from '@utils/hooks/useBlock';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { add } = useBlock();
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/app/user/${pubkey}`}
|
||||
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
add.mutate({
|
||||
kind: BLOCK_KINDS.user,
|
||||
title: user?.nip05 || user?.name || user?.displayNam,
|
||||
content: pubkey,
|
||||
})
|
||||
}
|
||||
className="break-words rounded bg-zinc-800 px-2 py-px text-sm font-normal text-blue-400 no-underline hover:bg-zinc-700 hover:text-blue-500"
|
||||
>
|
||||
@{user?.name || user?.displayName || shortenKey(pubkey)}
|
||||
</Link>
|
||||
{'@' + user?.name || user?.displayName || shortenKey(pubkey)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -1,165 +1,117 @@
|
||||
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { decode } from 'light-bolt11-decoder';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { createBlock, createReplyNote } from '@libs/storage';
|
||||
import { createReplyNote } from '@libs/storage';
|
||||
|
||||
import { LoaderIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons';
|
||||
import { ThreadIcon } from '@shared/icons/thread';
|
||||
import { NoteReply } from '@shared/notes/metadata/reply';
|
||||
import { NoteRepost } from '@shared/notes/metadata/repost';
|
||||
import { NoteZap } from '@shared/notes/metadata/zap';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { MiniUser } from '@shared/notes/users/mini';
|
||||
|
||||
export function NoteMetadata({
|
||||
id,
|
||||
rootID,
|
||||
eventPubkey,
|
||||
}: {
|
||||
id: string;
|
||||
rootID?: string;
|
||||
eventPubkey: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
import { BLOCK_KINDS } from '@stores/constants';
|
||||
|
||||
import { useBlock } from '@utils/hooks/useBlock';
|
||||
import { compactNumber } from '@utils/number';
|
||||
|
||||
export function NoteMetadata({ id }: { id: string }) {
|
||||
const { add } = useBlock();
|
||||
const { ndk } = useNDK();
|
||||
const { status, data } = useQuery(['note-metadata', id], async () => {
|
||||
let replies = 0;
|
||||
let reposts = 0;
|
||||
let zap = 0;
|
||||
const { status, data } = useQuery(
|
||||
['note-metadata', id],
|
||||
async () => {
|
||||
let replies = 0;
|
||||
let zap = 0;
|
||||
const users = [];
|
||||
|
||||
const filter: NDKFilter = {
|
||||
'#e': [id],
|
||||
kinds: [1, 6, 9735],
|
||||
};
|
||||
const filter: NDKFilter = {
|
||||
'#e': [id],
|
||||
kinds: [1, 9735],
|
||||
};
|
||||
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
events.forEach((event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
replies += 1;
|
||||
createReplyNote(
|
||||
id,
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at
|
||||
);
|
||||
break;
|
||||
case 6:
|
||||
reposts += 1;
|
||||
break;
|
||||
case 9735: {
|
||||
const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1];
|
||||
if (bolt11) {
|
||||
const decoded = decode(bolt11);
|
||||
const amount = decoded.sections.find((item) => item.name === 'amount');
|
||||
const sats = amount.value / 1000;
|
||||
zap += sats;
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
events.forEach((event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
replies += 1;
|
||||
if (users.length < 3) users.push(event.pubkey);
|
||||
createReplyNote(
|
||||
id,
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at
|
||||
);
|
||||
break;
|
||||
case 9735: {
|
||||
const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1];
|
||||
if (bolt11) {
|
||||
const decoded = decode(bolt11);
|
||||
const amount = decoded.sections.find((item) => item.name === 'amount');
|
||||
const sats = amount.value / 1000;
|
||||
zap += sats;
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { replies, reposts, zap };
|
||||
});
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
return createBlock(data.kind, data.title, data.content);
|
||||
return { replies, users, zap };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||
},
|
||||
});
|
||||
|
||||
const openThread = (thread: string) => {
|
||||
block.mutate({ kind: 2, title: 'Thread', content: thread });
|
||||
};
|
||||
{ refetchOnWindowFocus: false, refetchOnReconnect: false }
|
||||
);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="mt-2 inline-flex h-12 w-full items-center">
|
||||
<div className="group inline-flex w-20 items-center gap-1.5">
|
||||
<ReplyIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-green-400"
|
||||
/>
|
||||
<LoaderIcon
|
||||
width={12}
|
||||
height={12}
|
||||
className="animate-spin text-black dark:text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="group inline-flex w-20 items-center gap-1.5">
|
||||
<RepostIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-green-400"
|
||||
/>
|
||||
<LoaderIcon
|
||||
width={12}
|
||||
height={12}
|
||||
className="animate-spin text-black dark:text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="group inline-flex w-20 items-center gap-1.5">
|
||||
<ZapIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-green-400"
|
||||
/>
|
||||
<LoaderIcon
|
||||
width={12}
|
||||
height={12}
|
||||
className="animate-spin text-black dark:text-zinc-100"
|
||||
/>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="mt-2h-6 w-11 shrink-0"></div>
|
||||
<div className="mt-2 inline-flex h-6 items-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-zinc-100" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<div className="mt-2 inline-flex h-12 w-full items-center justify-between">
|
||||
<div className="inline-flex items-center justify-between">
|
||||
<NoteReply
|
||||
id={id}
|
||||
rootID={rootID}
|
||||
pubkey={eventPubkey}
|
||||
replies={data.replies}
|
||||
/>
|
||||
<NoteRepost id={id} pubkey={eventPubkey} reposts={data.reposts} />
|
||||
<NoteZap zaps={data.zap} />
|
||||
</div>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openThread(id)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800 hover:bg-zinc-700"
|
||||
>
|
||||
<ThreadIcon className="h-4 w-4 text-zinc-400" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="-left-10 select-none rounded-md bg-zinc-800/80 px-3.5 py-1.5 text-sm leading-none text-zinc-100 backdrop-blur-lg will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"
|
||||
sideOffset={5}
|
||||
>
|
||||
Open thread
|
||||
<Tooltip.Arrow className="fill-zinc-800/80 backdrop-blur-lg" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
<div>
|
||||
{data.replies > 0 ? (
|
||||
<>
|
||||
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
|
||||
<div className="relative z-10 flex items-center gap-3 bg-zinc-900 pb-3">
|
||||
<div className="mt-2 inline-flex h-6 w-11 shrink-0 items-center justify-center">
|
||||
<div className="isolate flex -space-x-1 overflow-hidden">
|
||||
{data.users?.map((user, index) => (
|
||||
<MiniUser key={user + index} pubkey={user} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 inline-flex h-6 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
add.mutate({ kind: BLOCK_KINDS.thread, title: 'Thread', content: id })
|
||||
}
|
||||
className="text-zinc-500"
|
||||
>
|
||||
<span className="font-semibold text-zinc-300">{data.replies}</span>{' '}
|
||||
replies
|
||||
</button>
|
||||
<span className="text-zinc-500">·</span>
|
||||
<p className="text-zinc-500">
|
||||
<span className="font-semibold text-zinc-300">
|
||||
{compactNumber.format(data.zap)}
|
||||
</span>{' '}
|
||||
zaps
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="pb-3" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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'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>
|
||||
);
|
||||
}
|
@ -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'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>
|
||||
);
|
||||
}
|
@ -2,7 +2,7 @@ import { Image } from '@shared/image';
|
||||
|
||||
export function ImagePreview({ urls, truncate }: { urls: string[]; truncate?: boolean }) {
|
||||
return (
|
||||
<div className="mt-3 max-w-[420px] overflow-hidden">
|
||||
<div className="mb-2 mt-3 max-w-[420px] overflow-hidden">
|
||||
<div className="flex flex-col gap-2">
|
||||
{urls.map((url) => (
|
||||
<div key={url} className="relative min-w-0 shrink-0 grow-0 basis-full">
|
||||
|
@ -3,11 +3,11 @@ import { Image } from '@shared/image';
|
||||
import { useOpenGraph } from '@utils/hooks/useOpenGraph';
|
||||
|
||||
export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
const domain = new URL(urls[0]);
|
||||
const { status, data, error } = useOpenGraph(urls[0]);
|
||||
const domain = new URL(urls[0]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 max-w-[420px] overflow-hidden rounded-lg bg-zinc-800">
|
||||
<div className="mb-2 mt-3 max-w-[420px] overflow-hidden rounded-lg bg-zinc-800">
|
||||
{status === 'loading' ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="h-44 w-full animate-pulse bg-zinc-700" />
|
||||
@ -21,7 +21,7 @@ export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
className="flex flex-col rounded-lg border border-zinc-800/50"
|
||||
className="flex flex-col rounded-lg border-t border-zinc-700/50"
|
||||
href={urls[0]}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@ -34,12 +34,14 @@ export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Image
|
||||
src={data.images?.[0] || 'https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW'}
|
||||
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
|
||||
alt={urls[0]}
|
||||
className="h-44 w-full rounded-t-lg object-cover"
|
||||
/>
|
||||
{data.images?.[0] && (
|
||||
<Image
|
||||
src={data.images?.[0] || 'https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW'}
|
||||
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
|
||||
alt={urls[0]}
|
||||
className="h-44 w-full rounded-t-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
<h5 className="line-clamp-1 font-medium leading-none text-zinc-200">
|
||||
{data.title}
|
||||
|
@ -2,7 +2,7 @@ import ReactPlayer from 'react-player/es6';
|
||||
|
||||
export function VideoPreview({ urls }: { urls: string[] }) {
|
||||
return (
|
||||
<div className="relative mt-3 flex w-full max-w-[420px] flex-col gap-2">
|
||||
<div className="relative mb-2 mt-3 flex w-full max-w-[420px] flex-col gap-2">
|
||||
{urls.map((url) => (
|
||||
<ReactPlayer
|
||||
key={url}
|
||||
|
@ -7,21 +7,16 @@ import { DEFAULT_AVATAR, FULL_RELAYS } from '@stores/constants';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { usePublish } from '@utils/hooks/usePublish';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
export function NoteReplyForm({
|
||||
rootID,
|
||||
userPubkey,
|
||||
}: {
|
||||
rootID: string;
|
||||
userPubkey: string;
|
||||
}) {
|
||||
export function NoteReplyForm({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
const publish = usePublish();
|
||||
const { status, user } = useProfile(userPubkey);
|
||||
|
||||
const { status, user } = useProfile(pubkey);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const submit = () => {
|
||||
const tags = [['e', rootID, FULL_RELAYS[0], 'root']];
|
||||
const tags = [['e', id, FULL_RELAYS[0], 'reply']];
|
||||
|
||||
// publish event
|
||||
publish({ content: value, kind: 1, tags });
|
||||
@ -31,36 +26,36 @@ export function NoteReplyForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||
<div className="relative w-full flex-1 overflow-hidden">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Reply to this thread..."
|
||||
className="relative h-20 w-full resize-none rounded-md bg-transparent px-5 py-3 text-base !outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
||||
className=" relative h-24 w-full resize-none rounded-md bg-transparent px-3 py-3 text-base !outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full border-t border-zinc-800 px-5 py-3">
|
||||
<div className="w-full border-t border-zinc-800 px-3 py-3">
|
||||
{status === 'loading' ? (
|
||||
<div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="relative h-9 w-9 shrink-0 rounded">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div className="relative h-11 w-11 shrink-0 rounded">
|
||||
<Image
|
||||
src={user.image}
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={userPubkey}
|
||||
className="h-9 w-9 rounded-md bg-white object-cover"
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-lg bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-px text-sm leading-none text-zinc-400">Reply as</p>
|
||||
<p className="mb-1 text-sm leading-none text-zinc-400">Reply as</p>
|
||||
<p className="text-sm font-medium leading-none text-zinc-100">
|
||||
{user.nip05 || user.name || shortenKey(userPubkey)}
|
||||
{user?.nip05 || user?.name || displayNpub(pubkey, 16)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,19 +1,33 @@
|
||||
import { Kind1 } from '@shared/notes/contents/kind1';
|
||||
import { NoteMetadata } from '@shared/notes/metadata';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { NoteActions, NoteContent, SubReply } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { parser } from '@utils/parser';
|
||||
import { LumeEvent } from '@utils/types';
|
||||
|
||||
export function Reply({ data }: { data: any }) {
|
||||
const content = parser(data);
|
||||
export function Reply({ event }: { event: LumeEvent }) {
|
||||
const content = useMemo(() => parser(event), [event]);
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex h-min min-h-min w-full select-text flex-col rounded-md bg-zinc-900 px-3 pt-5">
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[20px] pl-[50px]">
|
||||
<Kind1 content={content} />
|
||||
<NoteMetadata id={data.event_id} eventPubkey={data.pubkey} />
|
||||
<div className="h-min w-full py-1.5">
|
||||
<div className="relative overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
|
||||
<div className="relative flex flex-col">
|
||||
<User pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="relative z-20 -mt-6 flex items-start gap-3">
|
||||
<div className="w-11 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<NoteContent content={content} />
|
||||
<NoteActions id={event.event_id || event.id} pubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{event.replies ? (
|
||||
event.replies.map((sub) => <SubReply key={sub.id} event={sub} />)
|
||||
) : (
|
||||
<div className="pb-3" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,34 +1,66 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getReplies } from '@libs/storage';
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { Reply } from '@shared/notes/replies/item';
|
||||
import { NoteSkeleton, Reply } from '@shared/notes';
|
||||
|
||||
export function RepliesList({ parent_id }: { parent_id: string }) {
|
||||
const { status, data } = useQuery(['replies', parent_id], async () => {
|
||||
return await getReplies(parent_id);
|
||||
import { LumeEvent } from '@utils/types';
|
||||
|
||||
export function RepliesList({ id }: { id: string }) {
|
||||
const { relayUrls, fetcher } = useNDK();
|
||||
const { status, data } = useQuery(['thread', id], async () => {
|
||||
const events = (await fetcher.fetchAllEvents(
|
||||
relayUrls,
|
||||
{ kinds: [1], '#e': [id] },
|
||||
{ since: 0 }
|
||||
)) as unknown as LumeEvent[];
|
||||
if (events.length > 0) {
|
||||
const replies = new Set();
|
||||
events.forEach((event) => {
|
||||
const tags = event.tags.filter((el) => el[0] === 'e' && el[1] !== id);
|
||||
if (tags.length > 0) {
|
||||
tags.forEach((tag) => {
|
||||
const rootIndex = events.findIndex((el) => el.id === tag[1]);
|
||||
if (rootIndex) {
|
||||
const rootEvent = events[rootIndex];
|
||||
if (rootEvent.replies) {
|
||||
rootEvent.replies.push(event);
|
||||
} else {
|
||||
rootEvent.replies = [event];
|
||||
}
|
||||
replies.add(event.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
const cleanEvents = events.filter((ev) => !replies.has(ev.id));
|
||||
return cleanEvents;
|
||||
}
|
||||
return events;
|
||||
});
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="mt-3">
|
||||
<div className="mb-2">
|
||||
<h5 className="text-lg font-semibold text-zinc-300">Replies</h5>
|
||||
<h5 className="text-lg font-semibold text-zinc-300">{data.length} replies</h5>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{status === 'loading' ? (
|
||||
<div className="flex gap-2 px-3 py-4">
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="flex w-full flex-1 flex-col justify-center gap-1">
|
||||
<div className="flex items-baseline gap-2 text-base">
|
||||
<div className="h-2.5 w-20 animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
<div className="h-4 w-44 animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
{data?.length === 0 ? (
|
||||
<div className="px=3">
|
||||
<div className="flex w-full items-center justify-center rounded-md bg-zinc-900">
|
||||
<div className="flex w-full items-center justify-center rounded-xl bg-zinc-900">
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||
<h3 className="text-3xl">👋</h3>
|
||||
<p className="leading-none text-zinc-400">Share your thought on it...</p>
|
||||
@ -36,7 +68,7 @@ export function RepliesList({ parent_id }: { parent_id: string }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((event: NDKEvent) => <Reply key={event.id} data={event} />)
|
||||
data.reverse().map((event: NDKEvent) => <Reply key={event.id} event={event} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
24
src/shared/notes/replies/sub.tsx
Normal file
24
src/shared/notes/replies/sub.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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'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>
|
||||
);
|
||||
}
|
@ -2,16 +2,16 @@ export function NoteSkeleton() {
|
||||
return (
|
||||
<div className="flex h-min flex-col pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-lg bg-zinc-700" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="h-4 w-20 rounded bg-zinc-700" />
|
||||
<div className="h-3 w-20 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-5 animate-pulse pl-[49px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-4 w-full rounded-sm bg-zinc-700" />
|
||||
<div className="h-3 w-2/3 rounded-sm bg-zinc-700" />
|
||||
<div className="h-3 w-1/2 rounded-sm bg-zinc-700" />
|
||||
<div className="h-3 w-full rounded bg-zinc-700" />
|
||||
<div className="h-3 w-2/3 rounded bg-zinc-700" />
|
||||
<div className="h-3 w-1/2 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
86
src/shared/notes/stats.tsx
Normal file
86
src/shared/notes/stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/shared/notes/users/mini.tsx
Normal file
22
src/shared/notes/users/mini.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
34
src/shared/notes/users/repost.tsx
Normal file
34
src/shared/notes/users/repost.tsx
Normal 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>
|
||||
);
|
||||
}
|
46
src/shared/notes/users/thread.tsx
Normal file
46
src/shared/notes/users/thread.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
@ -9,29 +9,26 @@ import { BellIcon, CancelIcon, LoaderIcon } from '@shared/icons';
|
||||
import { NotificationUser } from '@shared/notification/user';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { dateToUnix, getHourAgo } from '@utils/date';
|
||||
import { nHoursAgo } from '@utils/date';
|
||||
import { LumeEvent } from '@utils/types';
|
||||
|
||||
export function NotificationModal({ pubkey }: { pubkey: string }) {
|
||||
const now = useRef(new Date());
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { ndk } = useNDK();
|
||||
const { fetcher, relayUrls } = useNDK();
|
||||
const { status, data } = useQuery(
|
||||
['user-notification', pubkey],
|
||||
['notification', pubkey],
|
||||
async () => {
|
||||
const filter: NDKFilter = {
|
||||
'#p': [pubkey],
|
||||
kinds: [1, 6, 7, 9735],
|
||||
since: dateToUnix(getHourAgo(48, now.current)),
|
||||
};
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
return [...events];
|
||||
const events = await fetcher.fetchAllEvents(
|
||||
relayUrls,
|
||||
{ '#p': [pubkey], kinds: [1, 6, 7, 9735] },
|
||||
{ since: nHoursAgo(48) },
|
||||
{ sort: true }
|
||||
);
|
||||
return events as unknown as LumeEvent[];
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { useStronghold } from '@stores/stronghold';
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
|
||||
export function Protected({ children }: { children: ReactNode }) {
|
||||
const password = useStronghold((state) => state.password);
|
||||
const privkey = useStronghold((state) => state.privkey);
|
||||
const { status, account } = useAccount();
|
||||
|
||||
if (status === 'success' && !account) {
|
||||
@ -17,7 +17,7 @@ export function Protected({ children }: { children: ReactNode }) {
|
||||
return <Navigate to="/auth/migrate" replace />;
|
||||
}
|
||||
|
||||
if (status === 'success' && account && !password) {
|
||||
if (status === 'success' && account && !privkey) {
|
||||
return <Navigate to="/auth/unlock" replace />;
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,10 @@
|
||||
import { CancelIcon } from '@shared/icons';
|
||||
|
||||
export function TitleBar({
|
||||
title,
|
||||
onClick = undefined,
|
||||
}: {
|
||||
title: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
import { useBlock } from '@utils/hooks/useBlock';
|
||||
|
||||
export function TitleBar({ id, title }: { id?: string; title: string }) {
|
||||
const { remove } = useBlock();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
@ -14,10 +12,10 @@ export function TitleBar({
|
||||
>
|
||||
<div className="w-6" />
|
||||
<h3 className="text-sm font-medium text-zinc-200">{title}</h3>
|
||||
{onClick ? (
|
||||
{id ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onClick={() => remove.mutate(id)}
|
||||
className="inline-flex h-6 w-6 shrink translate-y-8 transform items-center justify-center rounded transition-transform duration-150 ease-in-out hover:bg-zinc-900 group-hover:translate-y-0"
|
||||
>
|
||||
<CancelIcon width={12} height={12} className="text-zinc-300" />
|
||||
|
@ -2,6 +2,7 @@ import { Popover, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { VerticalDotsIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
@ -14,13 +15,13 @@ export function User({
|
||||
pubkey,
|
||||
time,
|
||||
size,
|
||||
repost,
|
||||
isRepost = false,
|
||||
isChat = false,
|
||||
}: {
|
||||
pubkey: string;
|
||||
time: number;
|
||||
size?: string;
|
||||
repost?: boolean;
|
||||
isRepost?: boolean;
|
||||
isChat?: boolean;
|
||||
}) {
|
||||
const { status, user } = useProfile(pubkey);
|
||||
@ -50,9 +51,7 @@ export function User({
|
||||
|
||||
return (
|
||||
<Popover
|
||||
className={`relative flex ${
|
||||
size === 'small' ? 'items-center gap-2' : 'items-start gap-3'
|
||||
}`}
|
||||
className={`flex ${size === 'small' ? 'items-center gap-2' : 'items-start gap-3'}`}
|
||||
>
|
||||
<Popover.Button
|
||||
className={`${avatarWidth} ${avatarHeight} relative z-10 shrink-0 overflow-hidden bg-zinc-900`}
|
||||
@ -61,24 +60,35 @@ export function User({
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className={`${avatarWidth} ${avatarHeight} ${
|
||||
className={`${
|
||||
isRepost ? 'ring-1 ring-zinc-800' : ''
|
||||
} ${avatarWidth} ${avatarHeight} ${
|
||||
size === 'small' ? 'rounded' : 'rounded-lg'
|
||||
} object-cover`}
|
||||
/>
|
||||
</Popover.Button>
|
||||
<div className="flex flex-wrap items-baseline gap-1">
|
||||
<div
|
||||
className={`${isRepost ? 'mt-4' : ''} flex flex-1 items-baseline justify-between`}
|
||||
>
|
||||
<h5
|
||||
className={`truncate font-semibold leading-none text-zinc-100 ${
|
||||
size === 'small' ? 'max-w-[8rem]' : 'max-w-[12rem]'
|
||||
size === 'small' ? 'max-w-[10rem]' : 'max-w-[15rem]'
|
||||
}`}
|
||||
>
|
||||
{user?.nip05 || user?.name || user?.displayName || shortenKey(pubkey)}
|
||||
{user?.nip05?.toLowerCase() ||
|
||||
user?.name ||
|
||||
user?.display_name ||
|
||||
shortenKey(pubkey)}
|
||||
</h5>
|
||||
{repost && (
|
||||
<span className="font-semibold leading-none text-fuchsia-500"> reposted</span>
|
||||
)}
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">{createdAt}</span>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="leading-none text-zinc-500">{createdAt}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-5 w-max items-center justify-center rounded px-1 hover:bg-zinc-800"
|
||||
>
|
||||
<VerticalDotsIcon className="h-4 w-4 rotate-90 transform text-zinc-200" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
|
115
src/shared/userProfile.tsx
Normal file
115
src/shared/userProfile.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -2,9 +2,9 @@ import { create } from 'zustand';
|
||||
|
||||
interface ComposerState {
|
||||
open: boolean;
|
||||
reply: { id: string; root: string; pubkey: string };
|
||||
reply: { id: string; pubkey: string; root?: string };
|
||||
toggleModal: (status: boolean) => void;
|
||||
setReply: (id: string, root: string, pubkey: string) => void;
|
||||
setReply: (id: string, pubkey: string) => void;
|
||||
clearReply: () => void;
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ export const useComposer = create<ComposerState>((set) => ({
|
||||
toggleModal: (status: boolean) => {
|
||||
set({ open: status });
|
||||
},
|
||||
setReply: (id: string, root: string, pubkey: string) => {
|
||||
setReply: (id: string, pubkey: string, root?: string) => {
|
||||
set({ reply: { id: id, root: root, pubkey: pubkey } });
|
||||
set({ open: true });
|
||||
},
|
||||
|
@ -1,4 +1,4 @@
|
||||
export const APP_VERSION = '1.0.1';
|
||||
export const APP_VERSION = '1.1.0';
|
||||
|
||||
export const DEFAULT_AVATAR = 'https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih';
|
||||
|
||||
@ -70,3 +70,12 @@ export const FULL_RELAYS = [
|
||||
'wss://relay.nostr.band/all',
|
||||
'wss://nostr.mutinywallet.com',
|
||||
];
|
||||
|
||||
export const BLOCK_KINDS = {
|
||||
image: 0,
|
||||
feed: 1,
|
||||
thread: 2,
|
||||
hashtag: 3,
|
||||
exchange_rate: 4,
|
||||
user: 5,
|
||||
};
|
||||
|
@ -3,11 +3,8 @@ import { create } from 'zustand';
|
||||
interface OnboardingState {
|
||||
profile: { [x: string]: string };
|
||||
pubkey: string;
|
||||
privkey: string;
|
||||
createProfile: (data: { [x: string]: string }) => void;
|
||||
setPubkey: (pubkey: string) => void;
|
||||
setPrivkey: (privkey: string) => void;
|
||||
clearPrivkey: (privkey: string) => void;
|
||||
}
|
||||
|
||||
export const useOnboarding = create<OnboardingState>((set) => ({
|
||||
@ -20,10 +17,4 @@ export const useOnboarding = create<OnboardingState>((set) => ({
|
||||
setPubkey: (pubkey: string) => {
|
||||
set({ pubkey: pubkey });
|
||||
},
|
||||
setPrivkey: (privkey: string) => {
|
||||
set({ privkey: privkey });
|
||||
},
|
||||
clearPrivkey: () => {
|
||||
set({ privkey: '' });
|
||||
},
|
||||
}));
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user