mirror of
https://github.com/lumehq/lume.git
synced 2025-03-29 11:11:43 +01:00
Merge pull request #14 from reyamir/feat/v0.2.0
Release first public version - v0.2.0
This commit is contained in:
commit
641a2c4acf
.prettierrcpackage.jsonpnpm-lock.yaml
src-tauri
src
App.css
tailwind.config.jstsconfig.jsonassets
components
appHeader
columns
account
navigator
contexts
form
imageWithFallback.tsxnote
atoms
base.tsxcomment.tsxconnector.tsxcontent
extend.tsxmeta
metadata.tsxparent.tsxplaceholder.tsxpreview
repost.tsxsingle.tsxprofile
relaysProvider.tsxuser
layouts
pages
stores
utils
@ -12,7 +12,9 @@
|
||||
"^@layouts/(.*)$",
|
||||
"^@pages/(.*)$",
|
||||
"^@components/(.*)$",
|
||||
"^@stores/(.*)$",
|
||||
"^@utils/(.*)$",
|
||||
"^@assets/(.*)$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^[./]"
|
||||
],
|
||||
|
54
package.json
54
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 1420",
|
||||
"build": "next build && next export -o dist",
|
||||
@ -12,53 +12,61 @@
|
||||
"**/*": "prettier --write --ignore-unknown"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.0.2",
|
||||
"@radix-ui/react-dialog": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||
"@radix-ui/react-icons": "^1.2.0",
|
||||
"@rehooks/local-storage": "^2.4.4",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-popover": "^1.0.5",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@supabase/supabase-js": "^2.12.1",
|
||||
"@tanstack/query-core": "^4.27.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"@uiw/react-markdown-preview": "^4.1.10",
|
||||
"@uiw/react-md-editor": "^3.20.5",
|
||||
"bitcoin-address-validation": "^2.2.1",
|
||||
"boring-avatars": "^1.7.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"destr": "^1.2.2",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"framer-motion": "^9.1.7",
|
||||
"moment": "^2.29.4",
|
||||
"jotai": "^2.0.3",
|
||||
"jotai-cache": "^0.3.0",
|
||||
"jotai-tanstack-query": "^0.6.0",
|
||||
"next": "^13.2.4",
|
||||
"next-remove-imports": "^1.0.10",
|
||||
"nostr-relaypool": "^0.5.12",
|
||||
"nostr-tools": "^1.7.4",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"nostr-relaypool": "^0.5.18",
|
||||
"nostr-tools": "^1.8.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.43.5",
|
||||
"react-moment": "^1.1.3",
|
||||
"react-hook-form": "^7.43.8",
|
||||
"react-player": "^2.12.0",
|
||||
"react-virtuoso": "^4.1.0",
|
||||
"react-string-replace": "^1.1.0",
|
||||
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"ws": "^8.12.1"
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tauri-apps/cli": "^1.2.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
||||
"@types/node": "^18.15.0",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/node": "^18.15.10",
|
||||
"@types/react": "^18.0.29",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.1",
|
||||
"@typescript-eslint/parser": "^5.54.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.56.0",
|
||||
"@typescript-eslint/parser": "^5.56.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"csstype": "^3.1.1",
|
||||
"eslint": "^8.35.0",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-config-next": "^13.2.4",
|
||||
"eslint-config-prettier": "^8.7.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^13.1.2",
|
||||
"lint-staged": "^13.2.0",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^2.8.4",
|
||||
"prettier-plugin-tailwindcss": "^0.2.4",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||
"prop-types": "^15.8.1",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "^4.9.5"
|
||||
|
2330
pnpm-lock.yaml
generated
2330
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
335
src-tauri/Cargo.lock
generated
335
src-tauri/Cargo.lock
generated
@ -82,6 +82,22 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attohttpc"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fcf00bc6d5abb29b5f97e3c61a90b6d3caa12f3faf897d4a3e3607c050a35a7"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"http",
|
||||
"log",
|
||||
"native-tls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.1.0"
|
||||
@ -500,17 +516,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.17"
|
||||
@ -899,7 +904,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows 0.39.0",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1393,15 +1398,6 @@ version = "0.2.139"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f8d7ae751e1cb825c840ae5e682f59b098cdfd213c350ac268b61449a5f58a0"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.7.4"
|
||||
@ -1468,7 +1464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lume"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"cocoa",
|
||||
"objc",
|
||||
@ -1485,19 +1481,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e72d50edb17756489e79d52eb146927bec8eba9dd48faadf9ef08bca3791ad5"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"dirs-next",
|
||||
"objc-foundation",
|
||||
"objc_id",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@ -1578,6 +1561,24 @@ dependencies = [
|
||||
"windows-sys 0.42.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.6.0"
|
||||
@ -1637,17 +1638,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ce656bb6d22a93ae276a23de52d1aec5ba4db3ece3c0eb79dfd5add7384db6a"
|
||||
dependencies = [
|
||||
"dbus",
|
||||
"mac-notification-sys",
|
||||
"tauri-winrt-notification",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
@ -1729,17 +1719,6 @@ dependencies = [
|
||||
"objc_exception",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-foundation"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
|
||||
dependencies = [
|
||||
"block",
|
||||
"objc",
|
||||
"objc_id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc_exception"
|
||||
version = "0.1.2"
|
||||
@ -1774,6 +1753,62 @@ dependencies = [
|
||||
"windows-sys 0.42.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b102428fd03bc5edf97f62620f7298614c45cedf287c271e7ed450bbaf83f2e1"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23bbbf7854cd45b83958ebe919f0e8e516793727652e27fda10a8384cfc790b7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c424bc68d15e0778838ac013b5b3449544d8133633d8016319e7e05a820b8c0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
@ -2026,7 +2061,7 @@ dependencies = [
|
||||
"base64 0.13.1",
|
||||
"indexmap",
|
||||
"line-wrap",
|
||||
"quick-xml 0.26.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@ -2104,15 +2139,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11bafc859c6815fbaffbbbf4229ecb767ac913fecb27f9ad4343662e9ef099ea"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.26.0"
|
||||
@ -2276,30 +2302,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea"
|
||||
dependencies = [
|
||||
"block",
|
||||
"dispatch",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
"objc_id",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.37.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.16.20"
|
||||
@ -2381,6 +2383,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3"
|
||||
dependencies = [
|
||||
"windows-sys 0.42.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@ -2403,6 +2414,29 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31c9bb296072e961fcbd8853511dd39c2d8be2deb1e17c6860b1d30732b323b4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.22.0"
|
||||
@ -2492,6 +2526,18 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa 1.0.5",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "1.14.0"
|
||||
@ -2797,27 +2843,6 @@ version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7ac893c7d471c8a21f31cfe213ec4f6d9afeed25537c772e08ef3f005f8729e"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "339f799d8b549e3744c7ac7feb216383e4005d94bdb22561b3ab8f3b808ae9fb"
|
||||
dependencies = [
|
||||
"heck 0.3.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.107"
|
||||
@ -2898,7 +2923,7 @@ dependencies = [
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
"uuid 1.3.0",
|
||||
"windows 0.39.0",
|
||||
"windows",
|
||||
"windows-implement",
|
||||
"x11-dl",
|
||||
]
|
||||
@ -2921,6 +2946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe7e0f1d535e7cbbbab43c82be4fc992b84f9156c16c160955617e0260ebc449"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"attohttpc",
|
||||
"cocoa",
|
||||
"dirs-next",
|
||||
"embed_plist",
|
||||
@ -2933,15 +2959,14 @@ dependencies = [
|
||||
"heck 0.4.1",
|
||||
"http",
|
||||
"ignore",
|
||||
"notify-rust",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"open",
|
||||
"os_info",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"raw-window-handle",
|
||||
"regex",
|
||||
"rfd",
|
||||
"semver 1.0.16",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -2960,7 +2985,7 @@ dependencies = [
|
||||
"uuid 1.3.0",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.39.0",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3051,7 +3076,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"uuid 1.3.0",
|
||||
"webview2-com",
|
||||
"windows 0.39.0",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3070,7 +3095,7 @@ dependencies = [
|
||||
"uuid 1.3.0",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.39.0",
|
||||
"windows",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@ -3099,18 +3124,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"url",
|
||||
"walkdir",
|
||||
"windows 0.39.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c58de036c4d2e20717024de2a3c4bf56c301f07b21bc8ef9b57189fce06f1f3b"
|
||||
dependencies = [
|
||||
"quick-xml 0.23.1",
|
||||
"strum",
|
||||
"windows 0.39.0",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3513,18 +3527,6 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f219e0d211ba40266969f6dbdd90636da12f75bee4fc9d6c23d1260dadb51454"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.84"
|
||||
@ -3638,7 +3640,7 @@ checksum = "b4a769c9f1a64a8734bde70caafac2b96cada12cd4aefa49196b3a386b8b4178"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows 0.39.0",
|
||||
"windows",
|
||||
"windows-implement",
|
||||
]
|
||||
|
||||
@ -3663,7 +3665,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"windows 0.39.0",
|
||||
"windows",
|
||||
"windows-bindgen",
|
||||
"windows-metadata",
|
||||
]
|
||||
@ -3699,19 +3701,6 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647"
|
||||
dependencies = [
|
||||
"windows_aarch64_msvc 0.37.0",
|
||||
"windows_i686_gnu 0.37.0",
|
||||
"windows_i686_msvc 0.37.0",
|
||||
"windows_x86_64_gnu 0.37.0",
|
||||
"windows_x86_64_msvc 0.37.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.39.0"
|
||||
@ -3803,12 +3792,6 @@ version = "0.42.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.39.0"
|
||||
@ -3821,12 +3804,6 @@ version = "0.42.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.39.0"
|
||||
@ -3839,12 +3816,6 @@ version = "0.42.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.39.0"
|
||||
@ -3857,12 +3828,6 @@ version = "0.42.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.39.0"
|
||||
@ -3881,12 +3846,6 @@ version = "0.42.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.39.0"
|
||||
@ -3942,7 +3901,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows 0.39.0",
|
||||
"windows",
|
||||
"windows-implement",
|
||||
]
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lume"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
description = "nostr client"
|
||||
authors = ["Ren Amamiya"]
|
||||
license = ""
|
||||
@ -16,7 +16,7 @@ tauri-build = { version = "1.2", features = [] }
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = ["clipboard-all", "notification-all", "shell-open", "system-tray", "window-start-dragging"] }
|
||||
tauri = { version = "1.2", features = ["clipboard-read-text", "clipboard-write-text", "http-request", "os-all", "shell-open", "system-tray", "window-close", "window-start-dragging"] }
|
||||
|
||||
[dependencies.tauri-plugin-sql]
|
||||
git = "https://github.com/tauri-apps/plugins-workspace"
|
||||
|
@ -9,21 +9,38 @@ CREATE TABLE
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- add default relays
|
||||
-- relay status:
|
||||
-- 0: off
|
||||
-- 1: on
|
||||
INSERT INTO
|
||||
relays (relay_url, relay_status)
|
||||
VALUES
|
||||
("wss://relay.damus.io", "1"),
|
||||
("wss://relay.uselume.xyz", "0"),
|
||||
("wss://eden.nostr.land", "0"),
|
||||
("wss://nostr-pub.wellorder.net", "1"),
|
||||
("wss://nostr.bongbong.com", "1"),
|
||||
("wss://nostr.zebedee.cloud", "1"),
|
||||
("wss://nostr.fmt.wiz.biz", "1"),
|
||||
("wss://nostr.walletofsatoshi.com", "1"),
|
||||
("wss://nostr.walletofsatoshi.com", "0"),
|
||||
("wss://relay.snort.social", "1"),
|
||||
("wss://offchain.pub", "1"),
|
||||
("wss://brb.io", "0"),
|
||||
("wss://relay.current.fyi", "1"),
|
||||
("wss://nostr.relayer.se", "0"),
|
||||
("wss://nostr.bitcoiner.social", "1"),
|
||||
("wss://relay.nostr.info", "1"),
|
||||
("wss://relay.zeh.app", "0"),
|
||||
("wss://nostr-01.dorafactory.org", "1"),
|
||||
("wss://nostr.zhongwen.world", "1"),
|
||||
("wss://nostro.cc", "1"),
|
||||
("wss://relay.nostr.net.in", "1"),
|
||||
("wss://nos.lol", "1");
|
||||
|
||||
-- create accounts
|
||||
-- is_active (part of multi-account feature):
|
||||
-- 0: false
|
||||
-- 1: true
|
||||
CREATE TABLE
|
||||
accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
@ -31,27 +48,30 @@ CREATE TABLE
|
||||
npub TEXT NOT NULL,
|
||||
nsec TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
metadata JSON
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
-- create follows
|
||||
-- kind (part of multi-newsfeed feature):
|
||||
-- 0: direct
|
||||
-- 1: follow of follow
|
||||
CREATE TABLE
|
||||
follows (
|
||||
id INTEGER PRIMARY KEY,
|
||||
pubkey TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
kind INTEGER NOT NULL DEFAULT 0,
|
||||
metadata JSON
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
-- create index for pubkey in follows
|
||||
CREATE UNIQUE INDEX index_pubkey ON follows (pubkey);
|
||||
CREATE UNIQUE INDEX index_pubkey_on_follows ON follows (pubkey);
|
||||
|
||||
-- create cache profiles
|
||||
CREATE TABLE
|
||||
cache_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
metadata JSON,
|
||||
metadata TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@ -65,6 +85,20 @@ CREATE TABLE
|
||||
kind INTEGER NOT NULL DEFAULT 1,
|
||||
tags TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
relay TEXT,
|
||||
is_multi BOOLEAN DEFAULT 0
|
||||
);
|
||||
parent_id TEXT,
|
||||
parent_comment_id TEXT
|
||||
);
|
||||
|
||||
-- create settings
|
||||
CREATE TABLE
|
||||
settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
setting_key TEXT NOT NULL,
|
||||
setting_value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- add default setting
|
||||
INSERT INTO
|
||||
settings (setting_key, setting_value)
|
||||
VALUES
|
||||
("last_login", "0");
|
@ -7,24 +7,21 @@
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
use tauri::{Manager, SystemTray, WindowEvent};
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
use window_ext::WindowExt;
|
||||
|
||||
mod window_ext;
|
||||
|
||||
fn main() {
|
||||
let tray = SystemTray::new();
|
||||
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let main_window = app.get_window("main").unwrap();
|
||||
// set inset for traffic lights
|
||||
main_window.position_traffic_lights(8.0, 16.0);
|
||||
main_window.position_traffic_lights(8.0, 20.0);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.system_tray(tray)
|
||||
.plugin(
|
||||
tauri_plugin_sql::Builder::default()
|
||||
.add_migrations(
|
||||
@ -41,7 +38,7 @@ fn main() {
|
||||
.on_window_event(|e| {
|
||||
let apply_offset = || {
|
||||
let win = e.window();
|
||||
win.position_traffic_lights(8.0, 16.0);
|
||||
win.position_traffic_lights(8.0, 20.0);
|
||||
};
|
||||
|
||||
match e.event() {
|
||||
|
@ -8,25 +8,37 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Lume",
|
||||
"version": "0.1.1"
|
||||
"version": "0.2.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false,
|
||||
"app": {
|
||||
"all": false
|
||||
},
|
||||
"os": {
|
||||
"all": true
|
||||
},
|
||||
"http": {
|
||||
"all": false,
|
||||
"request": true,
|
||||
"scope": ["https://rbr.bio/*", "https://metadata.uselume.xyz/*"]
|
||||
},
|
||||
"shell": {
|
||||
"all": false,
|
||||
"open": true
|
||||
},
|
||||
"clipboard": {
|
||||
"all": true,
|
||||
"all": false,
|
||||
"writeText": true,
|
||||
"readText": true
|
||||
},
|
||||
"notification": {
|
||||
"all": true
|
||||
"all": false
|
||||
},
|
||||
"window": {
|
||||
"startDragging": true
|
||||
"startDragging": true,
|
||||
"close": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
16
src/App.css
16
src/App.css
@ -4,6 +4,13 @@
|
||||
|
||||
@import './assets/editor.css';
|
||||
|
||||
/* Fixed next/image bug, source: https://nextjs.org/docs/api-reference/next/image */
|
||||
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
|
||||
img[loading='lazy'] {
|
||||
clip-path: inset(0.6px);
|
||||
}
|
||||
}
|
||||
|
||||
/* For Webkit-based browsers (Chrome, Safari and Opera) */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
@ -23,3 +30,12 @@
|
||||
.border {
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
@keyframes loop {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
@ -1,102 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "jb55",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1362882895669436423/Jzsp1Ikr.jpg",
|
||||
"npub": "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s"
|
||||
},
|
||||
{
|
||||
"name": "jack",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1115644092329758721/AFjOr-K8.jpg",
|
||||
"npub": "npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m"
|
||||
},
|
||||
{
|
||||
"name": "derekmoss",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1609534946435076096/Gl1xeTPP.jpg",
|
||||
"npub": "npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424"
|
||||
},
|
||||
{
|
||||
"name": "ODELL",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1421584695746338819/Z_7ZfAeP.jpg",
|
||||
"npub": "npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx"
|
||||
},
|
||||
{
|
||||
"name": "yeg0rpetrov",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1593772940126035968/D_LQYRd9.jpg",
|
||||
"npub": "npub1z4m7gkva6yxgvdyclc7zp0vz4ta0s2d9jh8g83w03tp5vdf3kzdsxana6p"
|
||||
},
|
||||
{
|
||||
"name": "PrestonPysh",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1408783276081299462/f4Ye5n7-.jpg",
|
||||
"npub": "npub1s5yq6wadwrxde4lhfs56gn64hwzuhnfa6r9mj476r5s4hkunzgzqrs6q7z"
|
||||
},
|
||||
{
|
||||
"name": "fiatjaf",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q.jpeg",
|
||||
"npub": "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"
|
||||
},
|
||||
{
|
||||
"name": "dergigi",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1566370176446119943/UeuACt-4.jpg",
|
||||
"npub": "npub1dergggklka99wwrs92yz8wdjs952h2ux2ha2ed598ngwu9w7a6fsh9xzpc"
|
||||
},
|
||||
{
|
||||
"name": "hodlonaut",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1570910274755911682/z8DJsufc.jpg",
|
||||
"npub": "npub1cjw49ftnxene9wdxujz3tp7zspp0kf862cjud4nm3j2usag6eg2smwj2rh"
|
||||
},
|
||||
{
|
||||
"name": "DylanLeClair_",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1599858581611941922/XxvPPWAt.jpg",
|
||||
"npub": "npub1pyp9fqq60689ppds9ec3vghsm7s6s4grfya0y342g2hs3a0y6t0segc0qq"
|
||||
},
|
||||
{
|
||||
"name": "ShadowOfNakadai",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1620811984374464514/V7GJo1ak.jpg",
|
||||
"npub": "npub1sqaxzwvh5fhgw9q3d7v658ucapvfeds3dcd2587fcwyesn7dnwuqt2r45v"
|
||||
},
|
||||
{
|
||||
"name": "jackmallers",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1599778945699909632/O0qc9ykA.jpg",
|
||||
"npub": "npub1cn4t4cd78nm900qc2hhqte5aa8c9njm6qkfzw95tszufwcwtcnsq7g3vle"
|
||||
},
|
||||
{
|
||||
"name": "remroya",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1616979727515881478/5ABZzBYO.jpg",
|
||||
"npub": "npub1csamkk8zu67zl9z4wkp90a462v53q775aqn5q6xzjdkxnkvcpd7srtz4x9"
|
||||
},
|
||||
{
|
||||
"name": "TakumiHisoka",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1623286991944302594/cXSJ04BF.jpg",
|
||||
"npub": "npub1yc8jxnzkzm2esndrqdae6lza6qlwzxpcz9drpy699j9k7xetrpkqgvkwe9"
|
||||
},
|
||||
{
|
||||
"name": "EvelinSchallert",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1448008447983763457/7k07LJxQ.jpg",
|
||||
"npub": "npub1l2gvp9wxajsl6wqnh6eulvz5sdk05gtajjwjn2yn45s9yvfru2kqf3r0gm"
|
||||
},
|
||||
{
|
||||
"name": "peer",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1623291991709700097/aBL_VpMC.jpg",
|
||||
"npub": "npub18zx8lw3947pghsgzqv2t0x8pe767sscag5djgj5afr755xkqd97qt530pr"
|
||||
},
|
||||
{
|
||||
"name": "francispouliot_",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1524789480439283719/5Q_XBKGb.jpg",
|
||||
"npub": "npub1t289s8ck5qfwynf2vsq49t2kypvvkpj7rhegayrur0ag9s2sezaqgunkzs"
|
||||
},
|
||||
{
|
||||
"name": "lanyihou",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1603653816175689729/Ctj5GXPt.jpg",
|
||||
"npub": "npub18hywyhcnn5rqhlgu80yxeyf57fyhghlrc54dzaqyd9vtts949u9s24rtva"
|
||||
},
|
||||
{
|
||||
"name": "marttimalmi",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/1125299725828272129/n8NDo1LN.png",
|
||||
"npub": "npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk"
|
||||
},
|
||||
{
|
||||
"name": "Snowden",
|
||||
"avatar": "https://pbs.twimg.com/profile_images/648888480974508032/66_cUYfj.jpg",
|
||||
"npub": "npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9"
|
||||
}
|
||||
]
|
18
src/assets/icons/comment.tsx
Normal file
18
src/assets/icons/comment.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function CommentIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/emoji.tsx
Normal file
18
src/assets/icons/emoji.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function EmojiIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.182 15.182a4.5 4.5 0 01-6.364 0M21 12a9 9 0 11-18 0 9 9 0 0118 0zM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75zm-.375 0h.008v.015h-.008V9.75zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/image.tsx
Normal file
18
src/assets/icons/image.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function ImageIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
18
src/assets/icons/like.tsx
Normal file
18
src/assets/icons/like.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function LikeIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
7
src/assets/icons/liked.tsx
Normal file
7
src/assets/icons/liked.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function LikedIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
53
src/components/appHeader/actions.tsx
Normal file
53
src/components/appHeader/actions.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon, ReloadIcon } from '@radix-ui/react-icons';
|
||||
import { platform } from '@tauri-apps/api/os';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
export default function AppActions() {
|
||||
const router = useRouter();
|
||||
const [os, setOS] = useState('');
|
||||
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
router.reload();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const getPlatform = async () => {
|
||||
const result = await platform();
|
||||
setOS(result);
|
||||
};
|
||||
|
||||
getPlatform().catch(console.error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`flex h-full items-center gap-2 ${os === 'darwin' ? 'pl-[68px]' : ''}`}>
|
||||
<button
|
||||
onClick={() => goBack()}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4 text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => goForward()}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4 text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reload()}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
|
||||
>
|
||||
<ReloadIcon className="h-[14px] w-[14px] text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
24
src/components/appHeader/index.tsx
Normal file
24
src/components/appHeader/index.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { PlusIcon } from '@radix-ui/react-icons';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const AppActions = dynamic(() => import('@components/appHeader/actions'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const NoteConnector = dynamic(() => import('@components/note/connector'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function AppHeader() {
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex h-full w-full flex-1 items-center px-2">
|
||||
<AppActions />
|
||||
<div data-tauri-drag-region className="flex h-full w-full items-center justify-between">
|
||||
<div className="flex h-full items-center divide-x divide-zinc-900 px-4 pt-px"></div>
|
||||
<div>
|
||||
<NoteConnector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const Account = memo(function Account({ user, current }: { user: any; current: string }) {
|
||||
const userData = JSON.parse(user.metadata);
|
||||
|
||||
const setCurrentUser = () => {
|
||||
console.log('clicked');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setCurrentUser()}
|
||||
className={`relative h-11 w-11 shrink overflow-hidden rounded-full ${
|
||||
current === user.pubkey ? 'ring-1 ring-fuchsia-500 ring-offset-4 ring-offset-black' : ''
|
||||
}`}
|
||||
>
|
||||
{userData?.picture !== undefined ? (
|
||||
<Image src={userData.picture} alt="user's avatar" fill={true} className="rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="h-11 w-11 animate-pulse rounded-full bg-zinc-700" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
120
src/components/columns/account/active.tsx
Normal file
120
src/components/columns/account/active.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
import { createFollows } from '@utils/storage';
|
||||
import { tagsToArray } from '@utils/transform';
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { AvatarIcon, ExitIcon, GearIcon } from '@radix-ui/react-icons';
|
||||
import { writeText } from '@tauri-apps/api/clipboard';
|
||||
import destr from 'destr';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { memo, useContext, useEffect, useRef } from 'react';
|
||||
|
||||
export const ActiveAccount = memo(function ActiveAccount({ user }: { user: any }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
|
||||
const router = useRouter();
|
||||
const userData = destr(user.metadata);
|
||||
|
||||
const now = useRef(new Date());
|
||||
|
||||
const openProfilePage = () => {
|
||||
router.push(`/users/${user.id}`);
|
||||
};
|
||||
|
||||
const copyPublicKey = async () => {
|
||||
await writeText(nip19.npubEncode(user.id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [3],
|
||||
authors: [user.id],
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
if (event.tags.length > 0) {
|
||||
createFollows(tagsToArray(event.tags), user.id, 0);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe;
|
||||
};
|
||||
}, [pool, relays, user.id]);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button className="relative h-11 w-11 rounded-md">
|
||||
<Image
|
||||
src={userData.picture}
|
||||
alt="user's avatar"
|
||||
fill={true}
|
||||
className="rounded-md object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
|
||||
priority
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="min-w-[220px] rounded-md bg-zinc-900/80 p-1.5 shadow-input shadow-black/50 ring-1 ring-zinc-800 backdrop-blur-xl will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade"
|
||||
side="right"
|
||||
sideOffset={5}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onClick={() => openProfilePage()}
|
||||
className="group relative flex h-7 select-none items-center rounded-sm px-1 pl-7 text-sm leading-none text-zinc-400 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-zinc-800 data-[highlighted]:text-fuchsia-500"
|
||||
>
|
||||
<div className="absolute left-0 inline-flex w-6 items-center justify-center">
|
||||
<AvatarIcon />
|
||||
</div>
|
||||
Open profile
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className="group relative flex h-7 select-none items-center rounded px-1 pl-7 text-sm leading-none text-zinc-400 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-zinc-800 data-[highlighted]:text-fuchsia-500">
|
||||
Update profile
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onClick={() => copyPublicKey()}
|
||||
className="group relative flex h-7 select-none items-center rounded px-1 pl-7 text-sm leading-none text-zinc-400 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-zinc-800 data-[highlighted]:text-fuchsia-500"
|
||||
>
|
||||
Copy public key
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="m-1 h-px bg-zinc-700/50" />
|
||||
<DropdownMenu.Item className="group relative flex h-7 select-none items-center rounded px-1 pl-7 text-sm leading-none text-zinc-400 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-zinc-800 data-[highlighted]:text-fuchsia-500">
|
||||
<div className="absolute left-0 inline-flex w-6 items-center justify-center">
|
||||
<GearIcon />
|
||||
</div>
|
||||
Settings
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className="group relative flex h-7 select-none items-center rounded px-1 pl-7 text-sm leading-none text-zinc-400 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-zinc-800 data-[highlighted]:text-fuchsia-500">
|
||||
<div className="absolute left-0 inline-flex w-6 items-center justify-center">
|
||||
<ExitIcon />
|
||||
</div>
|
||||
Logout
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
});
|
17
src/components/columns/account/inactive.tsx
Normal file
17
src/components/columns/account/inactive.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import destr from 'destr';
|
||||
import Image from 'next/image';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const InactiveAccount = memo(function InactiveAccount({ user }: { user: any }) {
|
||||
const userData = destr(user.metadata);
|
||||
|
||||
const setCurrentUser = () => {
|
||||
console.log('clicked');
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={() => setCurrentUser()} className="relative h-11 w-11 shrink rounded-md">
|
||||
<Image src={userData.picture} alt="user's avatar" fill={true} className="rounded-md object-cover" />
|
||||
</button>
|
||||
);
|
||||
});
|
@ -1,42 +1,46 @@
|
||||
import { Account } from '@components/columns/account/account';
|
||||
import AccountList from '@components/columns/account/list';
|
||||
|
||||
import LumeSymbol from '@assets/icons/Lume';
|
||||
|
||||
import { PlusIcon } from '@radix-ui/react-icons';
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
export default function AccountColumn() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [currentUser]: any = useLocalStorage('current-user');
|
||||
const [version, setVersion] = useState(null);
|
||||
|
||||
const getAccounts = useCallback(async () => {
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
const result: any = await db.select('SELECT * FROM accounts');
|
||||
|
||||
setUsers(result);
|
||||
const getAppVersion = useCallback(async () => {
|
||||
const appVersion = await getVersion();
|
||||
setVersion(appVersion);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getAccounts().catch(console.error);
|
||||
}, [getAccounts]);
|
||||
getAppVersion().catch(console.error);
|
||||
}, [getAppVersion]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-between px-2 pt-12 pb-4">
|
||||
<div className="flex h-full flex-col items-center justify-between px-2 pt-4 pb-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{users.map((user, index) => (
|
||||
<Account key={index} user={user} current={currentUser.id} />
|
||||
))}
|
||||
<Link
|
||||
href="/explore"
|
||||
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800"
|
||||
>
|
||||
<LumeSymbol className="h-6 w-auto text-zinc-400 group-hover:text-zinc-200" />
|
||||
</Link>
|
||||
<AccountList />
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center overflow-hidden rounded-full border-2 border-dashed border-zinc-600 hover:border-zinc-400"
|
||||
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-zinc-600 hover:border-zinc-400"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-200" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LumeSymbol className="h-8 w-auto text-zinc-700" />
|
||||
<div className="flex flex-col gap-0.5 text-center">
|
||||
<span className="animate-moveBg from-fuchsia-300 via-orange-100 to-amber-300 text-sm font-black uppercase leading-tight text-zinc-600 hover:bg-gradient-to-r hover:bg-clip-text hover:text-transparent">
|
||||
Lume
|
||||
</span>
|
||||
<span className="text-xs font-medium text-zinc-700">v{version}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
36
src/components/columns/account/list.tsx
Normal file
36
src/components/columns/account/list.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { ActiveAccount } from '@components/columns/account/active';
|
||||
import { InactiveAccount } from '@components/columns/account/inactive';
|
||||
|
||||
import { activeAccountAtom } from '@stores/account';
|
||||
|
||||
import { getAccounts } from '@utils/storage';
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export default function AccountList() {
|
||||
const [activeAccount] = useAtom(activeAccountAtom);
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const renderAccount = useCallback(
|
||||
(user: { id: string }) => {
|
||||
if (user.id === activeAccount.id) {
|
||||
return <ActiveAccount key={user.id} user={user} />;
|
||||
} else {
|
||||
return <InactiveAccount key={user.id} user={user} />;
|
||||
}
|
||||
},
|
||||
[activeAccount.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAccount = async () => {
|
||||
const result: any = await getAccounts();
|
||||
setUsers(result);
|
||||
};
|
||||
|
||||
fetchAccount().catch(console.error);
|
||||
}, []);
|
||||
|
||||
return <>{users.map((user) => renderAccount(user))}</>;
|
||||
}
|
@ -1,116 +0,0 @@
|
||||
import { RelayContext } from '@components/contexts/relay';
|
||||
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import * as commands from '@uiw/react-md-editor/lib/commands';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useState } from 'react';
|
||||
|
||||
const MDEditor = dynamic(() => import('@uiw/react-md-editor').then((mod) => mod.default), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function CreatePost() {
|
||||
const relayPool: any = useContext(RelayContext);
|
||||
const [relays]: any = useLocalStorage('relays');
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const [currentUser]: any = useLocalStorage('current-user');
|
||||
const pubkey = currentUser.id;
|
||||
const privkey = currentUser.privkey;
|
||||
|
||||
const postButton = {
|
||||
name: 'post',
|
||||
keyCommand: 'post',
|
||||
buttonProps: { className: 'cta-btn', 'aria-label': 'Post a message' },
|
||||
icon: (
|
||||
<div className="relative inline-flex h-10 w-16 transform cursor-pointer overflow-hidden rounded bg-zinc-900 px-2.5 ring-zinc-500/50 ring-offset-zinc-900 will-change-transform focus:outline-none focus:ring-1 focus:ring-offset-2 active:translate-y-1">
|
||||
<span className="absolute inset-px z-10 inline-flex items-center justify-center rounded bg-zinc-900 text-zinc-200">
|
||||
Post
|
||||
</span>
|
||||
<span className="absolute inset-0 z-0 scale-x-[2.0] blur before:absolute before:inset-0 before:top-1/2 before:aspect-square before:animate-disco before:bg-gradient-conic before:from-gray-300 before:via-fuchsia-600 before:to-orange-600"></span>
|
||||
</div>
|
||||
),
|
||||
execute: (state: { text: any }) => {
|
||||
const message = state.text;
|
||||
|
||||
if (message.length > 0) {
|
||||
const event: any = {
|
||||
content: message,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
relayPool.publish(event, relays);
|
||||
setValue('');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger asChild>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative h-16 shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<textarea
|
||||
readOnly
|
||||
placeholder="What's your thought?"
|
||||
className="relative h-16 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<button className="inline-flex h-9 w-full items-center justify-center rounded-lg border border-white/10 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 text-sm font-semibold shadow-input">
|
||||
<span className="drop-shadow-lg">Post</span>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-30 backdrop-blur-sm data-[state=open]:animate-overlayShow" />
|
||||
<Dialog.Content className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<div className="relative w-full max-w-2xl transform overflow-hidden rounded-lg text-zinc-100 shadow-modal transition-all">
|
||||
<div className="absolute top-0 left-0 h-full w-full bg-black bg-opacity-20 backdrop-blur-lg"></div>
|
||||
<div className="absolute bottom-0 left-0 h-24 w-full border-t border-white/10 bg-zinc-900"></div>
|
||||
<div className="relative z-10 px-4 pt-4 pb-2">
|
||||
<MDEditor
|
||||
value={value}
|
||||
preview={'edit'}
|
||||
height={200}
|
||||
minHeight={200}
|
||||
visibleDragbar={false}
|
||||
highlightEnable={false}
|
||||
defaultTabEnable={true}
|
||||
autoFocus={true}
|
||||
commands={[
|
||||
commands.bold,
|
||||
commands.italic,
|
||||
commands.strikethrough,
|
||||
commands.divider,
|
||||
commands.checkedListCommand,
|
||||
commands.unorderedListCommand,
|
||||
commands.orderedListCommand,
|
||||
commands.divider,
|
||||
commands.link,
|
||||
commands.image,
|
||||
]}
|
||||
extraCommands={[postButton]}
|
||||
textareaProps={{
|
||||
placeholder: "What's your thought?",
|
||||
}}
|
||||
onChange={(val) => setValue(val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
@ -1,74 +1,13 @@
|
||||
import ActiveLink from '@components/activeLink';
|
||||
import CreatePost from '@components/columns/navigator/createPost';
|
||||
import { UserDropdownMenu } from '@components/columns/navigator/userDropdownMenu';
|
||||
|
||||
import { PlusIcon } from '@radix-ui/react-icons';
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import Messages from '@components/columns/navigator/messages';
|
||||
import Newsfeed from '@components/columns/navigator/newsfeed';
|
||||
|
||||
export default function NavigatorColumn() {
|
||||
const [currentUser]: any = useLocalStorage('current-user');
|
||||
const profile = JSON.parse(currentUser.metadata);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col flex-wrap justify-between overflow-hidden px-2 pt-3 pb-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Create post */}
|
||||
<div className="flex flex-col rounded-lg bg-zinc-900 ring-1 ring-white/10">
|
||||
<div className="flex flex-col p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="font-semibold leading-tight text-zinc-100">{profile.display_name || ''}</h5>
|
||||
<UserDropdownMenu pubkey={currentUser.id} />
|
||||
</div>
|
||||
<span className="text-sm leading-tight text-zinc-500">@{profile.username || ''}</span>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<CreatePost />
|
||||
</div>
|
||||
</div>
|
||||
{/* Newsfeed */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-sm font-bold text-zinc-400">Newsfeed</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3 text-zinc-400 group-hover:text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-zinc-500">
|
||||
<ActiveLink
|
||||
href={`/newsfeed/following`}
|
||||
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
|
||||
className="flex h-10 items-center gap-1 rounded-lg px-2.5 text-sm font-medium hover:bg-zinc-900"
|
||||
>
|
||||
<span>#</span>
|
||||
<span>following</span>
|
||||
</ActiveLink>
|
||||
<ActiveLink
|
||||
href={`/newsfeed/global`}
|
||||
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
|
||||
className="flex h-10 items-center gap-1 rounded-lg px-2.5 text-sm font-medium hover:bg-zinc-900"
|
||||
>
|
||||
<span>#</span>
|
||||
<span>global</span>
|
||||
</ActiveLink>
|
||||
</div>
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-sm font-bold text-zinc-400">Direct Messages</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3 text-zinc-400 group-hover:text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex h-full flex-col gap-1 overflow-hidden pt-4">
|
||||
{/* Newsfeed */}
|
||||
<Newsfeed />
|
||||
{/* Messages */}
|
||||
<Messages />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
44
src/components/columns/navigator/messages/index.tsx
Normal file
44
src/components/columns/navigator/messages/index.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { MessageList } from '@components/columns/navigator/messages/list';
|
||||
|
||||
import { activeAccountAtom } from '@stores/account';
|
||||
|
||||
import { getAllFollowsByID } from '@utils/storage';
|
||||
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { TriangleUpIcon } from '@radix-ui/react-icons';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Messages() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [follows, setFollows] = useState([]);
|
||||
const [activeAccount] = useAtom(activeAccountAtom);
|
||||
|
||||
useEffect(() => {
|
||||
getAllFollowsByID(activeAccount.id)
|
||||
.then((res: any) => setFollows(res))
|
||||
.catch(console.error);
|
||||
}, [activeAccount.id]);
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen} className="h-full shrink-0">
|
||||
<div className="flex h-full flex-col gap-1 px-2 pb-8">
|
||||
<Collapsible.Trigger className="flex cursor-pointer items-center gap-2 py-1 px-2">
|
||||
<div
|
||||
className={`inline-flex h-6 w-6 transform items-center justify-center transition-transform duration-150 ease-in-out ${
|
||||
open ? 'rotate-180' : ''
|
||||
}`}
|
||||
>
|
||||
<TriangleUpIcon className="h-4 w-4 text-zinc-500" />
|
||||
</div>
|
||||
<h3 className="bg-gradient-to-r from-red-300 via-pink-100 to-blue-300 bg-clip-text text-xs font-bold uppercase tracking-wide text-transparent">
|
||||
Chats
|
||||
</h3>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="h-full">
|
||||
<MessageList data={follows} />
|
||||
</Collapsible.Content>
|
||||
</div>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
33
src/components/columns/navigator/messages/list.tsx
Normal file
33
src/components/columns/navigator/messages/list.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { UserMini } from '@components/user/mini';
|
||||
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Suspense, memo, useRef } from 'react';
|
||||
|
||||
export const MessageList = memo(function MessageList({ data }: { data: any }) {
|
||||
const parentRef = useRef(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: data.length,
|
||||
estimateSize: () => 32,
|
||||
getScrollElement: () => parentRef.current,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="scrollbar-hide h-full w-full overflow-y-auto" style={{ contain: 'strict' }}>
|
||||
<Suspense fallback={<p className="text-sm text-zinc-400">Loading...</p>}>
|
||||
{items.length > 0 && (
|
||||
<div className="relative mb-24 w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
<div className="absolute top-0 left-0 w-full" style={{ transform: `translateY(${items[0].start}px)` }}>
|
||||
{items.map((virtualRow) => (
|
||||
<div key={virtualRow.key} data-index={virtualRow.index} ref={virtualizer.measureElement}>
|
||||
<UserMini pubkey={data[virtualRow.index].pubkey} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
});
|
50
src/components/columns/navigator/newsfeed/index.tsx
Normal file
50
src/components/columns/navigator/newsfeed/index.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import ActiveLink from '@components/activeLink';
|
||||
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { TriangleUpIcon } from '@radix-ui/react-icons';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Newsfeed() {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<Collapsible.Trigger className="flex cursor-pointer items-center gap-2 py-1 px-2">
|
||||
<div
|
||||
className={`inline-flex h-6 w-6 transform items-center justify-center transition-transform duration-150 ease-in-out ${
|
||||
open ? 'rotate-180' : ''
|
||||
}`}
|
||||
>
|
||||
<TriangleUpIcon className="h-4 w-4 text-zinc-500" />
|
||||
</div>
|
||||
<h3 className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-xs font-bold uppercase tracking-wide text-transparent">
|
||||
Newsfeed
|
||||
</h3>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="flex flex-col gap-1 text-zinc-400">
|
||||
<ActiveLink
|
||||
href={`/newsfeed/following`}
|
||||
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white hover:dark:bg-zinc-800"
|
||||
className="flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium hover:bg-zinc-900"
|
||||
>
|
||||
<div className="inline-flex h-5 w-5 items-center justify-center">
|
||||
<span className="h-4 w-3 rounded-sm bg-gradient-to-br from-fuchsia-500 via-purple-300 to-pink-300"></span>
|
||||
</div>
|
||||
<span>Following</span>
|
||||
</ActiveLink>
|
||||
<ActiveLink
|
||||
href={`/newsfeed/circle`}
|
||||
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white hover:dark:bg-zinc-800"
|
||||
className="flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium hover:bg-zinc-900"
|
||||
>
|
||||
<div className="inline-flex h-5 w-5 items-center justify-center">
|
||||
<span className="h-4 w-3 rounded-sm bg-gradient-to-br from-amber-500 via-orange-200 to-yellow-300"></span>
|
||||
</div>
|
||||
<span>Circle</span>
|
||||
</ActiveLink>
|
||||
</Collapsible.Content>
|
||||
</div>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||
import { writeText } from '@tauri-apps/api/clipboard';
|
||||
import { useRouter } from 'next/router';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const UserDropdownMenu = memo(function ProfileMenu({ pubkey }: { pubkey: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
const viewProfile = () => {
|
||||
router.push(`/profile/${pubkey}`);
|
||||
};
|
||||
|
||||
const updateProfile = () => {
|
||||
router.push('/profile/update');
|
||||
};
|
||||
|
||||
const copyPubkey = async () => {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
await writeText(npub);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button className="rounded-lg p-1 hover:bg-zinc-800">
|
||||
<DotsHorizontalIcon className="h-4 w-4 text-zinc-300" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="min-w-[220px] rounded-md border border-white/20 bg-zinc-800 p-1 shadow-lg shadow-black/30 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade"
|
||||
sideOffset={2}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onClick={() => viewProfile()}
|
||||
className="group relative flex h-[30px] select-none items-center rounded px-1 pl-6 text-sm leading-none text-zinc-100 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-zinc-700 data-[highlighted]:text-fuchsia-400 data-[disabled]:text-zinc-400"
|
||||
>
|
||||
View profile
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onClick={() => updateProfile()}
|
||||
className="group relative flex h-[30px] select-none items-center rounded px-1 pl-6 text-sm leading-none text-zinc-100 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-zinc-700 data-[highlighted]:text-fuchsia-400 data-[disabled]:text-zinc-400"
|
||||
>
|
||||
Update profile
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onClick={() => copyPubkey()}
|
||||
className="group relative flex h-[30px] select-none items-center rounded px-1 pl-6 text-sm leading-none text-zinc-100 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-zinc-700 data-[highlighted]:text-fuchsia-400 data-[disabled]:text-zinc-400"
|
||||
>
|
||||
Copy public key
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className="group relative flex h-[30px] select-none items-center rounded px-1 pl-6 text-sm leading-none text-zinc-100 outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-zinc-700 data-[highlighted]:text-fuchsia-400 data-[disabled]:text-zinc-400">
|
||||
Log out
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
});
|
@ -1,68 +0,0 @@
|
||||
import { deleteFromStorage, writeStorage } from '@rehooks/local-storage';
|
||||
import { createContext, useCallback, useEffect, useState } from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
export const DatabaseContext = createContext({});
|
||||
|
||||
const db = typeof window !== 'undefined' ? await Database.load('sqlite:lume.db') : null;
|
||||
|
||||
export default function DatabaseProvider({ children }: { children: React.ReactNode }) {
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const getRelays = useCallback(async () => {
|
||||
const result: any[] = await db.select('SELECT relay_url FROM relays WHERE relay_status = "1"');
|
||||
const arr = [];
|
||||
result.forEach((item: { relay_url: string }) => {
|
||||
arr.push(item.relay_url);
|
||||
});
|
||||
// delete old item then save new item to local storage
|
||||
deleteFromStorage('relays');
|
||||
writeStorage('relays', arr);
|
||||
// return
|
||||
return;
|
||||
}, []);
|
||||
|
||||
const getAccount = useCallback(async () => {
|
||||
const result = await db.select(`SELECT * FROM accounts LIMIT 1`);
|
||||
// delete old item then save new item to local storage
|
||||
deleteFromStorage('current-user');
|
||||
if (result[0]) {
|
||||
writeStorage('current-user', result[0]);
|
||||
} else {
|
||||
writeStorage('current-user', null);
|
||||
}
|
||||
// return first record
|
||||
return result[0];
|
||||
}, []);
|
||||
|
||||
const getFollows = useCallback(async (id: string) => {
|
||||
const result: any[] = await db.select(`SELECT pubkey FROM follows WHERE account = "${id}"`);
|
||||
const arr = [];
|
||||
result.forEach((item: { pubkey: string }) => {
|
||||
arr.push(item.pubkey);
|
||||
});
|
||||
// delete old item then save new item to local storage
|
||||
deleteFromStorage('follows');
|
||||
writeStorage('follows', arr);
|
||||
// return
|
||||
return;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getRelays().catch(console.error);
|
||||
getAccount()
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
getFollows(res.id).catch(console.error);
|
||||
}
|
||||
setDone(true);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [getAccount, getFollows, getRelays]);
|
||||
|
||||
if (!done) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <DatabaseContext.Provider value={{ db }}>{children}</DatabaseContext.Provider>;
|
||||
}
|
81
src/components/form/base.tsx
Normal file
81
src/components/form/base.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import EmojiPicker from '@components/form/emojiPicker';
|
||||
import ImagePicker from '@components/form/imagePicker';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { activeAccountAtom } from '@stores/account';
|
||||
import { noteContentAtom } from '@stores/note';
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
|
||||
import { PersonIcon } from '@radix-ui/react-icons';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export default function FormBase() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [activeAccount] = useAtom(activeAccountAtom);
|
||||
const [value, setValue] = useAtom(noteContentAtom);
|
||||
const resetValue = useResetAtom(noteContentAtom);
|
||||
|
||||
const pubkey = activeAccount.id;
|
||||
const privkey = activeAccount.privkey;
|
||||
|
||||
const submitEvent = () => {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, relays);
|
||||
// reset form
|
||||
resetValue();
|
||||
// send notification
|
||||
// sendNotification('Note has been published successfully');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="relative h-32 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="What's your thought?"
|
||||
className="relative h-32 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<ImagePicker />
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<EmojiPicker />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
81
src/components/form/comment.tsx
Normal file
81
src/components/form/comment.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { activeAccountAtom } from '@stores/account';
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
|
||||
import destr from 'destr';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useState } from 'react';
|
||||
|
||||
export default function FormComment({ eventID }: { eventID: any }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [activeAccount] = useAtom(activeAccountAtom);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const profile = destr(activeAccount.metadata);
|
||||
|
||||
const submitEvent = () => {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: activeAccount.id,
|
||||
tags: [['e', eventID]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, activeAccount.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, relays);
|
||||
// send notification
|
||||
// sendNotification('Comment has been published successfully');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="flex gap-1">
|
||||
<div>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
|
||||
<ImageWithFallback
|
||||
src={profile?.picture}
|
||||
alt={activeAccount.id}
|
||||
fill={true}
|
||||
className="rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Send your comment"
|
||||
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
37
src/components/form/emojiPicker.tsx
Normal file
37
src/components/form/emojiPicker.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { noteContentAtom } from '@stores/note';
|
||||
|
||||
import EmojiIcon from '@assets/icons/emoji';
|
||||
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
export default function EmojiPicker() {
|
||||
const [value, setValue] = useAtom(noteContentAtom);
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-zinc-700">
|
||||
<EmojiIcon className="h-4 w-4 text-zinc-400" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="rounded-md will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||
sideOffset={5}
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
emojiSize={16}
|
||||
navPosition={'none'}
|
||||
skinTonePosition={'none'}
|
||||
onEmojiSelect={(res) => setValue(value + ' ' + res.native)}
|
||||
/>
|
||||
<Popover.Arrow className="fill-[#141516]" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
51
src/components/form/imagePicker.tsx
Normal file
51
src/components/form/imagePicker.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { noteContentAtom } from '@stores/note';
|
||||
|
||||
import { PlusIcon } from '@radix-ui/react-icons';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ImagePicker() {
|
||||
const [value, setValue] = useAtom(noteContentAtom);
|
||||
const [url, setURL] = useState('');
|
||||
|
||||
const handleEnter = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setValue(value + ' ' + url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-zinc-700">
|
||||
<PlusIcon className="h-4 w-4 text-zinc-400" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="w-80 rounded-md bg-zinc-900/80 p-3 shadow-input shadow-black/50 ring-1 ring-zinc-800 backdrop-blur-xl will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||
sideOffset={3}
|
||||
>
|
||||
<div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-200">Image URL</label>
|
||||
<div className="relative mb-1 shrink-0 before:pointer-events-none before:absolute before:-inset-px before:rounded-[8px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-1 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<input
|
||||
placeholder="https://..."
|
||||
onKeyDown={handleEnter}
|
||||
onChange={(e) => setURL(e.target.value)}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm leading-none text-zinc-500">
|
||||
Press <span className="rounded bg-zinc-800 px-1 py-0.5">Enter</span> to insert image
|
||||
</p>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
@ -26,10 +26,20 @@ export const ImageWithFallback = memo(function ImageWithFallback({
|
||||
size={44}
|
||||
name={alt}
|
||||
variant="beam"
|
||||
square={true}
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
) : (
|
||||
<Image alt={alt} onError={setError} src={src} fill={fill} className={className} />
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill={fill}
|
||||
className={className}
|
||||
onError={setError}
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -1,78 +0,0 @@
|
||||
import { RelayContext } from '@components/contexts/relay';
|
||||
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
|
||||
import { HeartFilledIcon, HeartIcon } from '@radix-ui/react-icons';
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useState } from 'react';
|
||||
|
||||
export default function Reaction({ eventID, eventPubkey }: { eventID: string; eventPubkey: string }) {
|
||||
const relayPool: any = useContext(RelayContext);
|
||||
const [relays]: any = useLocalStorage('relays');
|
||||
|
||||
const [reaction, setReaction] = useState(0);
|
||||
const [isReact, setIsReact] = useState(false);
|
||||
|
||||
const [currentUser]: any = useLocalStorage('current-user');
|
||||
const pubkey = currentUser.id;
|
||||
const privkey = currentUser.privkey;
|
||||
|
||||
/*
|
||||
relayPool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [eventID],
|
||||
since: 0,
|
||||
kinds: [7],
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
if (event.content === '🤙' || event.content === '+') {
|
||||
setReaction(reaction + 1);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
(events: any, relayURL: any) => {
|
||||
console.log(events, relayURL);
|
||||
}
|
||||
);
|
||||
*/
|
||||
|
||||
const handleReaction = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const event: any = {
|
||||
content: '+',
|
||||
kind: 7,
|
||||
tags: [
|
||||
['e', eventID],
|
||||
['p', eventPubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
relayPool.publish(event, relays);
|
||||
|
||||
setIsReact(true);
|
||||
setReaction(reaction + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={(e) => handleReaction(e)} className="group flex w-16 items-center gap-1.5 text-sm text-zinc-500">
|
||||
<div className="rounded-lg p-1 group-hover:bg-zinc-600">
|
||||
{isReact ? (
|
||||
<HeartFilledIcon className="h-4 w-4 group-hover:text-red-400" />
|
||||
) : (
|
||||
<HeartIcon className="h-4 w-4 text-zinc-500" />
|
||||
)}
|
||||
</div>
|
||||
<span>{reaction}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import { ChatBubbleIcon } from '@radix-ui/react-icons';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Reply() {
|
||||
const [count] = useState(0);
|
||||
|
||||
return (
|
||||
<button className="group flex w-16 items-center gap-1.5 text-sm text-zinc-500">
|
||||
<div className="rounded-lg p-1 group-hover:bg-zinc-600">
|
||||
<ChatBubbleIcon className="h-4 w-4 group-hover:text-orange-400" />
|
||||
</div>
|
||||
<span>{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
@ -1,83 +0,0 @@
|
||||
import { DatabaseContext } from '@components/contexts/database';
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||
import Avatar from 'boring-avatars';
|
||||
import { memo, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import Moment from 'react-moment';
|
||||
|
||||
export const User = memo(function User({ pubkey, time }: { pubkey: string; time: any }) {
|
||||
const { db }: any = useContext(DatabaseContext);
|
||||
const [profile, setProfile] = useState({ picture: null, name: null, username: null });
|
||||
|
||||
const insertCacheProfile = useCallback(
|
||||
async (event) => {
|
||||
const metadata: any = JSON.parse(event.content);
|
||||
|
||||
await db.execute(
|
||||
`INSERT OR IGNORE INTO cache_profiles (id, metadata) VALUES ("${pubkey}", '${JSON.stringify(metadata)}')`
|
||||
);
|
||||
setProfile(metadata);
|
||||
},
|
||||
[db, pubkey]
|
||||
);
|
||||
|
||||
const getCacheProfile = useCallback(async () => {
|
||||
const result: any = await db.select(`SELECT metadata FROM cache_profiles WHERE id = "${pubkey}"`);
|
||||
return result;
|
||||
}, [db, pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
getCacheProfile()
|
||||
.then((res) => {
|
||||
if (res[0] !== undefined) {
|
||||
setProfile(JSON.parse(res[0].metadata));
|
||||
} else {
|
||||
fetch(`https://rbr.bio/${pubkey}/metadata.json`).then((res) =>
|
||||
res.json().then((res) => {
|
||||
// update state
|
||||
setProfile(JSON.parse(res.content));
|
||||
// save profile to database
|
||||
insertCacheProfile(res);
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [getCacheProfile, insertCacheProfile, pubkey]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10">
|
||||
{profile.picture ? (
|
||||
<ImageWithFallback src={profile.picture} alt={pubkey} fill={true} className="rounded-full object-cover" />
|
||||
) : (
|
||||
<Avatar
|
||||
size={44}
|
||||
name={pubkey}
|
||||
variant="beam"
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-bold leading-tight">
|
||||
{profile.name ? profile.name : truncate(pubkey, 16, ' .... ')}
|
||||
</span>
|
||||
<span className="leading-tight text-zinc-500">·</span>
|
||||
<Moment fromNow unix className="text-zinc-500">
|
||||
{time}
|
||||
</Moment>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-4 w-4 text-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,22 +0,0 @@
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
export const UserRepost = memo(function UserRepost({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState({ picture: null, name: null });
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`https://rbr.bio/${pubkey}/metadata.json`).then((res) =>
|
||||
res.json().then((res) => {
|
||||
// update state
|
||||
setProfile(JSON.parse(res.content));
|
||||
})
|
||||
);
|
||||
}, [pubkey]);
|
||||
|
||||
return (
|
||||
<div className="text-zinc-400">
|
||||
<p>{profile.name ? profile.name : truncate(pubkey, 16, ' .... ')} repost</p>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,52 +0,0 @@
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||
import Avatar from 'boring-avatars';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
export const UserWithUsername = memo(function UserWithUsername({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState({ picture: null, name: null, username: null });
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`https://rbr.bio/${pubkey}/metadata.json`).then((res) =>
|
||||
res.json().then((res) => {
|
||||
// update state
|
||||
setProfile(JSON.parse(res.content));
|
||||
})
|
||||
);
|
||||
}, [pubkey]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10">
|
||||
{profile.picture ? (
|
||||
<ImageWithFallback src={profile.picture} alt={pubkey} fill={true} className="rounded-full object-cover" />
|
||||
) : (
|
||||
<Avatar
|
||||
size={44}
|
||||
name={pubkey}
|
||||
variant="beam"
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-bold leading-tight">
|
||||
{profile.name ? profile.name : truncate(pubkey, 16, ' .... ')}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{profile.username ? profile.username : truncate(pubkey, 16, ' .... ')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<DotsHorizontalIcon className="h-4 w-4 text-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
106
src/components/note/base.tsx
Normal file
106
src/components/note/base.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import NoteMetadata from '@components/note/metadata';
|
||||
import { NoteParent } from '@components/note/parent';
|
||||
import { ImagePreview } from '@components/note/preview/image';
|
||||
import { VideoPreview } from '@components/note/preview/video';
|
||||
import { NoteRepost } from '@components/note/repost';
|
||||
import { UserExtend } from '@components/user/extend';
|
||||
import { UserMention } from '@components/user/mention';
|
||||
|
||||
import destr from 'destr';
|
||||
import { useRouter } from 'next/router';
|
||||
import { memo, useMemo } from 'react';
|
||||
import ReactPlayer from 'react-player/lazy';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
|
||||
export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
|
||||
const router = useRouter();
|
||||
|
||||
const content = useMemo(() => {
|
||||
let parsedContent = event.content;
|
||||
// get data tags
|
||||
const tags = destr(event.tags);
|
||||
// handle urls
|
||||
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
|
||||
if (match.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
|
||||
// image url
|
||||
return <ImagePreview key={match + i} url={match} />;
|
||||
} else if (ReactPlayer.canPlay(match)) {
|
||||
return <VideoPreview key={match + i} url={match} />;
|
||||
} else {
|
||||
return (
|
||||
<a key={match + i} href={match} target="_blank" rel="noreferrer">
|
||||
{match}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
// handle #-hashtags
|
||||
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
|
||||
<span key={match + i} className="cursor-pointer text-fuchsia-500">
|
||||
#{match}
|
||||
</span>
|
||||
));
|
||||
// handle mentions
|
||||
if (tags.length > 0) {
|
||||
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
|
||||
if (tags[match][0] === 'p') {
|
||||
// @-mentions
|
||||
return <UserMention key={match + i} pubkey={tags[match][1]} />;
|
||||
} else if (tags[match][0] === 'e') {
|
||||
// note-mentions
|
||||
return <NoteRepost key={match + i} id={tags[match][1]} />;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return parsedContent;
|
||||
}, [event.content, event.tags]);
|
||||
|
||||
const getParent = useMemo(() => {
|
||||
if (event.parent_id) {
|
||||
if (event.parent_id !== event.id && !event.content.includes('#[0]')) {
|
||||
return <NoteParent id={event.parent_id} />;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}, [event.content, event.id, event.parent_id]);
|
||||
|
||||
const openThread = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
router.push(`/newsfeed/${event.parent_id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => openThread(e)}
|
||||
className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-5 px-3 hover:bg-black/20"
|
||||
>
|
||||
<>{getParent}</>
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<UserExtend pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||
<NoteMetadata
|
||||
eventID={event.id}
|
||||
eventPubkey={event.pubkey}
|
||||
eventContent={event.content}
|
||||
eventTime={event.created_at}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
76
src/components/note/comment.tsx
Normal file
76
src/components/note/comment.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import NoteMetadata from '@components/note/metadata';
|
||||
import { ImagePreview } from '@components/note/preview/image';
|
||||
import { VideoPreview } from '@components/note/preview/video';
|
||||
import { NoteRepost } from '@components/note/repost';
|
||||
import { UserExtend } from '@components/user/extend';
|
||||
import { UserMention } from '@components/user/mention';
|
||||
|
||||
import destr from 'destr';
|
||||
import { memo, useMemo } from 'react';
|
||||
import ReactPlayer from 'react-player/lazy';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
|
||||
export const NoteComment = memo(function NoteComment({ event }: { event: any }) {
|
||||
const content = useMemo(() => {
|
||||
let parsedContent = event.content;
|
||||
// get data tags
|
||||
const tags = destr(event.tags);
|
||||
// handle urls
|
||||
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
|
||||
if (match.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
|
||||
// image url
|
||||
return <ImagePreview key={match + i} url={match} />;
|
||||
} else if (ReactPlayer.canPlay(match)) {
|
||||
return <VideoPreview key={match + i} url={match} />;
|
||||
} else {
|
||||
return (
|
||||
<a key={match + i} href={match} target="_blank" rel="noreferrer">
|
||||
{match}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
// handle #-hashtags
|
||||
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
|
||||
<span key={match + i} className="cursor-pointer text-fuchsia-500">
|
||||
#{match}
|
||||
</span>
|
||||
));
|
||||
// handle mentions
|
||||
if (tags.length > 0) {
|
||||
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
|
||||
if (tags[match][0] === 'p') {
|
||||
// @-mentions
|
||||
return <UserMention key={match + i} pubkey={tags[match][1]} />;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return parsedContent;
|
||||
}, [event.content, event.tags]);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-5 px-3 hover:bg-black/20">
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<UserExtend pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||
<NoteMetadata
|
||||
eventID={event.id}
|
||||
eventPubkey={event.pubkey}
|
||||
eventContent={event.content}
|
||||
eventTime={event.created_at}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,111 +1,71 @@
|
||||
import { DatabaseContext } from '@components/contexts/database';
|
||||
import { RelayContext } from '@components/contexts/relay';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { dateToUnix, hoursAgo } from '@utils/getDate';
|
||||
import { activeAccountAtom } from '@stores/account';
|
||||
import { hasNewerNoteAtom } from '@stores/note';
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { ReloadIcon } from '@radix-ui/react-icons';
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
import { createCacheNote, getAllFollowsByID, updateLastLoginTime } from '@utils/storage';
|
||||
import { pubkeyArray } from '@utils/transform';
|
||||
|
||||
export const NoteConnector = memo(function NoteConnector({
|
||||
setParentReload,
|
||||
setHasNewNote,
|
||||
currentDate,
|
||||
}: {
|
||||
setParentReload: any;
|
||||
setHasNewNote: any;
|
||||
currentDate: any;
|
||||
}) {
|
||||
const { db }: any = useContext(DatabaseContext);
|
||||
const relayPool: any = useContext(RelayContext);
|
||||
import { TauriEvent } from '@tauri-apps/api/event';
|
||||
import { appWindow, getCurrent } from '@tauri-apps/api/window';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const [follows]: any = useLocalStorage('follows');
|
||||
const [relays]: any = useLocalStorage('relays');
|
||||
export default function NoteConnector() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [reload, setReload] = useState(false);
|
||||
const timeout = useRef(null);
|
||||
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [activeAccount] = useAtom(activeAccountAtom);
|
||||
|
||||
const reloadNewsfeed = () => {
|
||||
setParentReload(true);
|
||||
setReload(true);
|
||||
timeout.current = setTimeout(() => {
|
||||
setReload(false);
|
||||
}, 2000);
|
||||
};
|
||||
const [isOnline] = useState(true);
|
||||
const now = useRef(new Date());
|
||||
|
||||
const insertDB = useCallback(
|
||||
async (event: any) => {
|
||||
await db.execute(
|
||||
`INSERT OR IGNORE INTO
|
||||
cache_notes
|
||||
(id, pubkey, created_at, kind, tags, content) VALUES
|
||||
(
|
||||
"${event.id}",
|
||||
"${event.pubkey}",
|
||||
"${event.created_at}",
|
||||
"${event.kind}",
|
||||
'${JSON.stringify(event.tags)}',
|
||||
"${event.content}"
|
||||
);`
|
||||
);
|
||||
},
|
||||
[db]
|
||||
);
|
||||
|
||||
const fetchEvent = useCallback(() => {
|
||||
relayPool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [1],
|
||||
authors: follows,
|
||||
since: dateToUnix(hoursAgo(12, currentDate)),
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
// show trigger update newer event
|
||||
if (event.created_at > dateToUnix(currentDate)) {
|
||||
setHasNewNote(true);
|
||||
const subscribe = useCallback(() => {
|
||||
getAllFollowsByID(activeAccount.id).then((follows) => {
|
||||
pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [1],
|
||||
authors: pubkeyArray(follows),
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
// insert event to local database
|
||||
createCacheNote(event);
|
||||
setHasNewerNote(true);
|
||||
}
|
||||
// insert event to local database
|
||||
insertDB(event).catch(console.error);
|
||||
},
|
||||
undefined,
|
||||
(events: any, relayURL: any) => {
|
||||
console.log(events, relayURL);
|
||||
}
|
||||
);
|
||||
}, [relayPool, follows, currentDate, relays, insertDB, setHasNewNote]);
|
||||
);
|
||||
});
|
||||
}, [activeAccount.id, pool, relays, setHasNewerNote]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvent();
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout.current);
|
||||
};
|
||||
}, [fetchEvent]);
|
||||
subscribe();
|
||||
getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
|
||||
updateLastLoginTime(now.current);
|
||||
appWindow.close();
|
||||
});
|
||||
}, [activeAccount.id, pool, relays, setHasNewerNote, subscribe]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-12 items-center justify-between border-b border-zinc-800 px-6 shadow-input">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-500"># following</h3>
|
||||
<>
|
||||
<div className="inline-flex items-center gap-1 rounded-md py-1 px-1.5 hover:bg-zinc-900">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${
|
||||
isOnline ? 'bg-green-400' : 'bg-red-400'
|
||||
}`}
|
||||
></span>
|
||||
<span
|
||||
className={`relative inline-flex h-1.5 w-1.5 rounded-full ${isOnline ? 'bg-green-400' : 'bg-amber-400'}`}
|
||||
></span>
|
||||
</span>
|
||||
<p className="text-xs font-medium text-zinc-500">{isOnline ? 'Online' : 'Offline'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => reloadNewsfeed()}
|
||||
className={`${reload ? 'animate-spin' : ''} rounded-full p-1 hover:bg-zinc-800`}
|
||||
>
|
||||
<ReloadIcon className="h-3.5 w-3.5 text-zinc-500" />
|
||||
</button>
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-800 px-2.5 py-1">
|
||||
{/* #TODO: get user network status */}
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
</span>
|
||||
<p className="text-xs font-medium text-zinc-500">Online</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -1,98 +0,0 @@
|
||||
import Reaction from '@components/note/atoms/reaction';
|
||||
import Reply from '@components/note/atoms/reply';
|
||||
import { User } from '@components/note/atoms/user';
|
||||
import { ImageCard } from '@components/note/content/preview/imageCard';
|
||||
import { Video } from '@components/note/content/preview/video';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ReactPlayer from 'react-player';
|
||||
|
||||
const MarkdownPreview = dynamic(() => import('@uiw/react-markdown-preview'), {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-4 w-36 animate-pulse rounded bg-zinc-700" />,
|
||||
});
|
||||
|
||||
export const Content = memo(function Content({ data }: { data: any }) {
|
||||
const [preview, setPreview] = useState({});
|
||||
|
||||
const content = useRef(data.content);
|
||||
const urls = useMemo(
|
||||
() =>
|
||||
content.current.match(
|
||||
/((http|ftp|https):\/\/)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)?/gi
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (urls !== null && urls.length > 0) {
|
||||
// #TODO: support multiple url
|
||||
let url = urls[0];
|
||||
// make sure url alway have http://
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'http://' + url;
|
||||
}
|
||||
// parse url with new URL();
|
||||
const parseURL = new URL(url, 'https://uselume.xyz');
|
||||
// #TODO performance test
|
||||
if (parseURL.pathname.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
|
||||
// add image to preview
|
||||
setPreview({ image: parseURL.href, type: 'image' });
|
||||
content.current = content.current.replace(parseURL.href, '');
|
||||
} else if (ReactPlayer.canPlay(parseURL.href)) {
|
||||
// add video to preview
|
||||
setPreview({ url: parseURL.href, type: 'video' });
|
||||
content.current = content.current.replace(parseURL.href, '');
|
||||
} // #TODO: support multiple previ3ew
|
||||
}
|
||||
}, [urls]);
|
||||
|
||||
const previewAttachment = useCallback(() => {
|
||||
if (Object.keys(preview).length > 0) {
|
||||
switch (preview['type']) {
|
||||
case 'image':
|
||||
return <ImageCard data={preview} />;
|
||||
case 'video':
|
||||
return <Video data={preview} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}, [preview]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
<MarkdownPreview
|
||||
source={content.current}
|
||||
className={
|
||||
'prose prose-zinc max-w-none break-words dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:leading-normal prose-ul:mt-2 prose-li:my-1'
|
||||
}
|
||||
linkTarget="_blank"
|
||||
disallowedElements={[
|
||||
'Table',
|
||||
'Heading ID',
|
||||
'Highlight',
|
||||
'Fenced Code Block',
|
||||
'Footnote',
|
||||
'Definition List',
|
||||
'Task List',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<>{previewAttachment()}</>
|
||||
</div>
|
||||
<div className="relative z-10 -ml-1 flex items-center gap-8">
|
||||
<Reply eventID={data.id} />
|
||||
<Reaction eventID={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,22 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
import { memo } from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const ImageCard = memo(function ImageCard({ data }: { data: object }) {
|
||||
return (
|
||||
<div className={`relative mt-2 flex flex-col overflow-hidden`}>
|
||||
<div className="relative h-full w-full rounded-lg border border-zinc-800">
|
||||
<Image
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
|
||||
src={data['image']}
|
||||
alt={data['image']}
|
||||
width="0"
|
||||
height="0"
|
||||
sizes="100vw"
|
||||
className=" h-auto w-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,18 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import ReactPlayer from 'react-player/lazy';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Video = memo(function Video({ data }: { data: object }) {
|
||||
return (
|
||||
<div className="relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-800">
|
||||
<ReactPlayer
|
||||
url={data['url']}
|
||||
controls={true}
|
||||
volume={0}
|
||||
className="aspect-video w-full"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
79
src/components/note/extend.tsx
Normal file
79
src/components/note/extend.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import NoteMetadata from '@components/note/metadata';
|
||||
import { ImagePreview } from '@components/note/preview/image';
|
||||
import { VideoPreview } from '@components/note/preview/video';
|
||||
import { NoteRepost } from '@components/note/repost';
|
||||
import { UserLarge } from '@components/user/large';
|
||||
import { UserMention } from '@components/user/mention';
|
||||
|
||||
import destr from 'destr';
|
||||
import { memo, useMemo } from 'react';
|
||||
import ReactPlayer from 'react-player/lazy';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
|
||||
export const NoteExtend = memo(function NoteExtend({ event }: { event: any }) {
|
||||
const content = useMemo(() => {
|
||||
let parsedContent = event.content;
|
||||
// get data tags
|
||||
const tags = destr(event.tags);
|
||||
// handle urls
|
||||
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
|
||||
if (match.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
|
||||
// image url
|
||||
return <ImagePreview key={match + i} url={match} />;
|
||||
} else if (ReactPlayer.canPlay(match)) {
|
||||
return <VideoPreview key={match + i} url={match} />;
|
||||
} else {
|
||||
return (
|
||||
<a key={match + i} href={match} target="_blank" rel="noreferrer">
|
||||
{match}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
// handle #-hashtags
|
||||
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
|
||||
<span key={match + i} className="cursor-pointer text-fuchsia-500">
|
||||
#{match}
|
||||
</span>
|
||||
));
|
||||
// handle mentions
|
||||
if (tags.length > 0) {
|
||||
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
|
||||
if (tags[match][0] === 'p') {
|
||||
// @-mentions
|
||||
return <UserMention key={match + i} pubkey={tags[match][1]} />;
|
||||
} else if (tags[match][0] === 'e') {
|
||||
// note-mentions
|
||||
return <NoteRepost key={match + i} id={tags[match][1]} />;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return parsedContent;
|
||||
}, [event.content, event.tags]);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col">
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<UserLarge pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="mt-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center border-t border-b border-zinc-800 py-2">
|
||||
<NoteMetadata
|
||||
eventID={event.id}
|
||||
eventPubkey={event.pubkey}
|
||||
eventContent={event.content}
|
||||
eventTime={event.created_at}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
143
src/components/note/meta/comment.tsx
Normal file
143
src/components/note/meta/comment.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
import { UserExtend } from '@components/user/extend';
|
||||
|
||||
import { activeAccountAtom } from '@stores/account';
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
|
||||
import CommentIcon from '@assets/icons/comment';
|
||||
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { SizeIcon } from '@radix-ui/react-icons';
|
||||
import destr from 'destr';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { memo, useContext, useState } from 'react';
|
||||
|
||||
export const NoteComment = memo(function NoteComment({
|
||||
count,
|
||||
eventID,
|
||||
eventPubkey,
|
||||
eventContent,
|
||||
eventTime,
|
||||
}: {
|
||||
count: number;
|
||||
eventID: string;
|
||||
eventPubkey: string;
|
||||
eventTime: string;
|
||||
eventContent: any;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [activeAccount] = useAtom(activeAccountAtom);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const profile = destr(activeAccount.metadata);
|
||||
|
||||
const openThread = () => {
|
||||
router.push(`/newsfeed/${eventID}`);
|
||||
};
|
||||
|
||||
const submitEvent = () => {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: activeAccount.id,
|
||||
tags: [['e', eventID]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, activeAccount.privkey);
|
||||
|
||||
pool.publish(event, relays);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button className="group flex w-16 items-center gap-1 text-sm text-zinc-500">
|
||||
<div className="rounded-md p-1 group-hover:bg-zinc-800">
|
||||
<CommentIcon className="h-5 w-5 text-zinc-500" />
|
||||
</div>
|
||||
<span>{count}</span>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-30 backdrop-blur-sm data-[state=open]:animate-overlayShow" />
|
||||
<Dialog.Content className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center">
|
||||
<div className="relative w-full max-w-2xl rounded-lg bg-zinc-900 p-4 text-zinc-100 ring-1 ring-zinc-800">
|
||||
{/* root note */}
|
||||
<div className="relative z-10 flex flex-col pb-6">
|
||||
<div className="relative z-10">
|
||||
<UserExtend pubkey={eventPubkey} time={eventTime} />
|
||||
</div>
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="prose prose-zinc max-w-none break-words leading-tight dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1">
|
||||
{eventContent}
|
||||
</div>
|
||||
</div>
|
||||
{/* divider */}
|
||||
<div className="absolute top-0 left-[21px] h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||
</div>
|
||||
{/* comment form */}
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
|
||||
<ImageWithFallback
|
||||
src={profile.picture}
|
||||
alt="user's avatar"
|
||||
fill={true}
|
||||
className="rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-36 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Send your comment"
|
||||
className="relative h-36 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<button
|
||||
onClick={() => openThread()}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-zinc-700"
|
||||
>
|
||||
<SizeIcon className="h-4 w-4 text-zinc-400" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-md shadow-fuchsia-900/50 hover:bg-fuchsia-600"
|
||||
>
|
||||
<span className="text-white drop-shadow">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
});
|
67
src/components/note/meta/reaction.tsx
Normal file
67
src/components/note/meta/reaction.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { activeAccountAtom } from '@stores/account';
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
|
||||
import LikeIcon from '@assets/icons/like';
|
||||
import LikedIcon from '@assets/icons/liked';
|
||||
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { memo, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export const NoteReaction = memo(function NoteReaction({
|
||||
count,
|
||||
eventID,
|
||||
eventPubkey,
|
||||
}: {
|
||||
count: number;
|
||||
eventID: string;
|
||||
eventPubkey: string;
|
||||
}) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [activeAccount] = useAtom(activeAccountAtom);
|
||||
|
||||
const [isReact, setIsReact] = useState(false);
|
||||
const [like, setLike] = useState(0);
|
||||
|
||||
const handleLike = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const event: any = {
|
||||
content: '+',
|
||||
kind: 7,
|
||||
tags: [
|
||||
['e', eventID],
|
||||
['p', eventPubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: activeAccount.id,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, activeAccount.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, relays);
|
||||
// update state to change icon to filled heart
|
||||
setIsReact(true);
|
||||
// update counter
|
||||
setLike(like + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLike(count);
|
||||
}, [count]);
|
||||
|
||||
return (
|
||||
<button onClick={(e) => handleLike(e)} className="group flex w-16 items-center gap-1 text-sm text-zinc-500">
|
||||
<div className="rounded-md p-1 group-hover:bg-zinc-800">
|
||||
{isReact ? <LikedIcon className="h-5 w-5 text-red-500" /> : <LikeIcon className="h-5 w-5 text-zinc-500" />}
|
||||
</div>
|
||||
<span>{like}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
81
src/components/note/metadata.tsx
Normal file
81
src/components/note/metadata.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { NoteComment } from '@components/note/meta/comment';
|
||||
import { NoteReaction } from '@components/note/meta/reaction';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { createCacheCommentNote } from '@utils/storage';
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
export default function NoteMetadata({
|
||||
eventID,
|
||||
eventPubkey,
|
||||
eventContent,
|
||||
eventTime,
|
||||
}: {
|
||||
eventID: string;
|
||||
eventPubkey: string;
|
||||
eventTime: any;
|
||||
eventContent: any;
|
||||
}) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
|
||||
const [likes, setLikes] = useState(0);
|
||||
const [comments, setComments] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [eventID],
|
||||
since: parseInt(eventTime),
|
||||
kinds: [1, 7],
|
||||
limit: 50,
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
// update state
|
||||
setComments((comments) => (comments += 1));
|
||||
// save comment to database
|
||||
createCacheCommentNote(event, eventID);
|
||||
break;
|
||||
case 7:
|
||||
if (event.content === '🤙' || event.content === '+') {
|
||||
setLikes((likes) => (likes += 1));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
1000,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe;
|
||||
};
|
||||
}, [eventID, eventTime, pool, relays]);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 -ml-1 flex items-center gap-8">
|
||||
<NoteComment
|
||||
count={comments}
|
||||
eventID={eventID}
|
||||
eventPubkey={eventPubkey}
|
||||
eventContent={eventContent}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
<NoteReaction count={likes} eventID={eventID} eventPubkey={eventPubkey} />
|
||||
</div>
|
||||
);
|
||||
}
|
161
src/components/note/parent.tsx
Normal file
161
src/components/note/parent.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import NoteMetadata from '@components/note/metadata';
|
||||
import { ImagePreview } from '@components/note/preview/image';
|
||||
import { VideoPreview } from '@components/note/preview/video';
|
||||
import { NoteRepost } from '@components/note/repost';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
import { UserExtend } from '@components/user/extend';
|
||||
import { UserMention } from '@components/user/mention';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { createCacheNote, getNoteByID } from '@utils/storage';
|
||||
|
||||
import destr from 'destr';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ReactPlayer from 'react-player';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
|
||||
export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [event, setEvent] = useState(null);
|
||||
const unsubscribe = useRef(null);
|
||||
|
||||
const fetchEvent = useCallback(() => {
|
||||
unsubscribe.current = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [id],
|
||||
kinds: [1],
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
// update state
|
||||
setEvent(event);
|
||||
// insert to database
|
||||
createCacheNote(event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
}, [id, pool, relays]);
|
||||
|
||||
useEffect(() => {
|
||||
getNoteByID(id).then((res) => {
|
||||
if (res) {
|
||||
setEvent(res);
|
||||
} else {
|
||||
fetchEvent();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe.current;
|
||||
};
|
||||
}, [fetchEvent, id]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
let parsedContent = event ? event.content : null;
|
||||
|
||||
if (parsedContent !== null) {
|
||||
// get data tags
|
||||
const tags = destr(event.tags);
|
||||
// handle urls
|
||||
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
|
||||
if (match.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
|
||||
// image url
|
||||
return <ImagePreview key={match + i} url={match} />;
|
||||
} else if (ReactPlayer.canPlay(match)) {
|
||||
return <VideoPreview key={match + i} url={match} />;
|
||||
} else {
|
||||
return (
|
||||
<a key={match + i} href={match} target="_blank" rel="noreferrer">
|
||||
{match}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
// handle #-hashtags
|
||||
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
|
||||
<span key={match + i} className="cursor-pointer text-fuchsia-500">
|
||||
#{match}
|
||||
</span>
|
||||
));
|
||||
// handle mentions
|
||||
if (tags.length > 0) {
|
||||
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
|
||||
if (tags[match][0] === 'p') {
|
||||
// @-mentions
|
||||
return <UserMention key={match + i} pubkey={tags[match][1]} />;
|
||||
} else if (tags[match][0] === 'e') {
|
||||
// note-mentions
|
||||
return <NoteRepost key={match + i} id={tags[match][1]} />;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsedContent;
|
||||
}, [event]);
|
||||
|
||||
if (event) {
|
||||
return (
|
||||
<div className="relative pb-5">
|
||||
<div className="absolute top-0 left-[21px] h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<UserExtend pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||
<NoteMetadata
|
||||
eventID={event.id}
|
||||
eventPubkey={event.pubkey}
|
||||
eventContent={event.content}
|
||||
eventTime={event.created_at}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="relative z-10 flex h-min animate-pulse select-text flex-col pb-5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
<div className="h-3 w-3 rounded-full bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-16 w-full rounded bg-zinc-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
@ -2,9 +2,9 @@ import { memo } from 'react';
|
||||
|
||||
export const Placeholder = memo(function Placeholder() {
|
||||
return (
|
||||
<div className="relative z-10 flex h-min animate-pulse select-text flex-col py-4 px-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full bg-zinc-700" />
|
||||
<div className="relative z-10 flex h-min animate-pulse select-text flex-col py-5 px-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
@ -16,7 +16,7 @@ export const Placeholder = memo(function Placeholder() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-16 w-full rounded bg-zinc-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
|
20
src/components/note/preview/image.tsx
Normal file
20
src/components/note/preview/image.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Image from 'next/image';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const ImagePreview = memo(function ImagePreview({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="relative mt-3 mb-2 h-full w-full rounded-lg xl:w-2/3">
|
||||
<Image
|
||||
src={url}
|
||||
alt={url}
|
||||
width="0"
|
||||
height="0"
|
||||
sizes="100vw"
|
||||
className="h-auto w-full rounded-lg border border-zinc-800 object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,13 +1,12 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function LinkCard({ data }: { data: object }) {
|
||||
export default function LinkCard({ data }: { data: any }) {
|
||||
return (
|
||||
<Link
|
||||
href={data['url']}
|
||||
target={'_blank'}
|
||||
className="relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-700"
|
||||
className="relative mt-2 flex flex-col overflow-hidden rounded-lg border border-zinc-700"
|
||||
>
|
||||
<div className="relative aspect-video h-auto w-full">
|
||||
<Image src={data['image']} alt="image preview" fill={true} className="object-cover" />
|
17
src/components/note/preview/video.tsx
Normal file
17
src/components/note/preview/video.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { memo } from 'react';
|
||||
import ReactPlayer from 'react-player/lazy';
|
||||
|
||||
export const VideoPreview = memo(function VideoPreview({ url }: { url: string }) {
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()} className="relative mt-3 flex flex-col overflow-hidden rounded-lg">
|
||||
<ReactPlayer
|
||||
url={url}
|
||||
controls={true}
|
||||
volume={0}
|
||||
className="aspect-video w-full xl:w-2/3"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,52 +1,110 @@
|
||||
import { RelayContext } from '@components/contexts/relay';
|
||||
import { UserRepost } from '@components/note/atoms/userRepost';
|
||||
import { Content } from '@components/note/content';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
import { UserExtend } from '@components/user/extend';
|
||||
import { UserMention } from '@components/user/mention';
|
||||
|
||||
import { LoopIcon } from '@radix-ui/react-icons';
|
||||
import useLocalStorage from '@rehooks/local-storage';
|
||||
import { memo, useContext, useState } from 'react';
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
export const Repost = memo(function Repost({ root, user }: { root: any; user: string }) {
|
||||
const relayPool: any = useContext(RelayContext);
|
||||
const [relays]: any = useLocalStorage('relays');
|
||||
const [events, setEvents] = useState([]);
|
||||
import { createCacheNote, getNoteByID } from '@utils/storage';
|
||||
|
||||
relayPool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [root[0][1]],
|
||||
since: 0,
|
||||
kinds: [1],
|
||||
import destr from 'destr';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
|
||||
export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [event, setEvent] = useState(null);
|
||||
const unsubscribe = useRef(null);
|
||||
|
||||
const fetchEvent = useCallback(() => {
|
||||
unsubscribe.current = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [id],
|
||||
kinds: [1],
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
// update state
|
||||
setEvent(event);
|
||||
// insert to database
|
||||
createCacheNote(event);
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
setEvents((events) => [event, ...events]);
|
||||
},
|
||||
undefined,
|
||||
(events: any, relayURL: any) => {
|
||||
console.log(events, relayURL);
|
||||
}
|
||||
);
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
}, [id, pool, relays]);
|
||||
|
||||
if (events !== null && Object.keys(events).length > 0) {
|
||||
useEffect(() => {
|
||||
getNoteByID(id).then((res) => {
|
||||
if (res) {
|
||||
setEvent(res);
|
||||
} else {
|
||||
fetchEvent();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe.current;
|
||||
};
|
||||
}, [fetchEvent, id]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
let parsedContent = event ? event.content : null;
|
||||
|
||||
if (parsedContent !== null) {
|
||||
// get data tags
|
||||
const tags = destr(event.tags);
|
||||
// handle urls
|
||||
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => (
|
||||
<a key={match + i} href={match} target="_blank" rel="noreferrer">
|
||||
{match}
|
||||
</a>
|
||||
));
|
||||
// handle #-hashtags
|
||||
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
|
||||
<span key={match + i} className="cursor-pointer text-fuchsia-500">
|
||||
#{match}
|
||||
</span>
|
||||
));
|
||||
// handle mentions
|
||||
if (tags.length > 0) {
|
||||
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
|
||||
if (tags[match][0] === 'p') {
|
||||
// @-mentions
|
||||
return <UserMention key={match + i} pubkey={tags[match][1]} />;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsedContent;
|
||||
}, [event]);
|
||||
|
||||
if (event) {
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-6 px-6">
|
||||
<div className="flex items-center gap-1 pl-8 text-sm">
|
||||
<LoopIcon className="h-4 w-4 text-zinc-400" />
|
||||
<div className="ml-2">
|
||||
<UserRepost pubkey={user} />
|
||||
<div className="relative mt-3 mb-2 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3">
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<UserExtend pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{events[0].content && <Content data={events[0]} />}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="border-b border-zinc-800">
|
||||
<Placeholder />
|
||||
</div>
|
||||
);
|
||||
return <div className="mt-2 h-6 animate-pulse select-text flex-col rounded bg-zinc-700 pb-5"></div>;
|
||||
}
|
||||
});
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { Content } from '@components/note/content';
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
export const Single = memo(function Single({ event }: { event: any }) {
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full cursor-pointer select-text flex-col border-b border-zinc-800 py-4 px-6 hover:bg-zinc-800">
|
||||
<Content data={event} />
|
||||
</div>
|
||||
);
|
||||
});
|
27
src/components/profile/followers.tsx
Normal file
27
src/components/profile/followers.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
import { UserFollow } from '@components/user/follow';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import destr from 'destr';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Author } from 'nostr-relaypool';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
export default function ProfileFollowers({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const relays: any = useAtomValue(relaysAtom);
|
||||
|
||||
const [followers, setFollowers] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const user = new Author(pool, relays, id);
|
||||
user.followers((res) => setFollowers(destr(res.tags)), 0, 100);
|
||||
}, [id, pool, relays]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-3 py-5">
|
||||
{followers && followers.map((follower) => <UserFollow key={follower[1]} pubkey={follower[1]} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
26
src/components/profile/follows.tsx
Normal file
26
src/components/profile/follows.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
import { UserFollow } from '@components/user/follow';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Author } from 'nostr-relaypool';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
export default function ProfileFollows({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const relays: any = useAtomValue(relaysAtom);
|
||||
|
||||
const [follows, setFollows] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const user = new Author(pool, relays, id);
|
||||
user.follows((res) => setFollows(res), 0);
|
||||
}, [id, pool, relays]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-3 py-5">
|
||||
{follows && follows.map((follow) => <UserFollow key={follow.pubkey} pubkey={follow.pubkey} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
70
src/components/profile/metadata.tsx
Normal file
70
src/components/profile/metadata.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import Avatar from 'boring-avatars';
|
||||
import destr from 'destr';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import Image from 'next/image';
|
||||
import { Author } from 'nostr-relaypool';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
const DEFAULT_BANNER = 'https://bafybeiacwit7hjmdefqggxqtgh6ht5dhth7ndptwn2msl5kpkodudsr7py.ipfs.w3s.link/banner-1.jpg';
|
||||
|
||||
export default function ProfileMetadata({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const relays: any = useAtomValue(relaysAtom);
|
||||
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const user = new Author(pool, relays, id);
|
||||
user.metaData((res) => setProfile(destr(res.content)), 0);
|
||||
}, [id, pool, relays]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="relative h-56 w-full rounded-t-lg bg-zinc-800">
|
||||
<Image
|
||||
src={profile?.banner || DEFAULT_BANNER}
|
||||
alt="user's banner"
|
||||
fill={true}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative -top-8 z-10 px-4">
|
||||
<div className="relative h-16 w-16 rounded-lg bg-zinc-900 ring-2 ring-zinc-900">
|
||||
{profile?.picture ? (
|
||||
<ImageWithFallback src={profile.picture} alt={id} fill={true} className="rounded-lg object-cover" />
|
||||
) : (
|
||||
<Avatar
|
||||
size={64}
|
||||
name={id}
|
||||
variant="beam"
|
||||
square={true}
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-4 mb-8 px-4">
|
||||
<div>
|
||||
<div className="mb-3 flex flex-col">
|
||||
<h3 className="text-lg font-semibold leading-tight text-zinc-100">
|
||||
{profile?.display_name || profile?.name}
|
||||
</h3>
|
||||
<span className="text-sm leading-tight text-zinc-500">
|
||||
{profile?.username || (id && truncate(id, 16, ' .... '))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="prose-sm prose-zinc leading-tight dark:prose-invert">{profile?.about}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
30
src/components/profile/notes.tsx
Normal file
30
src/components/profile/notes.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { NoteBase } from '@components/note/base';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Author } from 'nostr-relaypool';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
export default function ProfileNotes({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const relays: any = useAtomValue(relaysAtom);
|
||||
|
||||
const [data, setData] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const user = new Author(pool, relays, id);
|
||||
user.text((res) => setData((data) => [...data, res]), 100, 0);
|
||||
}, [id, pool, relays]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{data.map((item) => (
|
||||
<div key={item.id}>
|
||||
<NoteBase event={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -3,8 +3,7 @@ import { createContext, useMemo } from 'react';
|
||||
|
||||
export const RelayContext = createContext({});
|
||||
|
||||
export default function RelayProvider({ relays, children }: { relays: any; children: React.ReactNode }) {
|
||||
const value = useMemo(() => new RelayPool(relays, { useEventCache: true }), [relays]);
|
||||
|
||||
export default function RelayProvider({ relays, children }: { relays: Array<string>; children: React.ReactNode }) {
|
||||
const value = useMemo(() => new RelayPool(relays, { useEventCache: false, logSubscriptions: false }), [relays]);
|
||||
return <RelayContext.Provider value={value}>{children}</RelayContext.Provider>;
|
||||
}
|
45
src/components/user/base.tsx
Normal file
45
src/components/user/base.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
|
||||
import { createCacheProfile } from '@utils/storage';
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import destr from 'destr';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const UserBase = memo(function UserBase({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const fetchProfile = useCallback(async (id: string) => {
|
||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
||||
method: 'GET',
|
||||
timeout: 30,
|
||||
});
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile(pubkey)
|
||||
.then((res: any) => {
|
||||
setProfile(destr(res.content));
|
||||
createCacheProfile(res.pubkey, res.content);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [fetchProfile, pubkey]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10">
|
||||
{profile?.picture && (
|
||||
<ImageWithFallback src={profile.picture} alt={pubkey} fill={true} className="rounded-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-200">
|
||||
{profile?.display_name || profile?.name}
|
||||
</span>
|
||||
<span className="text-sm leading-tight text-zinc-400">{truncate(pubkey, 16, ' .... ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
90
src/components/user/extend.tsx
Normal file
90
src/components/user/extend.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
|
||||
import { createCacheProfile, getCacheProfile } from '@utils/storage';
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import Avatar from 'boring-avatars';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import destr from 'destr';
|
||||
import { useRouter } from 'next/router';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const UserExtend = memo(function UserExtend({ pubkey, time }: { pubkey: string; time: any }) {
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const openUserPage = (e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/users/${pubkey}`);
|
||||
};
|
||||
|
||||
const fetchProfile = useCallback(async (id: string) => {
|
||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
||||
method: 'GET',
|
||||
timeout: 30,
|
||||
});
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getCacheProfile(pubkey).then((res) => {
|
||||
if (res) {
|
||||
setProfile(destr(res.metadata));
|
||||
} else {
|
||||
fetchProfile(pubkey)
|
||||
.then((res: any) => {
|
||||
setProfile(destr(res.content));
|
||||
createCacheProfile(pubkey, res.content);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
});
|
||||
}, [fetchProfile, pubkey]);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-2">
|
||||
<div
|
||||
onClick={(e) => openUserPage(e)}
|
||||
className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-900 ring-fuchsia-500 ring-offset-1 ring-offset-zinc-900 group-hover:ring-1"
|
||||
>
|
||||
{profile?.picture ? (
|
||||
<ImageWithFallback
|
||||
src={profile.picture}
|
||||
alt={pubkey}
|
||||
fill={true}
|
||||
className="rounded-md border border-white/10 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
size={44}
|
||||
name={pubkey}
|
||||
variant="beam"
|
||||
square={true}
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span onClick={(e) => openUserPage(e)} className="font-bold leading-tight group-hover:underline">
|
||||
{profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')}
|
||||
</span>
|
||||
<span className="leading-tight text-zinc-500">·</span>
|
||||
<span className="text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800">
|
||||
<DotsHorizontalIcon className="h-3 w-3 text-zinc-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
45
src/components/user/follow.tsx
Normal file
45
src/components/user/follow.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
|
||||
import { createCacheProfile } from '@utils/storage';
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import destr from 'destr';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const UserFollow = memo(function UserFollow({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const fetchProfile = useCallback(async (id: string) => {
|
||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
||||
method: 'GET',
|
||||
timeout: 30,
|
||||
});
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile(pubkey)
|
||||
.then((res: any) => {
|
||||
setProfile(destr(res.content));
|
||||
createCacheProfile(res.pubkey, res.content);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [fetchProfile, pubkey]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10">
|
||||
{profile?.picture && (
|
||||
<ImageWithFallback src={profile.picture} alt={pubkey} fill={true} className="rounded-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-200">
|
||||
{profile?.display_name || profile?.name}
|
||||
</span>
|
||||
<span className="text-sm leading-tight text-zinc-400">{truncate(pubkey, 16, ' .... ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
81
src/components/user/large.tsx
Normal file
81
src/components/user/large.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
|
||||
import { createCacheProfile, getCacheProfile } from '@utils/storage';
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import Avatar from 'boring-avatars';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import destr from 'destr';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const UserLarge = memo(function UserLarge({ pubkey, time }: { pubkey: string; time: any }) {
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const fetchProfile = useCallback(async (id: string) => {
|
||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
||||
method: 'GET',
|
||||
timeout: 30,
|
||||
});
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getCacheProfile(pubkey).then((res) => {
|
||||
if (res) {
|
||||
setProfile(destr(res.metadata));
|
||||
} else {
|
||||
fetchProfile(pubkey)
|
||||
.then((res: any) => {
|
||||
setProfile(destr(res.content));
|
||||
createCacheProfile(pubkey, res.content);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
});
|
||||
}, [fetchProfile, pubkey]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-900">
|
||||
{profile?.picture ? (
|
||||
<ImageWithFallback
|
||||
src={profile.picture}
|
||||
alt={pubkey}
|
||||
fill={true}
|
||||
className="rounded-md border border-white/10 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
size={44}
|
||||
name={pubkey}
|
||||
variant="beam"
|
||||
square={true}
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex-1">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-bold leading-tight text-zinc-100">
|
||||
{profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')}
|
||||
</span>
|
||||
<span className="leading-tight text-zinc-400">
|
||||
{profile?.username || truncate(pubkey, 16, ' .... ')} · {dayjs().to(dayjs.unix(time))}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<button className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800">
|
||||
<DotsHorizontalIcon className="h-3 w-3 text-zinc-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
35
src/components/user/mention.tsx
Normal file
35
src/components/user/mention.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { createCacheProfile, getCacheProfile } from '@utils/storage';
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import destr from 'destr';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const UserMention = memo(function UserMention({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const fetchProfile = useCallback(async (id: string) => {
|
||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
||||
method: 'GET',
|
||||
timeout: 30,
|
||||
});
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getCacheProfile(pubkey).then((res) => {
|
||||
if (res) {
|
||||
setProfile(destr(res.metadata));
|
||||
} else {
|
||||
fetchProfile(pubkey)
|
||||
.then((res: any) => {
|
||||
setProfile(destr(res.content));
|
||||
createCacheProfile(pubkey, res.content);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
});
|
||||
}, [fetchProfile, pubkey]);
|
||||
|
||||
return <span className="cursor-pointer text-fuchsia-500">@{profile?.name || truncate(pubkey, 16, ' .... ')}</span>;
|
||||
});
|
59
src/components/user/mini.tsx
Normal file
59
src/components/user/mini.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
|
||||
import { createCacheProfile, getCacheProfile } from '@utils/storage';
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import Avatar from 'boring-avatars';
|
||||
import destr from 'destr';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const UserMini = memo(function UserMini({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const fetchProfile = useCallback(async (id: string) => {
|
||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
||||
method: 'GET',
|
||||
timeout: 30,
|
||||
});
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getCacheProfile(pubkey).then((res) => {
|
||||
if (res) {
|
||||
setProfile(destr(res.metadata));
|
||||
} else {
|
||||
fetchProfile(pubkey)
|
||||
.then((res: any) => {
|
||||
setProfile(destr(res.content));
|
||||
createCacheProfile(pubkey, res.content);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
});
|
||||
}, [fetchProfile, pubkey]);
|
||||
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm font-medium hover:bg-zinc-900">
|
||||
<div className="relative h-5 w-5 shrink-0 overflow-hidden rounded">
|
||||
{profile?.picture ? (
|
||||
<ImageWithFallback src={profile.picture} alt={pubkey} fill={true} className="rounded object-cover" />
|
||||
) : (
|
||||
<Avatar
|
||||
size={20}
|
||||
name={pubkey}
|
||||
variant="beam"
|
||||
square={true}
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-flex w-full flex-1 flex-col overflow-hidden">
|
||||
<p className="truncate leading-tight text-zinc-300">
|
||||
{profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,3 +1,3 @@
|
||||
export default function BaseLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="h-screen w-screen bg-white text-zinc-900 dark:bg-near-black dark:text-white">{children}</div>;
|
||||
return <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">{children}</div>;
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-gradient-radial-page relative h-full overflow-hidden">
|
||||
{/* dragging area */}
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 z-20 h-16 w-full bg-transparent" />
|
||||
{/* end dragging area */}
|
||||
{/* content */}
|
||||
<div className="relative z-10 h-full">{children}</div>
|
||||
{/* end content */}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import AccountColumn from '@components/columns/account';
|
||||
import NavigatorColumn from '@components/columns/navigator';
|
||||
|
||||
export default function NewsFeedLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-row">
|
||||
<div className="relative h-full w-[70px] shrink-0 border-r border-zinc-900">
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 h-12 w-full" />
|
||||
<AccountColumn />
|
||||
</div>
|
||||
<div className="grid grow grid-cols-4">
|
||||
<div className="col-span-1">
|
||||
<NavigatorColumn />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 ml-0 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="h-full w-full rounded-lg">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
export default function OnboardingLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-row">
|
||||
<div className="relative h-full w-[70px] shrink-0 border-r border-zinc-900">
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 h-12 w-full" />
|
||||
</div>
|
||||
<div className="grid grow grid-cols-4">
|
||||
<div className="col-span-1"></div>
|
||||
<div className="col-span-3 m-3 ml-0 overflow-hidden rounded-lg bg-zinc-900 shadow-md ring-1 ring-inset dark:shadow-black/10 dark:ring-white/10">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
38
src/layouts/withSidebar.tsx
Normal file
38
src/layouts/withSidebar.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import AppHeader from '@components/appHeader';
|
||||
import AccountColumn from '@components/columns/account';
|
||||
import NavigatorColumn from '@components/columns/navigator';
|
||||
|
||||
export default function WithSidebarLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-11 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<div className="absolute top-0 left-0 h-12 w-full" />
|
||||
<AccountColumn />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<NavigatorColumn />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20 xl:col-span-2 xl:mr-1.5">
|
||||
<div className="h-full w-full rounded-lg">{children}</div>
|
||||
</div>
|
||||
<div className="col-span-3 m-3 hidden overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20 xl:col-span-2 xl:ml-1.5 xl:flex">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="select-text p-8 text-center text-zinc-400">
|
||||
This feature hasn't implemented yet, so resize Lume to the initial size for a better experience.
|
||||
I'm sorry for this inconvenience, and I swear I will add it soon 😁
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,7 +1,11 @@
|
||||
import DatabaseProvider from '@components/contexts/database';
|
||||
import RelayProvider from '@components/contexts/relay';
|
||||
import RelayProvider from '@components/relaysProvider';
|
||||
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Provider, useAtomValue } from 'jotai';
|
||||
import { queryClientAtom } from 'jotai-tanstack-query';
|
||||
import { useHydrateAtoms } from 'jotai/react/utils';
|
||||
import type { NextPage } from 'next';
|
||||
import type { AppProps } from 'next/app';
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
@ -17,15 +21,25 @@ type AppPropsWithLayout = AppProps & {
|
||||
Component: NextPageWithLayout;
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const HydrateAtoms = ({ children }) => {
|
||||
useHydrateAtoms([[queryClientAtom, queryClient]]);
|
||||
return children;
|
||||
};
|
||||
|
||||
export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
// Get relays from localstorage
|
||||
const [relays] = useLocalStorage('relays');
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
|
||||
return (
|
||||
<DatabaseProvider>
|
||||
<RelayProvider relays={relays}>{getLayout(<Component {...pageProps} />)}</RelayProvider>
|
||||
</DatabaseProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider>
|
||||
<HydrateAtoms>
|
||||
<RelayProvider relays={relays}>{getLayout(<Component {...pageProps} />)}</RelayProvider>
|
||||
</HydrateAtoms>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,14 +1,10 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import NewsFeedLayout from '@layouts/newsfeed';
|
||||
import WithSidebarLayout from '@layouts/withSidebar';
|
||||
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<p>Global</p>
|
||||
</div>
|
||||
);
|
||||
return <></>;
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
@ -22,7 +18,7 @@ Page.getLayout = function getLayout(
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<NewsFeedLayout>{page}</NewsFeedLayout>
|
||||
<WithSidebarLayout>{page}</WithSidebarLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
@ -1,102 +1,58 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import FullscreenLayout from '@layouts/fullscreen';
|
||||
|
||||
import { getAccounts } from '@utils/storage';
|
||||
|
||||
import LumeSymbol from '@assets/icons/Lume';
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useEffect, useRef, useState } from 'react';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useEffect } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
const [currentUser]: any = useLocalStorage('current-user');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
timer.current = setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/newsfeed/following');
|
||||
}, 1000);
|
||||
} else {
|
||||
timer.current = setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/onboarding');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// clean up
|
||||
return () => {
|
||||
clearTimeout(timer.current);
|
||||
};
|
||||
}, [currentUser, router]);
|
||||
getAccounts()
|
||||
.then((res: any) => {
|
||||
if (res.length > 0) {
|
||||
router.push('/init');
|
||||
} else {
|
||||
router.push('/onboarding');
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col items-center justify-between">
|
||||
<div>{/* spacer */}</div>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<motion.div layoutId="logo" className="relative">
|
||||
<LumeSymbol className="h-16 w-16 text-white" />
|
||||
</motion.div>
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<motion.h2
|
||||
layoutId="subtitle"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-4xl font-medium text-transparent"
|
||||
>
|
||||
A censorship-resistant social network
|
||||
</motion.h2>
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 bg-clip-text text-5xl font-bold text-transparent"
|
||||
>
|
||||
built on nostr
|
||||
</motion.h1>
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{/* dragging area */}
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 z-20 h-16 w-full bg-transparent" />
|
||||
{/* end dragging area */}
|
||||
<div className="relative flex h-full flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<LumeSymbol className="h-16 w-16 text-black dark:text-white" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">Did you know?</h3>
|
||||
<p className="font-medium text-zinc-300 dark:text-zinc-600">
|
||||
No one can't stop you use bitcoin and nostr
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-16">
|
||||
<div className="h-10">
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-fuchsia-400/10 to-orange-100/10 opacity-100 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)]">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="dark:fill-white/2.5 absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:stroke-white/5"
|
||||
>
|
||||
<defs>
|
||||
<pattern id=":R11d6:" width="72" height="56" patternUnits="userSpaceOnUse" x="-12" y="4">
|
||||
<path d="M.5 56V.5H72" fill="none"></path>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth="0" fill="url(#:R11d6:)"></rect>
|
||||
<svg x="-12" y="4" className="overflow-visible">
|
||||
<rect strokeWidth="0" width="73" height="57" x="288" y="168"></rect>
|
||||
<rect strokeWidth="0" width="73" height="57" x="144" y="56"></rect>
|
||||
<rect strokeWidth="0" width="73" height="57" x="504" y="168"></rect>
|
||||
<rect strokeWidth="0" width="73" height="57" x="720" y="336"></rect>
|
||||
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/* end background */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -110,9 +66,5 @@ Page.getLayout = function getLayout(
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<FullscreenLayout>{page}</FullscreenLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
return <BaseLayout>{page}</BaseLayout>;
|
||||
};
|
||||
|
135
src/pages/init.tsx
Normal file
135
src/pages/init.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { activeAccountAtom } from '@stores/account';
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { dateToUnix, hoursAgo } from '@utils/getDate';
|
||||
import { countTotalNotes, createCacheNote, getAllFollowsByID, getLastLoginTime } from '@utils/storage';
|
||||
import { pubkeyArray } from '@utils/transform';
|
||||
|
||||
import LumeSymbol from '@assets/icons/Lume';
|
||||
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactFragment,
|
||||
ReactPortal,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [activeAccount] = useAtom(activeAccountAtom);
|
||||
|
||||
const [done, setDone] = useState(false);
|
||||
const now = useRef(new Date());
|
||||
const unsubscribe = useRef(null);
|
||||
|
||||
const fetchData = useCallback(
|
||||
(since) => {
|
||||
getAllFollowsByID(activeAccount.id).then((follows) => {
|
||||
unsubscribe.current = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [1],
|
||||
authors: pubkeyArray(follows),
|
||||
since: dateToUnix(since),
|
||||
until: dateToUnix(now.current),
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event) => {
|
||||
// insert event to local database
|
||||
createCacheNote(event);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
setDone(true);
|
||||
},
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
[activeAccount.id, pool, relays]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!done) {
|
||||
countTotalNotes().then((count) => {
|
||||
if (count.total === 0) {
|
||||
fetchData(hoursAgo(24, now.current));
|
||||
} else {
|
||||
getLastLoginTime().then((time) => {
|
||||
const parseDate = new Date(time.setting_value);
|
||||
fetchData(parseDate);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
router.push('/newsfeed/following');
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe.current;
|
||||
};
|
||||
}, [activeAccount.id, done, pool, relays, router, fetchData]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{/* dragging area */}
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 z-20 h-16 w-full bg-transparent" />
|
||||
{/* end dragging area */}
|
||||
<div className="relative flex h-full flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<LumeSymbol className="h-16 w-16 text-black dark:text-white" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">Loading...</h3>
|
||||
<p className="font-medium text-zinc-300 dark:text-zinc-600">
|
||||
Keep calm and waiting, Lume is fetching event...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return <BaseLayout>{page}</BaseLayout>;
|
||||
};
|
74
src/pages/newsfeed/[id].tsx
Normal file
74
src/pages/newsfeed/[id].tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import WithSidebarLayout from '@layouts/withSidebar';
|
||||
|
||||
import FormComment from '@components/form/comment';
|
||||
import { NoteComment } from '@components/note/comment';
|
||||
import { NoteExtend } from '@components/note/extend';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { getAllCommentNotes, getNoteByID } from '@utils/storage';
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactFragment,
|
||||
ReactPortal,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const router = useRouter();
|
||||
const id = router.query.id || null;
|
||||
|
||||
const relays: any = useAtomValue(relaysAtom);
|
||||
|
||||
const [rootEvent, setRootEvent] = useState(null);
|
||||
const [comments, setComments] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
getNoteByID(id)
|
||||
.then((res) => {
|
||||
setRootEvent(res);
|
||||
getAllCommentNotes(id).then((res: any) => setComments(res));
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [id, pool, relays]);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide flex h-full flex-col gap-2 overflow-y-auto py-3">
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-3">
|
||||
{rootEvent && <NoteExtend event={rootEvent} />}
|
||||
</div>
|
||||
<div>
|
||||
<FormComment eventID={id} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{comments.length > 0 && comments.map((comment) => <NoteComment key={comment.id} event={comment} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<WithSidebarLayout>{page}</WithSidebarLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
@ -1,12 +1,12 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import NewsFeedLayout from '@layouts/newsfeed';
|
||||
import WithSidebarLayout from '@layouts/withSidebar';
|
||||
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<p>Global</p>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-sm text-zinc-400">Sorry, this feature under development, it will come in the next version</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -22,7 +22,7 @@ Page.getLayout = function getLayout(
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<NewsFeedLayout>{page}</NewsFeedLayout>
|
||||
<WithSidebarLayout>{page}</WithSidebarLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
|
@ -1,127 +1,48 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import NewsFeedLayout from '@layouts/newsfeed';
|
||||
import WithSidebarLayout from '@layouts/withSidebar';
|
||||
|
||||
import { DatabaseContext } from '@components/contexts/database';
|
||||
import { NoteConnector } from '@components/note/connector';
|
||||
import FormBase from '@components/form/base';
|
||||
import { NoteBase } from '@components/note/base';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
import { Repost } from '@components/note/repost';
|
||||
import { Single } from '@components/note/single';
|
||||
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
import { notesAtom } from '@stores/note';
|
||||
|
||||
import { ArrowUpIcon } from '@radix-ui/react-icons';
|
||||
import { writeStorage } from '@rehooks/local-storage';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useEffect, useRef } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom } from 'jotai';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, Suspense, useRef } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const { db }: any = useContext(DatabaseContext);
|
||||
const [data]: any = useAtom(notesAtom);
|
||||
const parentRef = useRef(null);
|
||||
|
||||
const [data, setData] = useState([]);
|
||||
const [parentReload, setParentReload] = useState(false);
|
||||
const [hasNewNote, setHasNewNote] = useState(false);
|
||||
|
||||
const now = useRef(new Date());
|
||||
const limit = useRef(30);
|
||||
const offset = useRef(0);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
offset.current += limit.current;
|
||||
// next query
|
||||
const result = await db.select(
|
||||
`SELECT * FROM
|
||||
cache_notes
|
||||
WHERE created_at <= ${dateToUnix(now.current)}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit.current} OFFSET ${offset.current}`
|
||||
);
|
||||
setData((data) => [...data, ...result]);
|
||||
}, [db]);
|
||||
|
||||
const loadNewest = useCallback(async () => {
|
||||
const result = await db.select(
|
||||
`SELECT * FROM
|
||||
cache_notes
|
||||
WHERE created_at > ${dateToUnix(now.current)}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit.current}`
|
||||
);
|
||||
setData((data) => [...result, ...data]);
|
||||
setHasNewNote(false);
|
||||
}, [db]);
|
||||
|
||||
const ItemContent = useCallback(
|
||||
(index: string | number) => {
|
||||
const event = data[index];
|
||||
if (event.content.includes('#[0]') && event.tags[0][0] == 'e') {
|
||||
// type: repost
|
||||
return <Repost root={event.tags} user={event.pubkey} />;
|
||||
} else {
|
||||
// type: default
|
||||
return <Single event={event} />;
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const getData = async () => {
|
||||
const result = await db.select(
|
||||
`SELECT * FROM cache_notes WHERE created_at <= ${dateToUnix(now.current)} ORDER BY created_at DESC LIMIT ${
|
||||
limit.current
|
||||
}`
|
||||
);
|
||||
if (result) {
|
||||
setData(result);
|
||||
writeStorage('settings', new Date());
|
||||
}
|
||||
};
|
||||
|
||||
getData().catch(console.error);
|
||||
}, [db, parentReload]);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: data.length,
|
||||
estimateSize: () => 500,
|
||||
getScrollElement: () => parentRef.current,
|
||||
getItemKey: (index) => data[index].id,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<NoteConnector setParentReload={setParentReload} setHasNewNote={setHasNewNote} currentDate={now.current} />
|
||||
{hasNewNote && (
|
||||
<div className="absolute top-16 left-1/2 z-50 -translate-x-1/2 transform">
|
||||
<button
|
||||
onClick={() => loadNewest()}
|
||||
className="inline-flex h-8 transform items-center justify-center gap-1 rounded-full bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 pl-3 pr-3.5 text-sm shadow-lg active:translate-y-1"
|
||||
>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
<span className="drop-shadow-md">Load newest</span>
|
||||
</button>
|
||||
<div ref={parentRef} className="scrollbar-hide h-full w-full overflow-y-auto" style={{ contain: 'strict' }}>
|
||||
<div className="relative">
|
||||
<FormBase />
|
||||
</div>
|
||||
<Suspense fallback={<Placeholder />}>
|
||||
<div>
|
||||
{items.length > 0 && (
|
||||
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
<div className="absolute top-0 left-0 w-full" style={{ transform: `translateY(${items[0].start}px)` }}>
|
||||
{items.map((virtualRow) => (
|
||||
<div key={virtualRow.key} data-index={virtualRow.index} ref={virtualizer.measureElement}>
|
||||
<NoteBase event={data[virtualRow.index]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Virtuoso
|
||||
data={data}
|
||||
itemContent={ItemContent}
|
||||
components={{
|
||||
EmptyPlaceholder: () => <Placeholder />,
|
||||
ScrollSeekPlaceholder: () => <Placeholder />,
|
||||
}}
|
||||
computeItemKey={computeItemKey}
|
||||
scrollSeekConfiguration={{
|
||||
enter: (velocity) => Math.abs(velocity) > 800,
|
||||
exit: (velocity) => Math.abs(velocity) < 500,
|
||||
}}
|
||||
endReached={loadMore}
|
||||
overscan={800}
|
||||
increaseViewportBy={1000}
|
||||
className="relative h-full w-full"
|
||||
style={{
|
||||
contain: 'strict',
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -137,7 +58,7 @@ Page.getLayout = function getLayout(
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<NewsFeedLayout>{page}</NewsFeedLayout>
|
||||
<WithSidebarLayout>{page}</WithSidebarLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
|
@ -1,25 +1,17 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import OnboardingLayout from '@layouts/onboarding';
|
||||
|
||||
import { DatabaseContext } from '@components/contexts/database';
|
||||
import { RelayContext } from '@components/contexts/relay';
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { createAccount } from '@utils/storage';
|
||||
|
||||
import { EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons';
|
||||
import { useLocalStorage, writeStorage } from '@rehooks/local-storage';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent } from 'nostr-tools';
|
||||
import {
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactFragment,
|
||||
ReactPortal,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useMemo, useState } from 'react';
|
||||
import { Config, names, uniqueNamesGenerator } from 'unique-names-generator';
|
||||
|
||||
const config: Config = {
|
||||
@ -28,12 +20,9 @@ const config: Config = {
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { db }: any = useContext(DatabaseContext);
|
||||
const relayPool: any = useContext(RelayContext);
|
||||
|
||||
const [relays] = useLocalStorage('relays');
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [type, setType] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@ -44,6 +33,30 @@ export default function Page() {
|
||||
const npub = nip19.npubEncode(pubKey);
|
||||
const nsec = nip19.nsecEncode(privKey);
|
||||
|
||||
// auto-generated profile metadata
|
||||
const metadata = useMemo(
|
||||
() => ({
|
||||
display_name: name,
|
||||
name: name,
|
||||
username: name.toLowerCase(),
|
||||
picture: 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89',
|
||||
}),
|
||||
[name]
|
||||
);
|
||||
|
||||
// build profile
|
||||
const data = useMemo(
|
||||
() => ({
|
||||
pubkey: pubKey,
|
||||
privkey: privKey,
|
||||
npub: npub,
|
||||
nsec: nsec,
|
||||
metadata: metadata,
|
||||
}),
|
||||
[metadata, npub, nsec, privKey, pubKey]
|
||||
);
|
||||
|
||||
// toggle privatek key
|
||||
const showPrivateKey = () => {
|
||||
if (type === 'password') {
|
||||
setType('text');
|
||||
@ -52,32 +65,13 @@ export default function Page() {
|
||||
}
|
||||
};
|
||||
|
||||
// auto-generated profile
|
||||
const data = useMemo(
|
||||
() => ({
|
||||
display_name: name,
|
||||
name: name,
|
||||
username: name.toLowerCase(),
|
||||
picture: 'https://bafybeidfsbrzqbvontmucteomoz2rkrxugu462l5hyhh6uioslkfzzs4oq.ipfs.w3s.link/avatar-11.png',
|
||||
banner: 'https://bafybeiacwit7hjmdefqggxqtgh6ht5dhth7ndptwn2msl5kpkodudsr7py.ipfs.w3s.link/banner-1.jpg',
|
||||
}),
|
||||
[name]
|
||||
);
|
||||
|
||||
const insertDB = useCallback(async () => {
|
||||
await db.execute(
|
||||
`INSERT INTO accounts (id, privkey, npub, nsec, metadata) VALUES ("${pubKey}", "${privKey}", "${npub}", "${nsec}", '${JSON.stringify(
|
||||
data
|
||||
)}')`
|
||||
);
|
||||
}, [data, db, npub, nsec, privKey, pubKey]);
|
||||
|
||||
const createAccount = async () => {
|
||||
// create account and broadcast to all relays
|
||||
const submit = () => {
|
||||
setLoading(true);
|
||||
|
||||
// build event
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
content: JSON.stringify(metadata),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0,
|
||||
pubkey: pubKey,
|
||||
@ -86,129 +80,113 @@ export default function Page() {
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privKey);
|
||||
|
||||
insertDB()
|
||||
// insert to database then broadcast
|
||||
createAccount(data)
|
||||
.then(() => {
|
||||
// publish to relays
|
||||
relayPool.publish(event, relays);
|
||||
// set currentUser in global state
|
||||
writeStorage('current-user', {
|
||||
metadata: JSON.stringify(data),
|
||||
npub: npub,
|
||||
privkey: privKey,
|
||||
pubkey: pubKey,
|
||||
pool.publish(event, relays);
|
||||
router.push({
|
||||
pathname: '/onboarding/create/step-2',
|
||||
query: { id: pubKey, privkey: privKey },
|
||||
});
|
||||
// redirect to pre-follow
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/onboarding/create/pre-follows');
|
||||
}, 1500);
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between px-8">
|
||||
<div>{/* spacer */}</div>
|
||||
<motion.div layoutId="form">
|
||||
<div className="mb-8 flex flex-col gap-3">
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent"
|
||||
>
|
||||
Create new key
|
||||
</motion.h1>
|
||||
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
|
||||
Lume will generate key with default profile for you, you can edit it later, and please store your key safely
|
||||
so you can restore your account or use other client
|
||||
</motion.h2>
|
||||
<div className="grid h-full w-full grid-rows-5">
|
||||
<div className="row-span-1 flex items-center justify-center">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent">
|
||||
Create new account
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Public Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="row-span-4">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Public Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Private Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
<button
|
||||
onClick={() => showPrivateKey()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{type === 'password' ? (
|
||||
<EyeClosedIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
|
||||
) : (
|
||||
<EyeOpenIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Private Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg border border-black/5 py-2.5 pl-3.5 pr-11 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
<button
|
||||
onClick={() => showPrivateKey()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{type === 'password' ? (
|
||||
<EyeClosedIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
|
||||
) : (
|
||||
<EyeOpenIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Default Profile (you can change it later)</label>
|
||||
<div className="relative max-w-sm shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div className="relative max-w-sm rounded-lg border border-black/5 px-3.5 py-4 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600">
|
||||
<div className="flex space-x-4">
|
||||
<div className="relative h-10 w-10 rounded-full">
|
||||
<Image className="inline-block rounded-full" src={data.picture} alt="" fill={true} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold">{data.display_name}</p>
|
||||
<p className="text-zinc-400">@{data.username}</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Default Profile (you can change it later)</label>
|
||||
<div className="relative w-full shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div className="relative w-full rounded-lg border border-black/5 px-3.5 py-4 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600">
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative h-11 w-11 rounded-md">
|
||||
<Image className="inline-block rounded-md" src={metadata.picture} alt="" fill={true} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 h-2 rounded bg-zinc-700"></div>
|
||||
<div className="col-span-1 h-2 rounded bg-zinc-700"></div>
|
||||
<div className="flex-1 space-y-2 py-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-semibold">{metadata.display_name}</p>
|
||||
<p className="text-zinc-400">@{metadata.username}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 h-2 rounded bg-zinc-700"></div>
|
||||
<div className="col-span-1 h-2 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
<div className="h-2 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
<div className="h-2 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div layoutId="action" className="pb-5">
|
||||
<div className="flex h-10 items-center">
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => createAccount()}
|
||||
className="transform rounded-lg border border-white/10 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium shadow-input shadow-black/5 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-50 dark:shadow-black/10"
|
||||
onClick={() => submit()}
|
||||
className="w-full transform rounded-lg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 px-3.5 py-2.5 font-medium text-zinc-800 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<span className="drop-shadow-lg">Continue →</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -222,9 +200,5 @@ Page.getLayout = function getLayout(
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<OnboardingLayout>{page}</OnboardingLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
return <BaseLayout>{page}</BaseLayout>;
|
||||
};
|
||||
|
@ -1,147 +0,0 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import OnboardingLayout from '@layouts/onboarding';
|
||||
|
||||
import { DatabaseContext } from '@components/contexts/database';
|
||||
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import data from '@assets/directory.json';
|
||||
import { CheckCircledIcon } from '@radix-ui/react-icons';
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useState } from 'react';
|
||||
|
||||
const shuffle = (arr: { name: string; avatar: string; npub: string }[]) => [...arr].sort(() => Math.random() - 0.5);
|
||||
|
||||
export default function Page() {
|
||||
const { db }: any = useContext(DatabaseContext);
|
||||
const router = useRouter();
|
||||
|
||||
const [follow, setFollow] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list] = useState(shuffle(data));
|
||||
const [currentUser]: any = useLocalStorage('current-user');
|
||||
|
||||
const followUser = (e) => {
|
||||
const npub = e.currentTarget.getAttribute('data-npub');
|
||||
setFollow((arr) => [...arr, npub]);
|
||||
};
|
||||
|
||||
const insertDB = async () => {
|
||||
// self follow
|
||||
await db.execute(
|
||||
`INSERT INTO follows (pubkey, account, kind) VALUES ("${currentUser.id}", "${currentUser.id}", "0")`
|
||||
);
|
||||
// follow selected
|
||||
follow.forEach(async (npub) => {
|
||||
const { data } = nip19.decode(npub);
|
||||
await db.execute(`INSERT INTO follows (pubkey, account, kind) VALUES ("${data}", "${currentUser.id}", "0")`);
|
||||
});
|
||||
};
|
||||
|
||||
const createFollowing = async () => {
|
||||
setLoading(true);
|
||||
|
||||
insertDB().then(() =>
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/');
|
||||
}, 1500)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between px-8">
|
||||
<div>{/* spacer */}</div>
|
||||
<motion.div layoutId="form" className="flex flex-col">
|
||||
<div className="mb-8 flex flex-col gap-3">
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent"
|
||||
>
|
||||
Choose 10 people you want to following
|
||||
</motion.h1>
|
||||
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
|
||||
For better experiences, you should follow the people you care about to personalize your newsfeed, otherwise
|
||||
you will be very bored
|
||||
</motion.h2>
|
||||
</div>
|
||||
<div className="h-full w-full shrink">
|
||||
<div className="scrollbar-hide grid grid-cols-3 gap-4 overflow-y-auto">
|
||||
{list.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={(e) => followUser(e)}
|
||||
data-npub={item.npub}
|
||||
className={`col-span-1 inline-flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-zinc-700 ${
|
||||
follow.includes(item.npub) ? 'bg-zinc-800' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image className="rounded-full object-cover" src={item.avatar} alt={item.name} fill={true} />
|
||||
</div>
|
||||
<div className="inline-flex flex-1 items-center justify-between">
|
||||
<div>
|
||||
<p className="truncate text-sm font-medium text-zinc-200">{item.name}</p>
|
||||
<p className="text-sm leading-tight text-zinc-500">{truncate(item.npub, 16, ' .... ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
{follow.includes(item.npub) ? <CheckCircledIcon className="h-4 w-4 text-green-500" /> : <></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div layoutId="action" className="pb-5">
|
||||
<div className="flex h-10 items-center">
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<button
|
||||
onClick={() => createFollowing()}
|
||||
disabled={follow.length < 10 ? true : false}
|
||||
className="transform rounded-lg border border-white/10 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium shadow-input shadow-black/5 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-50 dark:shadow-black/10"
|
||||
>
|
||||
<span className="drop-shadow-lg">Finish →</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<OnboardingLayout>{page}</OnboardingLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
203
src/pages/onboarding/create/step-2.tsx
Normal file
203
src/pages/onboarding/create/step-2.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
import { UserBase } from '@components/user/base';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { createFollows } from '@utils/storage';
|
||||
|
||||
import { CheckCircledIcon } from '@radix-ui/react-icons';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import {
|
||||
JSXElementConstructor,
|
||||
Key,
|
||||
ReactElement,
|
||||
ReactFragment,
|
||||
ReactPortal,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
const supabase = createClient(
|
||||
'https://niwaazauwnrwiwmnocnn.supabase.co',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5pd2FhemF1d25yd2l3bW5vY25uIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NzYwMjAzMjAsImV4cCI6MTk5MTU5NjMyMH0.IbjrnE6rDgC6lhIAHBIMN4niM2bPjxkRLtvAy_gFgqw'
|
||||
);
|
||||
|
||||
const initialList = [
|
||||
{ pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2' },
|
||||
{ pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98' },
|
||||
{ pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9' },
|
||||
{ pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0' },
|
||||
{ pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93' },
|
||||
{ pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411' },
|
||||
{ pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
|
||||
{ pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15' },
|
||||
{ pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42' },
|
||||
{ pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240' },
|
||||
{ pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898' },
|
||||
{ pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce' },
|
||||
{ pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0' },
|
||||
{ pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965' },
|
||||
{ pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6' },
|
||||
{ pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3' },
|
||||
{ pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63' },
|
||||
{ pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594' },
|
||||
{ pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c' },
|
||||
{ pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884' },
|
||||
{ pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24' },
|
||||
{ pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f' },
|
||||
{ pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479' },
|
||||
{ pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f' },
|
||||
{ pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b' },
|
||||
{ pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5' },
|
||||
{ pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c' },
|
||||
{ pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a' },
|
||||
{ pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27' },
|
||||
{ pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2' },
|
||||
{ pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14' },
|
||||
{ pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609' },
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const router = useRouter();
|
||||
const { id, privkey }: any = router.query || '';
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList]: any = useState(initialList);
|
||||
const [follows, setFollows] = useState([]);
|
||||
|
||||
// toggle follow state
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
const arr = follows.includes(pubkey) ? follows.filter((i) => i !== pubkey) : [...follows, pubkey];
|
||||
setFollows(arr);
|
||||
};
|
||||
|
||||
// build event tags
|
||||
const tags = () => {
|
||||
const arr = [];
|
||||
// push item to tags
|
||||
follows.forEach((item) => {
|
||||
arr.push(['p', item]);
|
||||
});
|
||||
return arr;
|
||||
};
|
||||
|
||||
// save follows to database then broadcast
|
||||
const submit = () => {
|
||||
setLoading(true);
|
||||
|
||||
// build event
|
||||
const event: any = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 3,
|
||||
pubkey: id,
|
||||
tags: tags(),
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
createFollows(follows, id, 0)
|
||||
.then((res) => {
|
||||
if (res === 'ok') {
|
||||
// publish to relays
|
||||
pool.publish(event, relays);
|
||||
router.push('/init');
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const { data } = await supabase.from('random_users').select('pubkey').limit(28);
|
||||
// update state
|
||||
setList((list: any) => [...list, ...data]);
|
||||
};
|
||||
|
||||
fetchData().catch(console.error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative grid h-full w-full grid-rows-5">
|
||||
<div className="row-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium leading-tight text-transparent">
|
||||
Personalized your newsfeed
|
||||
</h1>
|
||||
<h3 className="text-lg text-zinc-500">
|
||||
Follow at least{' '}
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
{follows.length}/10
|
||||
</span>{' '}
|
||||
plebs
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scrollbar-hide row-span-4 h-full w-full overflow-y-auto">
|
||||
<div className="grid grid-cols-4 gap-4 px-8 py-4">
|
||||
{list.map((item: { pubkey: string }, index: Key) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="flex transform items-center justify-between rounded-lg bg-zinc-900 p-2 ring-amber-100 hover:ring-1 active:translate-y-1"
|
||||
>
|
||||
<UserBase pubkey={item.pubkey} />
|
||||
{follows.includes(item.pubkey) && (
|
||||
<div>
|
||||
<CheckCircledIcon className="h-4 w-4 text-green-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{follows.length >= 10 && (
|
||||
<div className="fixed bottom-0 left-0 z-10 flex h-24 w-full items-center justify-center">
|
||||
<button
|
||||
onClick={() => submit()}
|
||||
className="relative z-20 inline-flex w-36 transform items-center justify-center rounded-full bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 px-3.5 py-2.5 font-medium text-zinc-800 shadow-xl active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-zinc-900"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="drop-shadow-lg">Done →</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return <BaseLayout>{page}</BaseLayout>;
|
||||
};
|
@ -1,47 +1,130 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import OnboardingLayout from '@layouts/onboarding';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRightIcon } from '@radix-ui/react-icons';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
|
||||
|
||||
const PLEBS = [
|
||||
'https://133332.xyz/p.jpg',
|
||||
'https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp',
|
||||
'https://i.imgur.com/f8SyhRL.jpg',
|
||||
'http://nostr.build/i/6369.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg',
|
||||
'https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif',
|
||||
'https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg',
|
||||
'https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg',
|
||||
'https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp',
|
||||
'https://avatars.githubusercontent.com/u/89577423',
|
||||
'https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg',
|
||||
'https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy',
|
||||
'https://phase1.attract-eu.com/wp-content/uploads/2020/03/ATTRACT_HPLM.png',
|
||||
'https://www.retro-synthwave.com/wp-content/uploads/2017/01/PowerGlove-23.jpg',
|
||||
'https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp',
|
||||
'https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif',
|
||||
'https://i.imgur.com/VGpUNFS.jpg',
|
||||
'https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg',
|
||||
'https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg',
|
||||
'https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg',
|
||||
'https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg',
|
||||
'https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png',
|
||||
'https://pbs.twimg.com/profile_images/1569570198348337152/-n1KD74u_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1600149653898596354/5PVe-r-J_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1639659216372658178/Dnn-Ysp-_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1554429112978120706/yr1hXl6R_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1615478486688272385/q2ECeZDX_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1638644441773748226/tNsA6RpG_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1607882836740120576/3Tg1mTYJ_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1401907430339002369/WKrP9Esn_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1523971278478131200/TMPzfvhE_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1626421539884204032/aj4tmzsk_400x400.png',
|
||||
'https://pbs.twimg.com/profile_images/1582771691779985408/C9MHYIgt_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1409612480465276931/38Vyx4e8_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1549826566787588098/MlduJCZO_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q_400x400.jpeg',
|
||||
'https://pbs.twimg.com/profile_images/1548660003522887682/1QMHmles_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1362497143999787013/KLUoN1Vn_400x400.png',
|
||||
'https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg',
|
||||
];
|
||||
|
||||
const DURATION = 50000;
|
||||
const ROWS = 7;
|
||||
const PLEBS_PER_ROW = 20;
|
||||
|
||||
const random = (min, max) => Math.floor(Math.random() * (max - min)) + min;
|
||||
const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random());
|
||||
|
||||
const InfiniteLoopSlider = ({ children, duration, reverse }: { children: any; duration: any; reverse: any }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex w-fit"
|
||||
style={{
|
||||
animationName: 'loop',
|
||||
animationIterationCount: 'infinite',
|
||||
animationDirection: reverse ? 'reverse' : 'normal',
|
||||
animationDuration: duration + 'ms',
|
||||
animationTimingFunction: 'linear',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between px-8">
|
||||
<div>{/* spacer */}</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent"
|
||||
>
|
||||
Other social network require email/password
|
||||
<br />
|
||||
nostr use{' '}
|
||||
<span className="bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 bg-clip-text text-transparent">
|
||||
public/private key instead
|
||||
</span>
|
||||
</motion.h1>
|
||||
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
|
||||
If you have used nostr before, you can import your own private key. Otherwise, you can create a new key or use
|
||||
auto-generated account created by system.
|
||||
</motion.h2>
|
||||
<motion.div layoutId="form"></motion.div>
|
||||
<motion.div layoutId="action" className="mt-4 flex gap-2">
|
||||
<div className="grid h-full w-full grid-rows-5">
|
||||
<div className="row-span-3 overflow-hidden">
|
||||
<div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
|
||||
{[...new Array(ROWS)].map((_, i) => (
|
||||
<InfiniteLoopSlider key={i} duration={random(DURATION - 5000, DURATION + 20000)} reverse={i % 2}>
|
||||
{shuffle(PLEBS)
|
||||
.slice(0, PLEBS_PER_ROW)
|
||||
.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="relative mr-4 flex h-11 w-11 items-center gap-2 rounded-md bg-zinc-900 px-4 py-1.5 shadow-xl"
|
||||
>
|
||||
<Image
|
||||
src={tag}
|
||||
alt={tag}
|
||||
fill={true}
|
||||
className="rounded-md border border-zinc-900"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</InfiniteLoopSlider>
|
||||
))}
|
||||
<div className="pointer-events-none absolute inset-0 bg-fade" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-span-2 flex w-full flex-col items-center gap-8 overflow-hidden pt-10">
|
||||
<h1 className="animate-moveBg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-5xl font-bold leading-none text-transparent">
|
||||
Let's start!
|
||||
</h1>
|
||||
<div className="mt-4 flex flex-col items-center gap-1.5">
|
||||
<Link
|
||||
href="/onboarding/create"
|
||||
className="hover:bg-zinc-900/2.5 transform rounded-lg border border-black/5 bg-zinc-800 px-3.5 py-2 font-medium ring-1 ring-inset ring-zinc-900/10 hover:text-zinc-900 active:translate-y-1 dark:text-zinc-300 dark:ring-white/10 dark:hover:bg-zinc-700 dark:hover:text-white"
|
||||
className="relative inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full bg-zinc-900 px-6 text-lg font-medium ring-1 ring-zinc-800 hover:bg-zinc-800"
|
||||
>
|
||||
Create new key
|
||||
<ArrowRightIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/onboarding/login"
|
||||
className="hover:bg-zinc-900/2.5 transform rounded-lg border border-black/5 bg-zinc-800 px-3.5 py-2 font-medium ring-1 ring-inset ring-zinc-900/10 hover:text-zinc-900 active:translate-y-1 dark:text-zinc-300 dark:ring-white/10 dark:hover:bg-zinc-700 dark:hover:text-white"
|
||||
className="inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full px-6 text-base font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Login with private key
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<div>{/* spacer */}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -55,9 +138,5 @@ Page.getLayout = function getLayout(
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<OnboardingLayout>{page}</OnboardingLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
return <BaseLayout>{page}</BaseLayout>;
|
||||
};
|
||||
|
@ -1,149 +0,0 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import OnboardingLayout from '@layouts/onboarding';
|
||||
|
||||
import { DatabaseContext } from '@components/contexts/database';
|
||||
import { RelayContext } from '@components/contexts/relay';
|
||||
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import {
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactFragment,
|
||||
ReactPortal,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const { db }: any = useContext(DatabaseContext);
|
||||
const relayPool: any = useContext(RelayContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [relays] = useLocalStorage('relays');
|
||||
|
||||
const router = useRouter();
|
||||
const { privkey }: any = router.query;
|
||||
|
||||
const pubkey = useMemo(() => (privkey ? getPublicKey(privkey) : null), [privkey]);
|
||||
|
||||
// save account to database
|
||||
const insertAccount = useCallback(
|
||||
async (metadata) => {
|
||||
if (loading === false) {
|
||||
const npub = privkey ? nip19.npubEncode(pubkey) : null;
|
||||
const nsec = privkey ? nip19.nsecEncode(privkey) : null;
|
||||
await db.execute(
|
||||
`INSERT OR IGNORE INTO accounts (id, privkey, npub, nsec, metadata) VALUES ("${pubkey}", "${privkey}", "${npub}", "${nsec}", '${metadata}')`
|
||||
);
|
||||
setLoading(true);
|
||||
}
|
||||
},
|
||||
[db, privkey, pubkey, loading]
|
||||
);
|
||||
|
||||
// save follows to database
|
||||
const insertFollows = useCallback(
|
||||
async (follows) => {
|
||||
follows.forEach(async (item) => {
|
||||
if (item) {
|
||||
await db.execute(
|
||||
`INSERT OR IGNORE INTO follows (pubkey, account, kind) VALUES ("${item[1]}", "${pubkey}", "0")`
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
[db, pubkey]
|
||||
);
|
||||
|
||||
relayPool.subscribe(
|
||||
[
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 3],
|
||||
since: 0,
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
if (event.kind === 0) {
|
||||
insertAccount(event.content);
|
||||
} else {
|
||||
if (event.tags.length > 0) {
|
||||
insertFollows(event.tags);
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
(events: any, relayURL: any) => {
|
||||
console.log(events, relayURL);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between px-8">
|
||||
<div>{/* spacer */}</div>
|
||||
<motion.div layoutId="form">
|
||||
<div className="mb-8 flex flex-col gap-3">
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent"
|
||||
>
|
||||
Fetching your profile...
|
||||
</motion.h1>
|
||||
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
|
||||
As long as you have private key, you alway can sync your profile and follows list on every nostr client, so
|
||||
please keep your key safely
|
||||
</motion.h2>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div layoutId="action" className="pb-5">
|
||||
<div className="flex h-10 items-center">
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<Link
|
||||
href="/"
|
||||
className="transform rounded-lg bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<span className="drop-shadow-lg">Finish</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<OnboardingLayout>{page}</OnboardingLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
@ -1,7 +1,6 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import OnboardingLayout from '@layouts/onboarding';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { LightningBoltIcon } from '@radix-ui/react-icons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
|
||||
@ -43,7 +42,7 @@ export default function Page() {
|
||||
|
||||
try {
|
||||
router.push({
|
||||
pathname: '/onboarding/login/fetch',
|
||||
pathname: '/onboarding/login/step-2',
|
||||
query: { privkey: privkey },
|
||||
});
|
||||
} catch (error) {
|
||||
@ -55,60 +54,72 @@ export default function Page() {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col justify-between px-8">
|
||||
<div>{/* spacer */}</div>
|
||||
<motion.div layoutId="form">
|
||||
<div className="mb-8 flex flex-col gap-3">
|
||||
<motion.h1
|
||||
layoutId="title"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent"
|
||||
>
|
||||
Import your private key
|
||||
</motion.h1>
|
||||
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
|
||||
You can import private key format as hex string or nsec. If you have installed Nostr Connect compality
|
||||
wallet in your mobile, you can connect by scan QR Code below
|
||||
</motion.h2>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="grid h-full w-full grid-rows-5">
|
||||
<div className="row-span-1 flex items-center justify-center">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-br from-zinc-200 via-white to-zinc-300 bg-clip-text text-3xl font-semibold text-transparent">
|
||||
Login with Private Key
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
{...register('key', { required: true, minLength: 32 })}
|
||||
type={'password'}
|
||||
placeholder="Paste nsec or hex key here..."
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="row-span-4">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
{/* #TODO: add function */}
|
||||
<button className="inline-flex w-full transform items-center justify-center gap-1.5 rounded-lg bg-zinc-700 px-3.5 py-2.5 font-medium text-zinc-200 shadow-input ring-1 ring-zinc-600 active:translate-y-1">
|
||||
{/* #TODO: change to nostr connect logo */}
|
||||
<LightningBoltIcon className="h-5 w-5 text-fuchsia-500" />
|
||||
<span>Continue with Nostr Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-zinc-800"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-black px-2 text-sm text-zinc-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
{...register('key', { required: true, minLength: 32 })}
|
||||
type={'password'}
|
||||
placeholder="Paste private key here..."
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 text-center shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{errors.key && <p>{errors.key.message}</p>}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex h-10 items-center justify-center">
|
||||
{isSubmitting ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="w-full transform rounded-lg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 px-3.5 py-2.5 font-medium text-zinc-800 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<span className="drop-shadow-lg">Continue →</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{errors.key && <p>{errors.key.message}</p>}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div layoutId="action" className="pb-5">
|
||||
<div className="flex h-10 items-center">
|
||||
{isSubmitting ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="transform rounded-lg bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<span className="drop-shadow-lg">Continue →</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -122,9 +133,5 @@ Page.getLayout = function getLayout(
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<OnboardingLayout>{page}</OnboardingLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
return <BaseLayout>{page}</BaseLayout>;
|
||||
};
|
||||
|
158
src/pages/onboarding/login/step-2.tsx
Normal file
158
src/pages/onboarding/login/step-2.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
|
||||
import { RelayContext } from '@components/relaysProvider';
|
||||
|
||||
import { relaysAtom } from '@stores/relays';
|
||||
|
||||
import { createAccount, createFollows } from '@utils/storage';
|
||||
import { tagsToArray } from '@utils/transform';
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import destr from 'destr';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import {
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactFragment,
|
||||
ReactPortal,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const router = useRouter();
|
||||
const privkey: any = router.query.privkey || null;
|
||||
const pubkey = privkey ? getPublicKey(privkey) : null;
|
||||
|
||||
const relays = useAtomValue(relaysAtom);
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 3],
|
||||
since: 0,
|
||||
},
|
||||
],
|
||||
relays,
|
||||
(event: any) => {
|
||||
if (event.kind === 0) {
|
||||
const data = {
|
||||
pubkey: pubkey,
|
||||
privkey: privkey,
|
||||
npub: nip19.npubEncode(pubkey),
|
||||
nsec: nip19.nsecEncode(privkey),
|
||||
metadata: event.content,
|
||||
};
|
||||
setProfile(destr(event.content));
|
||||
createAccount(data);
|
||||
} else {
|
||||
if (event.tags.length > 0) {
|
||||
createFollows(tagsToArray(event.tags), pubkey, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
setDone(true);
|
||||
},
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe;
|
||||
};
|
||||
}, [pool, privkey, pubkey, relays]);
|
||||
|
||||
// submit then redirect to home
|
||||
const submit = () => {
|
||||
router.push('/init');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-5">
|
||||
<div className="row-span-1 flex items-center justify-center">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent">
|
||||
Bringing back your profile...
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-span-4 flex flex-col gap-8">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
<div className="w-full rounded-lg bg-zinc-900 p-4 shadow-input ring-1 ring-zinc-800">
|
||||
<div className="flex space-x-4">
|
||||
<div className="relative h-10 w-10 rounded-full">
|
||||
<Image className="inline-block rounded-full" src={profile?.picture} alt="" fill={true} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold">{profile?.display_name || profile?.name}</p>
|
||||
<span className="leading-tight text-zinc-500">·</span>
|
||||
<p className="text-zinc-500">@{profile?.username || (pubkey && truncate(pubkey, 16, ' .... '))}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 h-2 rounded bg-zinc-700"></div>
|
||||
<div className="col-span-1 h-2 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
<div className="h-2 rounded bg-zinc-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
{done === false ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => submit()}
|
||||
className="inline-flex w-full transform items-center justify-center rounded-lg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 px-3.5 py-2.5 font-medium text-zinc-800 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<span className="drop-shadow-lg">Done →</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return <BaseLayout>{page}</BaseLayout>;
|
||||
};
|
@ -1,19 +0,0 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return <BaseLayout>{page}</BaseLayout>;
|
||||
};
|
@ -1,227 +0,0 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import UserLayout from '@layouts/user';
|
||||
|
||||
import { RelayContext } from '@components/contexts/relay';
|
||||
|
||||
import { dateToUnix } from '@utils/getDate';
|
||||
|
||||
import { useLocalStorage } from '@rehooks/local-storage';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
type FormValues = {
|
||||
display_name: string;
|
||||
name: string;
|
||||
username: string;
|
||||
picture: string;
|
||||
banner: string;
|
||||
about: string;
|
||||
website: string;
|
||||
};
|
||||
|
||||
// TODO: update the design
|
||||
export default function Page() {
|
||||
const relayPool: any = useContext(RelayContext);
|
||||
const [relays]: any = useLocalStorage('relays');
|
||||
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [currentUser]: any = useLocalStorage('current-user');
|
||||
const profile =
|
||||
currentUser.metadata !== undefined ? JSON.parse(currentUser.metadata) : { display_name: null, username: null };
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid },
|
||||
} = useForm<FormValues>();
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
setLoading(true);
|
||||
|
||||
// publish account to relays
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 0,
|
||||
pubkey: currentUser.id,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, currentUser.privkey);
|
||||
|
||||
relayPool.publish(event, relays);
|
||||
|
||||
// save account to database
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
await db.execute(`UPDATE accounts SET metadata = '${JSON.stringify(data)}' WHERE pubkey = "${currentUser.id}"`);
|
||||
|
||||
// set currentUser in global state
|
||||
currentUser.set({
|
||||
metadata: JSON.stringify(data),
|
||||
npub: currentUser.npub,
|
||||
privkey: currentUser.privkey,
|
||||
pubkey: currentUser.id,
|
||||
});
|
||||
|
||||
// redirect to newsfeed
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.reload();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full w-full flex-col justify-between px-6">
|
||||
<div className="mb-8 flex flex-col gap-3 pt-8">
|
||||
<h1 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent">
|
||||
Update profile
|
||||
</h1>
|
||||
<h2 className="w-3/4 text-zinc-400">
|
||||
Your profile will be published to all relays, as long as you have the private key, you always can recover your
|
||||
profile in any client
|
||||
</h2>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-4">
|
||||
<div className="col-span-1">
|
||||
<label className="text-zinc-300">Display Name</label>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-2">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
{...register('display_name')}
|
||||
defaultValue={profile.display_name || ''}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{errors.display_name && <p>{errors.display_name.message}</p>}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4">
|
||||
<div className="col-span-1">
|
||||
<label className="text-zinc-300">Name</label>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-2">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
{...register('name')}
|
||||
defaultValue={profile.name || ''}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{errors.name && <p>{errors.name.message}</p>}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4">
|
||||
<div className="col-span-1">
|
||||
<label className="text-zinc-300">Username</label>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-2">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
{...register('username')}
|
||||
defaultValue={profile.username || ''}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{errors.username && <p>{errors.username.message}</p>}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4">
|
||||
<div className="col-span-1">
|
||||
<label className="text-zinc-300">Profile Picture</label>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-2">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
{...register('picture')}
|
||||
defaultValue={profile.picture || ''}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{errors.picture && <p>{errors.picture.message}</p>}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4">
|
||||
<div className="col-span-1">
|
||||
<label className="text-zinc-300">Banner</label>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-2">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
{...register('banner')}
|
||||
defaultValue={profile.banner || ''}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{errors.banner && <p>{errors.banner.message}</p>}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4">
|
||||
<div className="col-span-1">
|
||||
<label className="text-zinc-300">About</label>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-2">
|
||||
<div className="relative h-24 shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<textarea
|
||||
{...register('about')}
|
||||
defaultValue={profile.about || ''}
|
||||
className="relative h-24 w-full resize-none rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{errors.about && <p>{errors.about.message}</p>}</span>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="pb-5">
|
||||
<div className="flex h-10 items-center">
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="transform rounded-lg bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<span className="drop-shadow-lg">Update</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<UserLayout>{page}</UserLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
69
src/pages/users/[id].tsx
Normal file
69
src/pages/users/[id].tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import BaseLayout from '@layouts/base';
|
||||
import WithSidebarLayout from '@layouts/withSidebar';
|
||||
|
||||
import ProfileFollowers from '@components/profile/followers';
|
||||
import ProfileFollows from '@components/profile/follows';
|
||||
import ProfileMetadata from '@components/profile/metadata';
|
||||
import ProfileNotes from '@components/profile/notes';
|
||||
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const id: any = router.query.id || '';
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide h-full w-full overflow-y-auto">
|
||||
<ProfileMetadata id={id} />
|
||||
<Tabs.Root className="flex w-full flex-col" defaultValue="notes">
|
||||
<Tabs.List className="flex border-b border-zinc-800">
|
||||
<Tabs.Trigger
|
||||
className="flex h-10 flex-1 cursor-default select-none items-center justify-center px-5 text-sm font-medium leading-none text-zinc-400 outline-none hover:text-fuchsia-400 data-[state=active]:text-fuchsia-500 data-[state=active]:shadow-[inset_0_-1px_0_0,0_1px_0_0] data-[state=active]:shadow-current"
|
||||
value="notes"
|
||||
>
|
||||
Notes
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className="flex h-10 flex-1 cursor-default select-none items-center justify-center px-5 text-sm font-medium text-zinc-400 outline-none placeholder:leading-none hover:text-fuchsia-400 data-[state=active]:text-fuchsia-500 data-[state=active]:shadow-[inset_0_-1px_0_0,0_1px_0_0] data-[state=active]:shadow-current"
|
||||
value="followers"
|
||||
>
|
||||
Followers
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className="flex h-10 flex-1 cursor-default select-none items-center justify-center px-5 text-sm font-medium leading-none text-zinc-400 outline-none hover:text-fuchsia-400 data-[state=active]:text-fuchsia-500 data-[state=active]:shadow-[inset_0_-1px_0_0,0_1px_0_0] data-[state=active]:shadow-current"
|
||||
value="following"
|
||||
>
|
||||
Following
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="notes">
|
||||
<ProfileNotes id={id} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="followers">
|
||||
<ProfileFollowers id={id} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="following">
|
||||
<ProfileFollows id={id} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Page.getLayout = function getLayout(
|
||||
page:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
|
||||
| ReactFragment
|
||||
| ReactPortal
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<WithSidebarLayout>{page}</WithSidebarLayout>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
9
src/stores/account.tsx
Normal file
9
src/stores/account.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { isSSR } from '@utils/ssr';
|
||||
import { getActiveAccount } from '@utils/storage';
|
||||
|
||||
import { atomWithCache } from 'jotai-cache';
|
||||
|
||||
export const activeAccountAtom = atomWithCache(async () => {
|
||||
const response = isSSR ? {} : await getActiveAccount();
|
||||
return response;
|
||||
});
|
24
src/stores/note.tsx
Normal file
24
src/stores/note.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { isSSR } from '@utils/ssr';
|
||||
import { getAllNotes } from '@utils/storage';
|
||||
|
||||
import { atom } from 'jotai';
|
||||
import { atomsWithQuery } from 'jotai-tanstack-query';
|
||||
import { atomWithReset } from 'jotai/utils';
|
||||
|
||||
// note content
|
||||
export const noteContentAtom = atomWithReset('');
|
||||
// notify user that connector has receive newer note
|
||||
export const hasNewerNoteAtom = atom(false);
|
||||
// query notes from database
|
||||
export const [notesAtom] = atomsWithQuery(() => ({
|
||||
queryKey: ['notes'],
|
||||
queryFn: async ({ queryKey: [] }) => {
|
||||
const res = isSSR ? [] : await getAllNotes();
|
||||
return res;
|
||||
},
|
||||
refetchInterval: 1000000,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
keepPreviousData: false,
|
||||
}));
|
9
src/stores/relays.tsx
Normal file
9
src/stores/relays.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { isSSR } from '@utils/ssr';
|
||||
import { getAllRelays } from '@utils/storage';
|
||||
|
||||
import { atomWithCache } from 'jotai-cache';
|
||||
|
||||
export const relaysAtom = atomWithCache(async () => {
|
||||
const response = isSSR ? [] : await getAllRelays();
|
||||
return response;
|
||||
});
|
1
src/utils/ssr.tsx
Normal file
1
src/utils/ssr.tsx
Normal file
@ -0,0 +1 @@
|
||||
export const isSSR = typeof window === 'undefined';
|
165
src/utils/storage.tsx
Normal file
165
src/utils/storage.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { getParentID } from '@utils/transform';
|
||||
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
let db: null | Database = null;
|
||||
|
||||
// connect database (sqlite)
|
||||
// path: tauri::api::path::BaseDirectory::App
|
||||
export async function connect(): Promise<Database> {
|
||||
if (db) {
|
||||
return db;
|
||||
}
|
||||
db = await Database.load('sqlite:lume.db');
|
||||
return db;
|
||||
}
|
||||
|
||||
// get all relays
|
||||
export async function getAllRelays() {
|
||||
const db = await connect();
|
||||
const result: any = await db.select('SELECT relay_url FROM relays WHERE relay_status = "1";');
|
||||
return result.reduce((relays, { relay_url }) => {
|
||||
relays.push(relay_url);
|
||||
return relays;
|
||||
}, []);
|
||||
}
|
||||
|
||||
// get active account
|
||||
export async function getActiveAccount() {
|
||||
const db = await connect();
|
||||
const result = await db.select(`SELECT * FROM accounts LIMIT 1;`);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// get all accounts
|
||||
export async function getAccounts() {
|
||||
const db = await connect();
|
||||
return await db.select(`SELECT * FROM accounts`);
|
||||
}
|
||||
|
||||
// get all follows by account id
|
||||
export async function getAllFollowsByID(id) {
|
||||
const db = await connect();
|
||||
return await db.select(`SELECT pubkey FROM follows WHERE account = "${id}";`);
|
||||
}
|
||||
|
||||
// create account
|
||||
export async function createAccount(data) {
|
||||
const db = await connect();
|
||||
return await db.execute(
|
||||
'INSERT OR IGNORE INTO accounts (id, privkey, npub, nsec, metadata) VALUES (?, ?, ?, ?, ?);',
|
||||
[data.pubkey, data.privkey, data.npub, data.nsec, data.metadata]
|
||||
);
|
||||
}
|
||||
|
||||
// create follow
|
||||
export async function createFollow(pubkey, account, kind) {
|
||||
const db = await connect();
|
||||
return await db.execute('INSERT OR IGNORE INTO follows (pubkey, account, kind) VALUES (?, ?, ?);', [
|
||||
pubkey,
|
||||
account,
|
||||
kind || 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// create follow
|
||||
export async function createFollows(data, account, kind) {
|
||||
const db = await connect();
|
||||
data.forEach(async (item) => {
|
||||
await db.execute('INSERT OR IGNORE INTO follows (pubkey, account, kind) VALUES (?, ?, ?);', [
|
||||
item,
|
||||
account,
|
||||
kind || 0,
|
||||
]);
|
||||
});
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
// create cache profile
|
||||
export async function createCacheProfile(id, metadata) {
|
||||
const db = await connect();
|
||||
return await db.execute('INSERT OR IGNORE INTO cache_profiles (id, metadata) VALUES (?, ?);', [id, metadata]);
|
||||
}
|
||||
|
||||
// get cache profile
|
||||
export async function getCacheProfile(id) {
|
||||
const db = await connect();
|
||||
const result = await db.select(`SELECT metadata FROM cache_profiles WHERE id = "${id}"`);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// get all notes
|
||||
export async function getAllNotes() {
|
||||
const db = await connect();
|
||||
return await db.select(`SELECT * FROM cache_notes GROUP BY parent_id ORDER BY created_at DESC LIMIT 500`);
|
||||
}
|
||||
|
||||
// get note by id
|
||||
export async function getNoteByID(id) {
|
||||
const db = await connect();
|
||||
const result = await db.select(`SELECT * FROM cache_notes WHERE id = "${id}"`);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// create cache note
|
||||
export async function createCacheNote(data) {
|
||||
const db = await connect();
|
||||
return await db.execute(
|
||||
'INSERT OR IGNORE INTO cache_notes (id, pubkey, created_at, kind, content, tags, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?);',
|
||||
[
|
||||
data.id,
|
||||
data.pubkey,
|
||||
data.created_at,
|
||||
data.kind,
|
||||
data.content,
|
||||
JSON.stringify(data.tags),
|
||||
getParentID(data.tags, data.id),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// get all comment notes
|
||||
export async function getAllCommentNotes(eid) {
|
||||
const db = await connect();
|
||||
return await db.select(
|
||||
`SELECT * FROM cache_notes WHERE parent_comment_id = "${eid}" ORDER BY created_at DESC LIMIT 500`
|
||||
);
|
||||
}
|
||||
|
||||
// create cache comment note
|
||||
export async function createCacheCommentNote(data, eid) {
|
||||
const db = await connect();
|
||||
return await db.execute(
|
||||
'INSERT OR IGNORE INTO cache_notes (id, pubkey, created_at, kind, content, tags, parent_id, parent_comment_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?);',
|
||||
[
|
||||
data.id,
|
||||
data.pubkey,
|
||||
data.created_at,
|
||||
data.kind,
|
||||
data.content,
|
||||
JSON.stringify(data.tags),
|
||||
getParentID(data.tags, data.id),
|
||||
eid,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// create cache comment note
|
||||
export async function countTotalNotes() {
|
||||
const db = await connect();
|
||||
const result = await db.select('SELECT COUNT(*) AS "total" FROM cache_notes;');
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// get last login time
|
||||
export async function getLastLoginTime() {
|
||||
const db = await connect();
|
||||
const result = await db.select('SELECT setting_value FROM settings WHERE setting_key = "last_login"');
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// update last login time
|
||||
export async function updateLastLoginTime(time) {
|
||||
const db = await connect();
|
||||
return await db.execute(`UPDATE settings SET setting_value = "${time}" WHERE setting_key = "last_login"`);
|
||||
}
|
38
src/utils/transform.tsx
Normal file
38
src/utils/transform.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import destr from 'destr';
|
||||
|
||||
export const tagsToArray = (arr) => {
|
||||
const newarr = [];
|
||||
// push item to newarr
|
||||
arr.forEach((item) => {
|
||||
newarr.push(item[1]);
|
||||
});
|
||||
return newarr;
|
||||
};
|
||||
|
||||
export const pubkeyArray = (arr) => {
|
||||
const newarr = [];
|
||||
// push item to newarr
|
||||
arr.forEach((item) => {
|
||||
newarr.push(item.pubkey);
|
||||
});
|
||||
return newarr;
|
||||
};
|
||||
|
||||
export const getParentID = (arr, fallback) => {
|
||||
const tags = destr(arr);
|
||||
let parentID = fallback;
|
||||
|
||||
if (tags.length > 0) {
|
||||
if (tags[0][0] === 'e' || tags[0][2] === 'root' || tags[0][3] === 'root') {
|
||||
parentID = tags[0][1];
|
||||
} else {
|
||||
tags.forEach((tag) => {
|
||||
if (tag[0] === 'e' && (tag[2] === 'root' || tag[3] === 'root')) {
|
||||
parentID = tag[1];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parentID;
|
||||
};
|
@ -28,6 +28,12 @@ module.exports = {
|
||||
0 2px 2px rgb(4 4 7 / 45%),
|
||||
0 8px 24px rgb(4 4 7 / 60%)
|
||||
`,
|
||||
button: `
|
||||
rgba(112, 26, 117, 0.5) 0px 2px 8px,
|
||||
rgb(112, 26, 117) 0px 2px 4px,
|
||||
rgb(112, 26, 117) 0px 0px 0px 1px,
|
||||
rgba(255, 255, 255, 0.2) 0px 0px 0px 1px inset
|
||||
`,
|
||||
},
|
||||
backgroundColor: {
|
||||
'near-black': '#07070d',
|
||||
@ -35,6 +41,7 @@ module.exports = {
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-conic': 'conic-gradient(var(--tw-gradient-stops))',
|
||||
fade: 'linear-gradient(120deg, #000, transparent 30%, transparent 70%, #000)',
|
||||
},
|
||||
keyframes: {
|
||||
disco: {
|
||||
@ -65,6 +72,14 @@ module.exports = {
|
||||
from: { opacity: 0, transform: 'translateX(2px)' },
|
||||
to: { opacity: 1, transform: 'translateX(0)' },
|
||||
},
|
||||
moveBg: {
|
||||
'0%': { backgroundPosition: '50px' },
|
||||
'20%': { backgroundPosition: '150px' },
|
||||
'40%': { backgroundPosition: '250px' },
|
||||
'60%': { backgroundPosition: '350px' },
|
||||
'80%': { backgroundPosition: '450px' },
|
||||
'100%': { backgroundPosition: '550px' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
disco: 'disco 1.5s linear infinite',
|
||||
@ -74,6 +89,7 @@ module.exports = {
|
||||
slideLeftAndFade: 'slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideUpAndFade: 'slideUpAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideRightAndFade: 'slideRightAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
moveBg: 'moveBg 3s ease-in-out infinite alternate running forwards',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -5,6 +5,7 @@
|
||||
"@pages/*": ["src/pages/*"],
|
||||
"@layouts/*": ["src/layouts/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@stores/*": ["src/stores/*"],
|
||||
"@utils/*": ["src/utils/*"],
|
||||
"@assets/*": ["src/assets/*"]
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user