Merge pull request #14 from reyamir/feat/v0.2.0

Release first public version - v0.2.0
This commit is contained in:
Ren Amamiya
2023-03-28 10:36:24 +07:00
committed by GitHub
97 changed files with 4548 additions and 3841 deletions

View File

@@ -12,7 +12,9 @@
"^@layouts/(.*)$", "^@layouts/(.*)$",
"^@pages/(.*)$", "^@pages/(.*)$",
"^@components/(.*)$", "^@components/(.*)$",
"^@stores/(.*)$",
"^@utils/(.*)$", "^@utils/(.*)$",
"^@assets/(.*)$",
"<THIRD_PARTY_MODULES>", "<THIRD_PARTY_MODULES>",
"^[./]" "^[./]"
], ],

View File

@@ -1,7 +1,7 @@
{ {
"name": "lume", "name": "lume",
"private": true, "private": true,
"version": "0.1.1", "version": "0.2.0",
"scripts": { "scripts": {
"dev": "next dev -p 1420", "dev": "next dev -p 1420",
"build": "next build && next export -o dist", "build": "next build && next export -o dist",
@@ -12,53 +12,61 @@
"**/*": "prettier --write --ignore-unknown" "**/*": "prettier --write --ignore-unknown"
}, },
"dependencies": { "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-dialog": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-icons": "^1.2.0", "@radix-ui/react-icons": "^1.3.0",
"@rehooks/local-storage": "^2.4.4", "@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", "@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", "boring-avatars": "^1.7.0",
"dayjs": "^1.11.7",
"destr": "^1.2.2",
"emoji-mart": "^5.5.2",
"framer-motion": "^9.1.7", "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": "^13.2.4",
"next-remove-imports": "^1.0.10", "next-remove-imports": "^1.0.10",
"nostr-relaypool": "^0.5.12", "nostr-relaypool": "^0.5.18",
"nostr-tools": "^1.7.4", "nostr-tools": "^1.8.0",
"qrcode.react": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.43.5", "react-hook-form": "^7.43.8",
"react-moment": "^1.1.3",
"react-player": "^2.12.0", "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", "tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"ws": "^8.12.1" "ws": "^8.13.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.2.3", "@tauri-apps/cli": "^1.2.3",
"@trivago/prettier-plugin-sort-imports": "^4.1.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/node": "^18.15.0", "@types/node": "^18.15.10",
"@types/react": "^18.0.28", "@types/react": "^18.0.29",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^5.54.1", "@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.54.1", "@typescript-eslint/parser": "^5.56.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"csstype": "^3.1.1", "csstype": "^3.1.1",
"eslint": "^8.35.0", "eslint": "^8.36.0",
"eslint-config-next": "^13.2.4", "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": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^13.1.2", "lint-staged": "^13.2.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"prettier": "^2.8.4", "prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.4", "prettier-plugin-tailwindcss": "^0.2.5",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "^4.9.5" "typescript": "^4.9.5"

2330
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

335
src-tauri/Cargo.lock generated
View File

@@ -82,6 +82,22 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.1.0" version = "1.1.0"
@@ -500,17 +516,6 @@ dependencies = [
"syn", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.17" version = "0.99.17"
@@ -899,7 +904,7 @@ dependencies = [
"libc", "libc",
"log", "log",
"rustversion", "rustversion",
"windows 0.39.0", "windows",
] ]
[[package]] [[package]]
@@ -1393,15 +1398,6 @@ version = "0.2.139"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" 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]] [[package]]
name = "libloading" name = "libloading"
version = "0.7.4" version = "0.7.4"
@@ -1468,7 +1464,7 @@ dependencies = [
[[package]] [[package]]
name = "lume" name = "lume"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"cocoa", "cocoa",
"objc", "objc",
@@ -1485,19 +1481,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" 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]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@@ -1578,6 +1561,24 @@ dependencies = [
"windows-sys 0.42.0", "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]] [[package]]
name = "ndk" name = "ndk"
version = "0.6.0" version = "0.6.0"
@@ -1637,17 +1638,6 @@ dependencies = [
"memchr", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@@ -1729,17 +1719,6 @@ dependencies = [
"objc_exception", "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]] [[package]]
name = "objc_exception" name = "objc_exception"
version = "0.1.2" version = "0.1.2"
@@ -1774,6 +1753,62 @@ dependencies = [
"windows-sys 0.42.0", "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]] [[package]]
name = "overload" name = "overload"
version = "0.1.1" version = "0.1.1"
@@ -2026,7 +2061,7 @@ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"indexmap", "indexmap",
"line-wrap", "line-wrap",
"quick-xml 0.26.0", "quick-xml",
"serde", "serde",
"time", "time",
] ]
@@ -2104,15 +2139,6 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.26.0" version = "0.26.0"
@@ -2276,30 +2302,6 @@ dependencies = [
"winapi", "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]] [[package]]
name = "ring" name = "ring"
version = "0.16.20" version = "0.16.20"
@@ -2381,6 +2383,15 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "scoped-tls" name = "scoped-tls"
version = "1.0.1" version = "1.0.1"
@@ -2403,6 +2414,29 @@ dependencies = [
"untrusted", "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]] [[package]]
name = "selectors" name = "selectors"
version = "0.22.0" version = "0.22.0"
@@ -2492,6 +2526,18 @@ dependencies = [
"syn", "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]] [[package]]
name = "serde_with" name = "serde_with"
version = "1.14.0" version = "1.14.0"
@@ -2797,27 +2843,6 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 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]] [[package]]
name = "syn" name = "syn"
version = "1.0.107" version = "1.0.107"
@@ -2898,7 +2923,7 @@ dependencies = [
"serde", "serde",
"unicode-segmentation", "unicode-segmentation",
"uuid 1.3.0", "uuid 1.3.0",
"windows 0.39.0", "windows",
"windows-implement", "windows-implement",
"x11-dl", "x11-dl",
] ]
@@ -2921,6 +2946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe7e0f1d535e7cbbbab43c82be4fc992b84f9156c16c160955617e0260ebc449" checksum = "fe7e0f1d535e7cbbbab43c82be4fc992b84f9156c16c160955617e0260ebc449"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"attohttpc",
"cocoa", "cocoa",
"dirs-next", "dirs-next",
"embed_plist", "embed_plist",
@@ -2933,15 +2959,14 @@ dependencies = [
"heck 0.4.1", "heck 0.4.1",
"http", "http",
"ignore", "ignore",
"notify-rust",
"objc", "objc",
"once_cell", "once_cell",
"open", "open",
"os_info",
"percent-encoding", "percent-encoding",
"rand 0.8.5", "rand 0.8.5",
"raw-window-handle", "raw-window-handle",
"regex", "regex",
"rfd",
"semver 1.0.16", "semver 1.0.16",
"serde", "serde",
"serde_json", "serde_json",
@@ -2960,7 +2985,7 @@ dependencies = [
"uuid 1.3.0", "uuid 1.3.0",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows 0.39.0", "windows",
] ]
[[package]] [[package]]
@@ -3051,7 +3076,7 @@ dependencies = [
"thiserror", "thiserror",
"uuid 1.3.0", "uuid 1.3.0",
"webview2-com", "webview2-com",
"windows 0.39.0", "windows",
] ]
[[package]] [[package]]
@@ -3070,7 +3095,7 @@ dependencies = [
"uuid 1.3.0", "uuid 1.3.0",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
"windows 0.39.0", "windows",
"wry", "wry",
] ]
@@ -3099,18 +3124,7 @@ dependencies = [
"thiserror", "thiserror",
"url", "url",
"walkdir", "walkdir",
"windows 0.39.0", "windows",
]
[[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",
] ]
[[package]] [[package]]
@@ -3513,18 +3527,6 @@ dependencies = [
"wasm-bindgen-shared", "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]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.84" version = "0.2.84"
@@ -3638,7 +3640,7 @@ checksum = "b4a769c9f1a64a8734bde70caafac2b96cada12cd4aefa49196b3a386b8b4178"
dependencies = [ dependencies = [
"webview2-com-macros", "webview2-com-macros",
"webview2-com-sys", "webview2-com-sys",
"windows 0.39.0", "windows",
"windows-implement", "windows-implement",
] ]
@@ -3663,7 +3665,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
"windows 0.39.0", "windows",
"windows-bindgen", "windows-bindgen",
"windows-metadata", "windows-metadata",
] ]
@@ -3699,19 +3701,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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]] [[package]]
name = "windows" name = "windows"
version = "0.39.0" version = "0.39.0"
@@ -3803,12 +3792,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608"
[[package]]
name = "windows_aarch64_msvc"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.39.0" version = "0.39.0"
@@ -3821,12 +3804,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7"
[[package]]
name = "windows_i686_gnu"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.39.0" version = "0.39.0"
@@ -3839,12 +3816,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640"
[[package]]
name = "windows_i686_msvc"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.39.0" version = "0.39.0"
@@ -3857,12 +3828,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.39.0" version = "0.39.0"
@@ -3881,12 +3846,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.39.0" version = "0.39.0"
@@ -3942,7 +3901,7 @@ dependencies = [
"webkit2gtk", "webkit2gtk",
"webkit2gtk-sys", "webkit2gtk-sys",
"webview2-com", "webview2-com",
"windows 0.39.0", "windows",
"windows-implement", "windows-implement",
] ]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "lume" name = "lume"
version = "0.1.1" version = "0.2.0"
description = "nostr client" description = "nostr client"
authors = ["Ren Amamiya"] authors = ["Ren Amamiya"]
license = "" license = ""
@@ -16,7 +16,7 @@ tauri-build = { version = "1.2", features = [] }
[dependencies] [dependencies]
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["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] [dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace" git = "https://github.com/tauri-apps/plugins-workspace"

View File

@@ -9,21 +9,38 @@ CREATE TABLE
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
-- add default relays
-- relay status:
-- 0: off
-- 1: on
INSERT INTO INSERT INTO
relays (relay_url, relay_status) relays (relay_url, relay_status)
VALUES VALUES
("wss://relay.damus.io", "1"), ("wss://relay.damus.io", "1"),
("wss://relay.uselume.xyz", "0"), ("wss://eden.nostr.land", "0"),
("wss://nostr-pub.wellorder.net", "1"), ("wss://nostr-pub.wellorder.net", "1"),
("wss://nostr.bongbong.com", "1"), ("wss://nostr.bongbong.com", "1"),
("wss://nostr.zebedee.cloud", "1"), ("wss://nostr.zebedee.cloud", "1"),
("wss://nostr.fmt.wiz.biz", "1"), ("wss://nostr.fmt.wiz.biz", "1"),
("wss://nostr.walletofsatoshi.com", "1"), ("wss://nostr.walletofsatoshi.com", "0"),
("wss://relay.snort.social", "1"), ("wss://relay.snort.social", "1"),
("wss://offchain.pub", "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"); ("wss://nos.lol", "1");
-- create accounts -- create accounts
-- is_active (part of multi-account feature):
-- 0: false
-- 1: true
CREATE TABLE CREATE TABLE
accounts ( accounts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -31,27 +48,30 @@ CREATE TABLE
npub TEXT NOT NULL, npub TEXT NOT NULL,
nsec TEXT NOT NULL, nsec TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 0,
metadata JSON metadata TEXT
); );
-- create follows -- create follows
-- kind (part of multi-newsfeed feature):
-- 0: direct
-- 1: follow of follow
CREATE TABLE CREATE TABLE
follows ( follows (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
pubkey TEXT NOT NULL, pubkey TEXT NOT NULL,
account TEXT NOT NULL, account TEXT NOT NULL,
kind INTEGER NOT NULL DEFAULT 0, kind INTEGER NOT NULL DEFAULT 0,
metadata JSON metadata TEXT
); );
-- create index for pubkey in follows -- 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 cache profiles
CREATE TABLE CREATE TABLE
cache_profiles ( cache_profiles (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
metadata JSON, metadata TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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, kind INTEGER NOT NULL DEFAULT 1,
tags TEXT NOT NULL, tags TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
relay TEXT, parent_id TEXT,
is_multi BOOLEAN DEFAULT 0 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");

View File

@@ -7,24 +7,21 @@
#[macro_use] #[macro_use]
extern crate objc; extern crate objc;
use tauri::{Manager, SystemTray, WindowEvent}; use tauri::{Manager, WindowEvent};
use tauri_plugin_sql::{Migration, MigrationKind}; use tauri_plugin_sql::{Migration, MigrationKind};
use window_ext::WindowExt; use window_ext::WindowExt;
mod window_ext; mod window_ext;
fn main() { fn main() {
let tray = SystemTray::new();
tauri::Builder::default() tauri::Builder::default()
.setup(|app| { .setup(|app| {
let main_window = app.get_window("main").unwrap(); let main_window = app.get_window("main").unwrap();
// set inset for traffic lights // set inset for traffic lights
main_window.position_traffic_lights(8.0, 16.0); main_window.position_traffic_lights(8.0, 20.0);
Ok(()) Ok(())
}) })
.system_tray(tray)
.plugin( .plugin(
tauri_plugin_sql::Builder::default() tauri_plugin_sql::Builder::default()
.add_migrations( .add_migrations(
@@ -41,7 +38,7 @@ fn main() {
.on_window_event(|e| { .on_window_event(|e| {
let apply_offset = || { let apply_offset = || {
let win = e.window(); let win = e.window();
win.position_traffic_lights(8.0, 16.0); win.position_traffic_lights(8.0, 20.0);
}; };
match e.event() { match e.event() {

View File

@@ -8,25 +8,37 @@
}, },
"package": { "package": {
"productName": "Lume", "productName": "Lume",
"version": "0.1.1" "version": "0.2.0"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {
"all": false, "all": false,
"app": {
"all": false
},
"os": {
"all": true
},
"http": {
"all": false,
"request": true,
"scope": ["https://rbr.bio/*", "https://metadata.uselume.xyz/*"]
},
"shell": { "shell": {
"all": false, "all": false,
"open": true "open": true
}, },
"clipboard": { "clipboard": {
"all": true, "all": false,
"writeText": true, "writeText": true,
"readText": true "readText": true
}, },
"notification": { "notification": {
"all": true "all": false
}, },
"window": { "window": {
"startDragging": true "startDragging": true,
"close": true
} }
}, },
"bundle": { "bundle": {

View File

@@ -4,6 +4,13 @@
@import './assets/editor.css'; @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) */ /* For Webkit-based browsers (Chrome, Safari and Opera) */
.scrollbar-hide::-webkit-scrollbar { .scrollbar-hide::-webkit-scrollbar {
display: none; display: none;
@@ -23,3 +30,12 @@
.border { .border {
background-clip: padding-box; background-clip: padding-box;
} }
@keyframes loop {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}

View File

@@ -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"
}
]

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
});

View 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>
);
});

View 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>
);
});

View File

@@ -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 LumeSymbol from '@assets/icons/Lume';
import { PlusIcon } from '@radix-ui/react-icons'; 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 Link from 'next/link';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import Database from 'tauri-plugin-sql-api';
export default function AccountColumn() { export default function AccountColumn() {
const [users, setUsers] = useState([]); const [version, setVersion] = useState(null);
const [currentUser]: any = useLocalStorage('current-user');
const getAccounts = useCallback(async () => { const getAppVersion = useCallback(async () => {
const db = await Database.load('sqlite:lume.db'); const appVersion = await getVersion();
const result: any = await db.select('SELECT * FROM accounts'); setVersion(appVersion);
setUsers(result);
}, []); }, []);
useEffect(() => { useEffect(() => {
getAccounts().catch(console.error); getAppVersion().catch(console.error);
}, [getAccounts]); }, [getAppVersion]);
return ( 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"> <div className="flex flex-col gap-4">
{users.map((user, index) => ( <Link
<Account key={index} user={user} current={currentUser.id} /> 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 <Link
href="/onboarding" 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" /> <PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-200" />
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-0.5 text-center">
<LumeSymbol className="h-8 w-auto text-zinc-700" /> <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>
</div> </div>
); );

View 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))}</>;
}

View File

@@ -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>
);
}

View File

@@ -1,74 +1,13 @@
import ActiveLink from '@components/activeLink'; import Messages from '@components/columns/navigator/messages';
import CreatePost from '@components/columns/navigator/createPost'; import Newsfeed from '@components/columns/navigator/newsfeed';
import { UserDropdownMenu } from '@components/columns/navigator/userDropdownMenu';
import { PlusIcon } from '@radix-ui/react-icons';
import { useLocalStorage } from '@rehooks/local-storage';
export default function NavigatorColumn() { export default function NavigatorColumn() {
const [currentUser]: any = useLocalStorage('current-user');
const profile = JSON.parse(currentUser.metadata);
return ( return (
<div className="flex h-full flex-col flex-wrap justify-between overflow-hidden px-2 pt-3 pb-4"> <div className="relative flex h-full flex-col gap-1 overflow-hidden pt-4">
<div className="flex flex-col gap-4"> {/* Newsfeed */}
{/* Create post */} <Newsfeed />
<div className="flex flex-col rounded-lg bg-zinc-900 ring-1 ring-white/10"> {/* Messages */}
<div className="flex flex-col p-2"> <Messages />
<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> </div>
); );
} }

View 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>
);
}

View 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>
);
});

View 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>
);
}

View File

@@ -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>
);
});

View File

@@ -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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -26,10 +26,20 @@ export const ImageWithFallback = memo(function ImageWithFallback({
size={44} size={44}
name={alt} name={alt}
variant="beam" variant="beam"
square={true}
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']} 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
/>
)} )}
</> </>
); );

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View 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>
);
});

View 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>
);
});

View File

@@ -1,111 +1,71 @@
import { DatabaseContext } from '@components/contexts/database'; import { RelayContext } from '@components/relaysProvider';
import { RelayContext } from '@components/contexts/relay';
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 { dateToUnix } from '@utils/getDate';
import { useLocalStorage } from '@rehooks/local-storage'; import { createCacheNote, getAllFollowsByID, updateLastLoginTime } from '@utils/storage';
import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { pubkeyArray } from '@utils/transform';
export const NoteConnector = memo(function NoteConnector({ import { TauriEvent } from '@tauri-apps/api/event';
setParentReload, import { appWindow, getCurrent } from '@tauri-apps/api/window';
setHasNewNote, import { useAtom, useAtomValue, useSetAtom } from 'jotai';
currentDate, import { useCallback, useContext, useEffect, useRef, useState } from 'react';
}: {
setParentReload: any;
setHasNewNote: any;
currentDate: any;
}) {
const { db }: any = useContext(DatabaseContext);
const relayPool: any = useContext(RelayContext);
const [follows]: any = useLocalStorage('follows'); export default function NoteConnector() {
const [relays]: any = useLocalStorage('relays'); const pool: any = useContext(RelayContext);
const [reload, setReload] = useState(false); const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
const timeout = useRef(null); const relays = useAtomValue(relaysAtom);
const [activeAccount] = useAtom(activeAccountAtom);
const reloadNewsfeed = () => { const [isOnline] = useState(true);
setParentReload(true); const now = useRef(new Date());
setReload(true);
timeout.current = setTimeout(() => {
setReload(false);
}, 2000);
};
const insertDB = useCallback( const subscribe = useCallback(() => {
async (event: any) => { getAllFollowsByID(activeAccount.id).then((follows) => {
await db.execute( pool.subscribe(
`INSERT OR IGNORE INTO [
cache_notes {
(id, pubkey, created_at, kind, tags, content) VALUES kinds: [1],
( authors: pubkeyArray(follows),
"${event.id}", since: dateToUnix(now.current),
"${event.pubkey}", },
"${event.created_at}", ],
"${event.kind}", relays,
'${JSON.stringify(event.tags)}', (event: any) => {
"${event.content}" // insert event to local database
);` createCacheNote(event);
); setHasNewerNote(true);
},
[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);
} }
// insert event to local database );
insertDB(event).catch(console.error); });
}, }, [activeAccount.id, pool, relays, setHasNewerNote]);
undefined,
(events: any, relayURL: any) => {
console.log(events, relayURL);
}
);
}, [relayPool, follows, currentDate, relays, insertDB, setHasNewNote]);
useEffect(() => { useEffect(() => {
fetchEvent(); subscribe();
getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
return () => { updateLastLoginTime(now.current);
clearTimeout(timeout.current); appWindow.close();
}; });
}, [fetchEvent]); }, [activeAccount.id, pool, relays, setHasNewerNote, subscribe]);
return ( return (
<div className="relative flex h-12 items-center justify-between border-b border-zinc-800 px-6 shadow-input"> <>
<div> <div className="inline-flex items-center gap-1 rounded-md py-1 px-1.5 hover:bg-zinc-900">
<h3 className="text-sm font-semibold text-zinc-500"># following</h3> <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>
<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>
); );
}); }

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
}

View 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>
);
}
});

View File

@@ -2,9 +2,9 @@ import { memo } from 'react';
export const Placeholder = memo(function Placeholder() { export const Placeholder = memo(function Placeholder() {
return ( return (
<div className="relative z-10 flex h-min animate-pulse select-text flex-col py-4 px-6"> <div className="relative z-10 flex h-min animate-pulse select-text flex-col py-5 px-3">
<div className="flex items-start gap-4"> <div className="flex items-start gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full bg-zinc-700" /> <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 flex-1 items-start justify-between">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
@@ -16,7 +16,7 @@ export const Placeholder = memo(function Placeholder() {
</div> </div>
</div> </div>
</div> </div>
<div className="-mt-4 pl-[60px]"> <div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="h-16 w-full rounded bg-zinc-700" /> <div className="h-16 w-full rounded bg-zinc-700" />
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">

View 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>
);
});

View File

@@ -1,13 +1,12 @@
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
// eslint-disable-next-line @typescript-eslint/no-explicit-any export default function LinkCard({ data }: { data: any }) {
export default function LinkCard({ data }: { data: object }) {
return ( return (
<Link <Link
href={data['url']} href={data['url']}
target={'_blank'} 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"> <div className="relative aspect-video h-auto w-full">
<Image src={data['image']} alt="image preview" fill={true} className="object-cover" /> <Image src={data['image']} alt="image preview" fill={true} className="object-cover" />

View 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>
);
});

View File

@@ -1,52 +1,110 @@
import { RelayContext } from '@components/contexts/relay'; import { RelayContext } from '@components/relaysProvider';
import { UserRepost } from '@components/note/atoms/userRepost'; import { UserExtend } from '@components/user/extend';
import { Content } from '@components/note/content'; import { UserMention } from '@components/user/mention';
import { Placeholder } from '@components/note/placeholder';
import { LoopIcon } from '@radix-ui/react-icons'; import { relaysAtom } from '@stores/relays';
import useLocalStorage from '@rehooks/local-storage';
import { memo, useContext, useState } from 'react';
export const Repost = memo(function Repost({ root, user }: { root: any; user: string }) { import { createCacheNote, getNoteByID } from '@utils/storage';
const relayPool: any = useContext(RelayContext);
const [relays]: any = useLocalStorage('relays');
const [events, setEvents] = useState([]);
relayPool.subscribe( import destr from 'destr';
[ import { useAtomValue } from 'jotai';
{ import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
ids: [root[0][1]], import reactStringReplace from 'react-string-replace';
since: 0,
kinds: [1], 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);
}, },
], undefined,
relays, undefined,
(event: any) => { {
setEvents((events) => [event, ...events]); unsubscribeOnEose: true,
}, }
undefined, );
(events: any, relayURL: any) => { }, [id, pool, relays]);
console.log(events, relayURL);
}
);
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 ( 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="relative mt-3 mb-2 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3">
<div className="flex items-center gap-1 pl-8 text-sm"> <div className="relative z-10 flex flex-col">
<LoopIcon className="h-4 w-4 text-zinc-400" /> <UserExtend pubkey={event.pubkey} time={event.created_at} />
<div className="ml-2"> <div className="-mt-5 pl-[52px]">
<UserRepost pubkey={user} /> <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>
</div> </div>
{events[0].content && <Content data={events[0]} />}
</div> </div>
); );
} else { } else {
return ( return <div className="mt-2 h-6 animate-pulse select-text flex-col rounded bg-zinc-700 pb-5"></div>;
<div className="border-b border-zinc-800">
<Placeholder />
</div>
);
} }
}); });

View File

@@ -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>
);
});

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View File

@@ -3,8 +3,7 @@ import { createContext, useMemo } from 'react';
export const RelayContext = createContext({}); export const RelayContext = createContext({});
export default function RelayProvider({ relays, children }: { relays: any; children: React.ReactNode }) { export default function RelayProvider({ relays, children }: { relays: Array<string>; children: React.ReactNode }) {
const value = useMemo(() => new RelayPool(relays, { useEventCache: true }), [relays]); const value = useMemo(() => new RelayPool(relays, { useEventCache: false, logSubscriptions: false }), [relays]);
return <RelayContext.Provider value={value}>{children}</RelayContext.Provider>; return <RelayContext.Provider value={value}>{children}</RelayContext.Provider>;
} }

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>
);
});

View 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>;
});

View 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>
);
});

View File

@@ -1,3 +1,3 @@
export default function BaseLayout({ children }: { children: React.ReactNode }) { 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>;
} }

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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&apos;t implemented yet, so resize Lume to the initial size for a better experience.
I&apos;m sorry for this inconvenience, and I swear I will add it soon 😁
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,11 @@
import DatabaseProvider from '@components/contexts/database'; import RelayProvider from '@components/relaysProvider';
import RelayProvider from '@components/contexts/relay';
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 { NextPage } from 'next';
import type { AppProps } from 'next/app'; import type { AppProps } from 'next/app';
import { ReactElement, ReactNode } from 'react'; import { ReactElement, ReactNode } from 'react';
@@ -17,15 +21,25 @@ type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout; Component: NextPageWithLayout;
}; };
const queryClient = new QueryClient();
const HydrateAtoms = ({ children }) => {
useHydrateAtoms([[queryClientAtom, queryClient]]);
return children;
};
export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available // Use the layout defined at the page level, if available
const getLayout = Component.getLayout ?? ((page) => page); const getLayout = Component.getLayout ?? ((page) => page);
// Get relays from localstorage const relays = useAtomValue(relaysAtom);
const [relays] = useLocalStorage('relays');
return ( return (
<DatabaseProvider> <QueryClientProvider client={queryClient}>
<RelayProvider relays={relays}>{getLayout(<Component {...pageProps} />)}</RelayProvider> <Provider>
</DatabaseProvider> <HydrateAtoms>
<RelayProvider relays={relays}>{getLayout(<Component {...pageProps} />)}</RelayProvider>
</HydrateAtoms>
</Provider>
</QueryClientProvider>
); );
} }

View File

@@ -1,14 +1,10 @@
import BaseLayout from '@layouts/base'; import BaseLayout from '@layouts/base';
import NewsFeedLayout from '@layouts/newsfeed'; import WithSidebarLayout from '@layouts/withSidebar';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react'; import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
export default function Page() { export default function Page() {
return ( return <></>;
<div className="h-full w-full">
<p>Global</p>
</div>
);
} }
Page.getLayout = function getLayout( Page.getLayout = function getLayout(
@@ -22,7 +18,7 @@ Page.getLayout = function getLayout(
) { ) {
return ( return (
<BaseLayout> <BaseLayout>
<NewsFeedLayout>{page}</NewsFeedLayout> <WithSidebarLayout>{page}</WithSidebarLayout>
</BaseLayout> </BaseLayout>
); );
}; };

View File

@@ -1,102 +1,58 @@
import BaseLayout from '@layouts/base'; import BaseLayout from '@layouts/base';
import FullscreenLayout from '@layouts/fullscreen';
import { getAccounts } from '@utils/storage';
import LumeSymbol from '@assets/icons/Lume'; import LumeSymbol from '@assets/icons/Lume';
import { useLocalStorage } from '@rehooks/local-storage';
import { motion } from 'framer-motion';
import { useRouter } from 'next/router'; 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() { export default function Page() {
const router = useRouter(); const router = useRouter();
const [currentUser]: any = useLocalStorage('current-user');
const [loading, setLoading] = useState(true);
const timer = useRef(null);
useEffect(() => { useEffect(() => {
if (currentUser) { getAccounts()
timer.current = setTimeout(() => { .then((res: any) => {
setLoading(false); if (res.length > 0) {
router.push('/newsfeed/following'); router.push('/init');
}, 1000); } else {
} else { router.push('/onboarding');
timer.current = setTimeout(() => { }
setLoading(false); })
router.push('/onboarding'); .catch(console.error);
}, 1000); }, [router]);
}
// clean up
return () => {
clearTimeout(timer.current);
};
}, [currentUser, router]);
return ( return (
<div className="relative flex h-full flex-col items-center justify-between"> <div className="relative h-full overflow-hidden">
<div>{/* spacer */}</div> {/* dragging area */}
<div className="flex flex-col items-center gap-4"> <div data-tauri-drag-region className="absolute top-0 left-0 z-20 h-16 w-full bg-transparent" />
<motion.div layoutId="logo" className="relative"> {/* end dragging area */}
<LumeSymbol className="h-16 w-16 text-white" /> <div className="relative flex h-full flex-col items-center justify-center">
</motion.div> <div className="flex flex-col items-center gap-2">
<div className="flex flex-col items-center gap-0.5"> <LumeSymbol className="h-16 w-16 text-black dark:text-white" />
<motion.h2 <div className="text-center">
layoutId="subtitle" <h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">Did you know?</h3>
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-4xl font-medium text-transparent" <p className="font-medium text-zinc-300 dark:text-zinc-600">
> No one can&apos;t stop you use bitcoin and nostr
A censorship-resistant social network </p>
</motion.h2> </div>
<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> </div>
</div> <div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<div className="flex items-center gap-2 pb-16"> <svg
<div className="h-10"> className="h-5 w-5 animate-spin text-black dark:text-white"
{loading ? ( xmlns="http://www.w3.org/2000/svg"
<svg fill="none"
className="h-5 w-5 animate-spin text-white" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" >
fill="none" <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
viewBox="0 0 24 24" <path
> className="opacity-75"
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> fill="currentColor"
<path 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"
className="opacity-75" ></path>
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>
</svg> </svg>
</svg> </div>
</div> </div>
{/* end background */}
</div> </div>
); );
} }
@@ -110,9 +66,5 @@ Page.getLayout = function getLayout(
| ReactFragment | ReactFragment
| ReactPortal | ReactPortal
) { ) {
return ( return <BaseLayout>{page}</BaseLayout>;
<BaseLayout>
<FullscreenLayout>{page}</FullscreenLayout>
</BaseLayout>
);
}; };

135
src/pages/init.tsx Normal file
View 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>;
};

View 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>
);
};

View File

@@ -1,12 +1,12 @@
import BaseLayout from '@layouts/base'; import BaseLayout from '@layouts/base';
import NewsFeedLayout from '@layouts/newsfeed'; import WithSidebarLayout from '@layouts/withSidebar';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react'; import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
export default function Page() { export default function Page() {
return ( return (
<div className="h-full w-full"> <div className="flex h-full w-full items-center justify-center">
<p>Global</p> <p className="text-sm text-zinc-400">Sorry, this feature under development, it will come in the next version</p>
</div> </div>
); );
} }
@@ -22,7 +22,7 @@ Page.getLayout = function getLayout(
) { ) {
return ( return (
<BaseLayout> <BaseLayout>
<NewsFeedLayout>{page}</NewsFeedLayout> <WithSidebarLayout>{page}</WithSidebarLayout>
</BaseLayout> </BaseLayout>
); );
}; };

View File

@@ -1,127 +1,48 @@
import BaseLayout from '@layouts/base'; import BaseLayout from '@layouts/base';
import NewsFeedLayout from '@layouts/newsfeed'; import WithSidebarLayout from '@layouts/withSidebar';
import { DatabaseContext } from '@components/contexts/database'; import FormBase from '@components/form/base';
import { NoteConnector } from '@components/note/connector'; import { NoteBase } from '@components/note/base';
import { Placeholder } from '@components/note/placeholder'; 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 { useVirtualizer } from '@tanstack/react-virtual';
import { writeStorage } from '@rehooks/local-storage'; import { useAtom } from 'jotai';
import { useCallback, useState } from 'react'; import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, Suspense, useRef } from 'react';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useEffect, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
export default function Page() { export default function Page() {
const { db }: any = useContext(DatabaseContext); const [data]: any = useAtom(notesAtom);
const parentRef = useRef(null);
const [data, setData] = useState([]); const virtualizer = useVirtualizer({
const [parentReload, setParentReload] = useState(false); count: data.length,
const [hasNewNote, setHasNewNote] = useState(false); estimateSize: () => 500,
getScrollElement: () => parentRef.current,
const now = useRef(new Date()); getItemKey: (index) => data[index].id,
const limit = useRef(30); });
const offset = useRef(0); const items = virtualizer.getVirtualItems();
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]
);
return ( return (
<div className="relative h-full w-full"> <div ref={parentRef} className="scrollbar-hide h-full w-full overflow-y-auto" style={{ contain: 'strict' }}>
<NoteConnector setParentReload={setParentReload} setHasNewNote={setHasNewNote} currentDate={now.current} /> <div className="relative">
{hasNewNote && ( <FormBase />
<div className="absolute top-16 left-1/2 z-50 -translate-x-1/2 transform"> </div>
<button <Suspense fallback={<Placeholder />}>
onClick={() => loadNewest()} <div>
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" {items.length > 0 && (
> <div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
<ArrowUpIcon className="h-4 w-4" /> <div className="absolute top-0 left-0 w-full" style={{ transform: `translateY(${items[0].start}px)` }}>
<span className="drop-shadow-md">Load newest</span> {items.map((virtualRow) => (
</button> <div key={virtualRow.key} data-index={virtualRow.index} ref={virtualizer.measureElement}>
<NoteBase event={data[virtualRow.index]} />
</div>
))}
</div>
</div>
)}
</div> </div>
)} </Suspense>
<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',
}}
/>
</div> </div>
); );
} }
@@ -137,7 +58,7 @@ Page.getLayout = function getLayout(
) { ) {
return ( return (
<BaseLayout> <BaseLayout>
<NewsFeedLayout>{page}</NewsFeedLayout> <WithSidebarLayout>{page}</WithSidebarLayout>
</BaseLayout> </BaseLayout>
); );
}; };

View File

@@ -1,25 +1,17 @@
import BaseLayout from '@layouts/base'; import BaseLayout from '@layouts/base';
import OnboardingLayout from '@layouts/onboarding';
import { DatabaseContext } from '@components/contexts/database'; import { RelayContext } from '@components/relaysProvider';
import { RelayContext } from '@components/contexts/relay';
import { relaysAtom } from '@stores/relays';
import { createAccount } from '@utils/storage';
import { EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons'; import { EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons';
import { useLocalStorage, writeStorage } from '@rehooks/local-storage'; import { useAtomValue } from 'jotai';
import { motion } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent } from 'nostr-tools'; import { generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent } from 'nostr-tools';
import { import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useMemo, useState } from 'react';
JSXElementConstructor,
ReactElement,
ReactFragment,
ReactPortal,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { Config, names, uniqueNamesGenerator } from 'unique-names-generator'; import { Config, names, uniqueNamesGenerator } from 'unique-names-generator';
const config: Config = { const config: Config = {
@@ -28,12 +20,9 @@ const config: Config = {
export default function Page() { export default function Page() {
const router = useRouter(); const router = useRouter();
const pool: any = useContext(RelayContext);
const { db }: any = useContext(DatabaseContext); const relays = useAtomValue(relaysAtom);
const relayPool: any = useContext(RelayContext);
const [relays] = useLocalStorage('relays');
const [type, setType] = useState('password'); const [type, setType] = useState('password');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -44,6 +33,30 @@ export default function Page() {
const npub = nip19.npubEncode(pubKey); const npub = nip19.npubEncode(pubKey);
const nsec = nip19.nsecEncode(privKey); 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 = () => { const showPrivateKey = () => {
if (type === 'password') { if (type === 'password') {
setType('text'); setType('text');
@@ -52,32 +65,13 @@ export default function Page() {
} }
}; };
// auto-generated profile // create account and broadcast to all relays
const data = useMemo( const submit = () => {
() => ({
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 () => {
setLoading(true); setLoading(true);
// build event // build event
const event: any = { const event: any = {
content: JSON.stringify(data), content: JSON.stringify(metadata),
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind: 0, kind: 0,
pubkey: pubKey, pubkey: pubKey,
@@ -86,129 +80,113 @@ export default function Page() {
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, privKey); event.sig = signEvent(event, privKey);
insertDB() // insert to database then broadcast
createAccount(data)
.then(() => { .then(() => {
// publish to relays pool.publish(event, relays);
relayPool.publish(event, relays); router.push({
// set currentUser in global state pathname: '/onboarding/create/step-2',
writeStorage('current-user', { query: { id: pubKey, privkey: privKey },
metadata: JSON.stringify(data),
npub: npub,
privkey: privKey,
pubkey: pubKey,
}); });
// redirect to pre-follow
setTimeout(() => {
setLoading(false);
router.push('/onboarding/create/pre-follows');
}, 1500);
}) })
.catch(console.error); .catch(console.error);
}; };
return ( return (
<div className="flex h-full flex-col justify-between px-8"> <div className="grid h-full w-full grid-rows-5">
<div>{/* spacer */}</div> <div className="row-span-1 flex items-center justify-center">
<motion.div layoutId="form"> <div>
<div className="mb-8 flex flex-col gap-3"> <h1 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent">
<motion.h1 Create new account
layoutId="title" </h1>
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> </div>
<div className="flex flex-col gap-4"> </div>
<div className="flex flex-col gap-1"> <div className="row-span-4">
<label className="text-sm font-semibold text-zinc-400">Public Key</label> <div className="mx-auto w-full max-w-md">
<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="mb-8 flex flex-col gap-4">
<input <div className="flex flex-col gap-1">
readOnly <label className="text-sm font-semibold text-zinc-400">Public Key</label>
value={npub} <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">
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" <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> <div className="flex flex-col gap-1">
<div className="flex flex-col gap-1"> <label className="text-sm font-semibold text-zinc-400">Private Key</label>
<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">
<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
<input readOnly
readOnly type={type}
type={type} value={nsec}
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"
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
<button onClick={() => showPrivateKey()}
onClick={() => showPrivateKey()} className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700" >
> {type === 'password' ? (
{type === 'password' ? ( <EyeClosedIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
<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" />
<EyeOpenIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" /> )}
)} </button>
</button> </div>
</div> </div>
</div> <div className="flex flex-col gap-1">
<div className="flex flex-col gap-1"> <label className="text-sm font-semibold text-zinc-400">Default Profile (you can change it later)</label>
<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 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 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="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-2">
<div className="flex space-x-4"> <div className="relative h-11 w-11 rounded-md">
<div className="relative h-10 w-10 rounded-full"> <Image className="inline-block rounded-md" src={metadata.picture} alt="" fill={true} />
<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> </div>
<div className="space-y-3"> <div className="flex-1 space-y-2 py-1">
<div className="grid grid-cols-3 gap-4"> <div className="flex items-center gap-1">
<div className="col-span-2 h-2 rounded bg-zinc-700"></div> <p className="font-semibold">{metadata.display_name}</p>
<div className="col-span-1 h-2 rounded bg-zinc-700"></div> <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>
<div className="h-2 rounded bg-zinc-700"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div className="flex h-10 items-center justify-center">
</motion.div> {loading === true ? (
<motion.div layoutId="action" className="pb-5"> <svg
<div className="flex h-10 items-center"> className="h-5 w-5 animate-spin text-white"
{loading === true ? ( xmlns="http://www.w3.org/2000/svg"
<svg fill="none"
className="h-5 w-5 animate-spin text-white" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" >
fill="none" <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
viewBox="0 0 24 24" <path
> className="opacity-75"
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> fill="currentColor"
<path 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"
className="opacity-75" ></path>
fill="currentColor" </svg>
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 <button
onClick={() => createAccount()} onClick={() => submit()}
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" 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> <span className="drop-shadow-lg">Continue </span>
</button> </button>
</div> )}
)} </div>
</div> </div>
</motion.div> </div>
</div> </div>
); );
} }
@@ -222,9 +200,5 @@ Page.getLayout = function getLayout(
| ReactFragment | ReactFragment
| ReactPortal | ReactPortal
) { ) {
return ( return <BaseLayout>{page}</BaseLayout>;
<BaseLayout>
<OnboardingLayout>{page}</OnboardingLayout>
</BaseLayout>
);
}; };

View File

@@ -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>
);
};

View 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>;
};

View File

@@ -1,47 +1,130 @@
import BaseLayout from '@layouts/base'; 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 Link from 'next/link';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react'; 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() { export default function Page() {
return ( return (
<div className="flex h-full flex-col justify-between px-8"> <div className="grid h-full w-full grid-rows-5">
<div>{/* spacer */}</div> <div className="row-span-3 overflow-hidden">
<div className="flex flex-col gap-3"> <div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
<motion.h1 {[...new Array(ROWS)].map((_, i) => (
layoutId="title" <InfiniteLoopSlider key={i} duration={random(DURATION - 5000, DURATION + 20000)} reverse={i % 2}>
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent" {shuffle(PLEBS)
> .slice(0, PLEBS_PER_ROW)
Other social network require email/password .map((tag) => (
<br /> <div
nostr use{' '} key={tag}
<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"> 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"
public/private key instead >
</span> <Image
</motion.h1> src={tag}
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400"> alt={tag}
If you have used nostr before, you can import your own private key. Otherwise, you can create a new key or use fill={true}
auto-generated account created by system. className="rounded-md border border-zinc-900"
</motion.h2> placeholder="blur"
<motion.div layoutId="form"></motion.div> blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
<motion.div layoutId="action" className="mt-4 flex gap-2"> 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&apos;s start!
</h1>
<div className="mt-4 flex flex-col items-center gap-1.5">
<Link <Link
href="/onboarding/create" 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 Create new key
<ArrowRightIcon className="h-5 w-5" />
</Link> </Link>
<Link <Link
href="/onboarding/login" 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 Login with private key
</Link> </Link>
</motion.div> </div>
</div> </div>
<div>{/* spacer */}</div>
</div> </div>
); );
} }
@@ -55,9 +138,5 @@ Page.getLayout = function getLayout(
| ReactFragment | ReactFragment
| ReactPortal | ReactPortal
) { ) {
return ( return <BaseLayout>{page}</BaseLayout>;
<BaseLayout>
<OnboardingLayout>{page}</OnboardingLayout>
</BaseLayout>
);
}; };

View File

@@ -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>
);
};

View File

@@ -1,7 +1,6 @@
import BaseLayout from '@layouts/base'; 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 { useRouter } from 'next/router';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react'; import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
@@ -43,7 +42,7 @@ export default function Page() {
try { try {
router.push({ router.push({
pathname: '/onboarding/login/fetch', pathname: '/onboarding/login/step-2',
query: { privkey: privkey }, query: { privkey: privkey },
}); });
} catch (error) { } catch (error) {
@@ -55,60 +54,72 @@ export default function Page() {
}; };
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col justify-between px-8"> <form onSubmit={handleSubmit(onSubmit)} className="grid h-full w-full grid-rows-5">
<div>{/* spacer */}</div> <div className="row-span-1 flex items-center justify-center">
<motion.div layoutId="form"> <div>
<div className="mb-8 flex flex-col gap-3"> <h1 className="bg-gradient-to-br from-zinc-200 via-white to-zinc-300 bg-clip-text text-3xl font-semibold text-transparent">
<motion.h1 Login with Private Key
layoutId="title" </h1>
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>
</div> </div>
<div className="flex flex-col gap-2"> </div>
<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="row-span-4">
<input <div className="mx-auto w-full max-w-md">
{...register('key', { required: true, minLength: 32 })} <div className="flex flex-col gap-4">
type={'password'} <div>
placeholder="Paste nsec or hex key here..." {/* #TODO: add function */}
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" <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> </div>
<span className="text-sm text-red-400">{errors.key && <p>{errors.key.message}</p>}</span>
</div> </div>
</motion.div> </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>
</form> </form>
); );
} }
@@ -122,9 +133,5 @@ Page.getLayout = function getLayout(
| ReactFragment | ReactFragment
| ReactPortal | ReactPortal
) { ) {
return ( return <BaseLayout>{page}</BaseLayout>;
<BaseLayout>
<OnboardingLayout>{page}</OnboardingLayout>
</BaseLayout>
);
}; };

View 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>;
};

View File

@@ -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>;
};

View File

@@ -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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
export const isSSR = typeof window === 'undefined';

165
src/utils/storage.tsx Normal file
View 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
View 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;
};

View File

@@ -28,6 +28,12 @@ module.exports = {
0 2px 2px rgb(4 4 7 / 45%), 0 2px 2px rgb(4 4 7 / 45%),
0 8px 24px rgb(4 4 7 / 60%) 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: { backgroundColor: {
'near-black': '#07070d', 'near-black': '#07070d',
@@ -35,6 +41,7 @@ module.exports = {
}, },
backgroundImage: { backgroundImage: {
'gradient-conic': 'conic-gradient(var(--tw-gradient-stops))', 'gradient-conic': 'conic-gradient(var(--tw-gradient-stops))',
fade: 'linear-gradient(120deg, #000, transparent 30%, transparent 70%, #000)',
}, },
keyframes: { keyframes: {
disco: { disco: {
@@ -65,6 +72,14 @@ module.exports = {
from: { opacity: 0, transform: 'translateX(2px)' }, from: { opacity: 0, transform: 'translateX(2px)' },
to: { opacity: 1, transform: 'translateX(0)' }, 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: { animation: {
disco: 'disco 1.5s linear infinite', disco: 'disco 1.5s linear infinite',
@@ -74,6 +89,7 @@ module.exports = {
slideLeftAndFade: 'slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)', slideLeftAndFade: 'slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
slideUpAndFade: 'slideUpAndFade 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)', slideRightAndFade: 'slideRightAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
moveBg: 'moveBg 3s ease-in-out infinite alternate running forwards',
}, },
}, },
}, },

View File

@@ -5,6 +5,7 @@
"@pages/*": ["src/pages/*"], "@pages/*": ["src/pages/*"],
"@layouts/*": ["src/layouts/*"], "@layouts/*": ["src/layouts/*"],
"@components/*": ["src/components/*"], "@components/*": ["src/components/*"],
"@stores/*": ["src/stores/*"],
"@utils/*": ["src/utils/*"], "@utils/*": ["src/utils/*"],
"@assets/*": ["src/assets/*"] "@assets/*": ["src/assets/*"]
}, },