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
commit 641a2c4acf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 4548 additions and 3841 deletions

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "lume",
"private": true,
"version": "0.1.1",
"version": "0.2.0",
"scripts": {
"dev": "next dev -p 1420",
"build": "next build && next export -o dist",
@ -12,53 +12,61 @@
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@radix-ui/react-collapsible": "^1.0.2",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-icons": "^1.2.0",
"@rehooks/local-storage": "^2.4.4",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-tabs": "^1.0.3",
"@supabase/supabase-js": "^2.12.1",
"@tanstack/query-core": "^4.27.0",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.2.0",
"@uiw/react-markdown-preview": "^4.1.10",
"@uiw/react-md-editor": "^3.20.5",
"bitcoin-address-validation": "^2.2.1",
"boring-avatars": "^1.7.0",
"dayjs": "^1.11.7",
"destr": "^1.2.2",
"emoji-mart": "^5.5.2",
"framer-motion": "^9.1.7",
"moment": "^2.29.4",
"jotai": "^2.0.3",
"jotai-cache": "^0.3.0",
"jotai-tanstack-query": "^0.6.0",
"next": "^13.2.4",
"next-remove-imports": "^1.0.10",
"nostr-relaypool": "^0.5.12",
"nostr-tools": "^1.7.4",
"qrcode.react": "^3.1.0",
"nostr-relaypool": "^0.5.18",
"nostr-tools": "^1.8.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.43.5",
"react-moment": "^1.1.3",
"react-hook-form": "^7.43.8",
"react-player": "^2.12.0",
"react-virtuoso": "^4.1.0",
"react-string-replace": "^1.1.0",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"unique-names-generator": "^4.7.1",
"ws": "^8.12.1"
"ws": "^8.13.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.2.3",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/node": "^18.15.0",
"@types/react": "^18.0.28",
"@types/node": "^18.15.10",
"@types/react": "^18.0.29",
"@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.54.1",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"autoprefixer": "^10.4.14",
"csstype": "^3.1.1",
"eslint": "^8.35.0",
"eslint": "^8.36.0",
"eslint-config-next": "^13.2.4",
"eslint-config-prettier": "^8.7.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.3",
"lint-staged": "^13.1.2",
"lint-staged": "^13.2.0",
"postcss": "^8.4.21",
"prettier": "^2.8.4",
"prettier-plugin-tailwindcss": "^0.2.4",
"prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.5",
"prop-types": "^15.8.1",
"tailwindcss": "^3.2.7",
"typescript": "^4.9.5"

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",
]
[[package]]
name = "attohttpc"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fcf00bc6d5abb29b5f97e3c61a90b6d3caa12f3faf897d4a3e3607c050a35a7"
dependencies = [
"flate2",
"http",
"log",
"native-tls",
"serde",
"serde_json",
"serde_urlencoded",
"url",
]
[[package]]
name = "autocfg"
version = "1.1.0"
@ -500,17 +516,6 @@ dependencies = [
"syn",
]
[[package]]
name = "dbus"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b"
dependencies = [
"libc",
"libdbus-sys",
"winapi",
]
[[package]]
name = "derive_more"
version = "0.99.17"
@ -899,7 +904,7 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows 0.39.0",
"windows",
]
[[package]]
@ -1393,15 +1398,6 @@ version = "0.2.139"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79"
[[package]]
name = "libdbus-sys"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f8d7ae751e1cb825c840ae5e682f59b098cdfd213c350ac268b61449a5f58a0"
dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.7.4"
@ -1468,7 +1464,7 @@ dependencies = [
[[package]]
name = "lume"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"cocoa",
"objc",
@ -1485,19 +1481,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e72d50edb17756489e79d52eb146927bec8eba9dd48faadf9ef08bca3791ad5"
dependencies = [
"cc",
"dirs-next",
"objc-foundation",
"objc_id",
"time",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@ -1578,6 +1561,24 @@ dependencies = [
"windows-sys 0.42.0",
]
[[package]]
name = "native-tls"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.6.0"
@ -1637,17 +1638,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "notify-rust"
version = "4.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ce656bb6d22a93ae276a23de52d1aec5ba4db3ece3c0eb79dfd5add7384db6a"
dependencies = [
"dbus",
"mac-notification-sys",
"tauri-winrt-notification",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -1729,17 +1719,6 @@ dependencies = [
"objc_exception",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc_exception"
version = "0.1.2"
@ -1774,6 +1753,62 @@ dependencies = [
"windows-sys 0.42.0",
]
[[package]]
name = "openssl"
version = "0.10.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b102428fd03bc5edf97f62620f7298614c45cedf287c271e7ed450bbaf83f2e1"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23bbbf7854cd45b83958ebe919f0e8e516793727652e27fda10a8384cfc790b7"
dependencies = [
"autocfg",
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "os_info"
version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c424bc68d15e0778838ac013b5b3449544d8133633d8016319e7e05a820b8c0"
dependencies = [
"log",
"serde",
"winapi",
]
[[package]]
name = "overload"
version = "0.1.1"
@ -2026,7 +2061,7 @@ dependencies = [
"base64 0.13.1",
"indexmap",
"line-wrap",
"quick-xml 0.26.0",
"quick-xml",
"serde",
"time",
]
@ -2104,15 +2139,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11bafc859c6815fbaffbbbf4229ecb767ac913fecb27f9ad4343662e9ef099ea"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.26.0"
@ -2276,30 +2302,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "rfd"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea"
dependencies = [
"block",
"dispatch",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"lazy_static",
"log",
"objc",
"objc-foundation",
"objc_id",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.37.0",
]
[[package]]
name = "ring"
version = "0.16.20"
@ -2381,6 +2383,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3"
dependencies = [
"windows-sys 0.42.0",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@ -2403,6 +2414,29 @@ dependencies = [
"untrusted",
]
[[package]]
name = "security-framework"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31c9bb296072e961fcbd8853511dd39c2d8be2deb1e17c6860b1d30732b323b4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.22.0"
@ -2492,6 +2526,18 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa 1.0.5",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "1.14.0"
@ -2797,27 +2843,6 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strum"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7ac893c7d471c8a21f31cfe213ec4f6d9afeed25537c772e08ef3f005f8729e"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "339f799d8b549e3744c7ac7feb216383e4005d94bdb22561b3ab8f3b808ae9fb"
dependencies = [
"heck 0.3.3",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "1.0.107"
@ -2898,7 +2923,7 @@ dependencies = [
"serde",
"unicode-segmentation",
"uuid 1.3.0",
"windows 0.39.0",
"windows",
"windows-implement",
"x11-dl",
]
@ -2921,6 +2946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe7e0f1d535e7cbbbab43c82be4fc992b84f9156c16c160955617e0260ebc449"
dependencies = [
"anyhow",
"attohttpc",
"cocoa",
"dirs-next",
"embed_plist",
@ -2933,15 +2959,14 @@ dependencies = [
"heck 0.4.1",
"http",
"ignore",
"notify-rust",
"objc",
"once_cell",
"open",
"os_info",
"percent-encoding",
"rand 0.8.5",
"raw-window-handle",
"regex",
"rfd",
"semver 1.0.16",
"serde",
"serde_json",
@ -2960,7 +2985,7 @@ dependencies = [
"uuid 1.3.0",
"webkit2gtk",
"webview2-com",
"windows 0.39.0",
"windows",
]
[[package]]
@ -3051,7 +3076,7 @@ dependencies = [
"thiserror",
"uuid 1.3.0",
"webview2-com",
"windows 0.39.0",
"windows",
]
[[package]]
@ -3070,7 +3095,7 @@ dependencies = [
"uuid 1.3.0",
"webkit2gtk",
"webview2-com",
"windows 0.39.0",
"windows",
"wry",
]
@ -3099,18 +3124,7 @@ dependencies = [
"thiserror",
"url",
"walkdir",
"windows 0.39.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c58de036c4d2e20717024de2a3c4bf56c301f07b21bc8ef9b57189fce06f1f3b"
dependencies = [
"quick-xml 0.23.1",
"strum",
"windows 0.39.0",
"windows",
]
[[package]]
@ -3513,18 +3527,6 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f219e0d211ba40266969f6dbdd90636da12f75bee4fc9d6c23d1260dadb51454"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.84"
@ -3638,7 +3640,7 @@ checksum = "b4a769c9f1a64a8734bde70caafac2b96cada12cd4aefa49196b3a386b8b4178"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows 0.39.0",
"windows",
"windows-implement",
]
@ -3663,7 +3665,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror",
"windows 0.39.0",
"windows",
"windows-bindgen",
"windows-metadata",
]
@ -3699,19 +3701,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647"
dependencies = [
"windows_aarch64_msvc 0.37.0",
"windows_i686_gnu 0.37.0",
"windows_i686_msvc 0.37.0",
"windows_x86_64_gnu 0.37.0",
"windows_x86_64_msvc 0.37.0",
]
[[package]]
name = "windows"
version = "0.39.0"
@ -3803,12 +3792,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608"
[[package]]
name = "windows_aarch64_msvc"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a"
[[package]]
name = "windows_aarch64_msvc"
version = "0.39.0"
@ -3821,12 +3804,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7"
[[package]]
name = "windows_i686_gnu"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1"
[[package]]
name = "windows_i686_gnu"
version = "0.39.0"
@ -3839,12 +3816,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640"
[[package]]
name = "windows_i686_msvc"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c"
[[package]]
name = "windows_i686_msvc"
version = "0.39.0"
@ -3857,12 +3828,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605"
[[package]]
name = "windows_x86_64_gnu"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.39.0"
@ -3881,12 +3846,6 @@ version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463"
[[package]]
name = "windows_x86_64_msvc"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.39.0"
@ -3942,7 +3901,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows 0.39.0",
"windows",
"windows-implement",
]

View File

@ -1,6 +1,6 @@
[package]
name = "lume"
version = "0.1.1"
version = "0.2.0"
description = "nostr client"
authors = ["Ren Amamiya"]
license = ""
@ -16,7 +16,7 @@ tauri-build = { version = "1.2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["clipboard-all", "notification-all", "shell-open", "system-tray", "window-start-dragging"] }
tauri = { version = "1.2", features = ["clipboard-read-text", "clipboard-write-text", "http-request", "os-all", "shell-open", "system-tray", "window-close", "window-start-dragging"] }
[dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace"

View File

@ -9,21 +9,38 @@ CREATE TABLE
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- add default relays
-- relay status:
-- 0: off
-- 1: on
INSERT INTO
relays (relay_url, relay_status)
VALUES
("wss://relay.damus.io", "1"),
("wss://relay.uselume.xyz", "0"),
("wss://eden.nostr.land", "0"),
("wss://nostr-pub.wellorder.net", "1"),
("wss://nostr.bongbong.com", "1"),
("wss://nostr.zebedee.cloud", "1"),
("wss://nostr.fmt.wiz.biz", "1"),
("wss://nostr.walletofsatoshi.com", "1"),
("wss://nostr.walletofsatoshi.com", "0"),
("wss://relay.snort.social", "1"),
("wss://offchain.pub", "1"),
("wss://brb.io", "0"),
("wss://relay.current.fyi", "1"),
("wss://nostr.relayer.se", "0"),
("wss://nostr.bitcoiner.social", "1"),
("wss://relay.nostr.info", "1"),
("wss://relay.zeh.app", "0"),
("wss://nostr-01.dorafactory.org", "1"),
("wss://nostr.zhongwen.world", "1"),
("wss://nostro.cc", "1"),
("wss://relay.nostr.net.in", "1"),
("wss://nos.lol", "1");
-- create accounts
-- is_active (part of multi-account feature):
-- 0: false
-- 1: true
CREATE TABLE
accounts (
id TEXT PRIMARY KEY,
@ -31,27 +48,30 @@ CREATE TABLE
npub TEXT NOT NULL,
nsec TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 0,
metadata JSON
metadata TEXT
);
-- create follows
-- kind (part of multi-newsfeed feature):
-- 0: direct
-- 1: follow of follow
CREATE TABLE
follows (
id INTEGER PRIMARY KEY,
pubkey TEXT NOT NULL,
account TEXT NOT NULL,
kind INTEGER NOT NULL DEFAULT 0,
metadata JSON
metadata TEXT
);
-- create index for pubkey in follows
CREATE UNIQUE INDEX index_pubkey ON follows (pubkey);
CREATE UNIQUE INDEX index_pubkey_on_follows ON follows (pubkey);
-- create cache profiles
CREATE TABLE
cache_profiles (
id TEXT PRIMARY KEY,
metadata JSON,
metadata TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@ -65,6 +85,20 @@ CREATE TABLE
kind INTEGER NOT NULL DEFAULT 1,
tags TEXT NOT NULL,
content TEXT NOT NULL,
relay TEXT,
is_multi BOOLEAN DEFAULT 0
);
parent_id TEXT,
parent_comment_id TEXT
);
-- create settings
CREATE TABLE
settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
setting_key TEXT NOT NULL,
setting_value TEXT NOT NULL
);
-- add default setting
INSERT INTO
settings (setting_key, setting_value)
VALUES
("last_login", "0");

View File

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

View File

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

View File

@ -4,6 +4,13 @@
@import './assets/editor.css';
/* Fixed next/image bug, source: https://nextjs.org/docs/api-reference/next/image */
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
img[loading='lazy'] {
clip-path: inset(0.6px);
}
}
/* For Webkit-based browsers (Chrome, Safari and Opera) */
.scrollbar-hide::-webkit-scrollbar {
display: none;
@ -23,3 +30,12 @@
.border {
background-clip: padding-box;
}
@keyframes loop {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}

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 { PlusIcon } from '@radix-ui/react-icons';
import { useLocalStorage } from '@rehooks/local-storage';
import { getVersion } from '@tauri-apps/api/app';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import Database from 'tauri-plugin-sql-api';
export default function AccountColumn() {
const [users, setUsers] = useState([]);
const [currentUser]: any = useLocalStorage('current-user');
const [version, setVersion] = useState(null);
const getAccounts = useCallback(async () => {
const db = await Database.load('sqlite:lume.db');
const result: any = await db.select('SELECT * FROM accounts');
setUsers(result);
const getAppVersion = useCallback(async () => {
const appVersion = await getVersion();
setVersion(appVersion);
}, []);
useEffect(() => {
getAccounts().catch(console.error);
}, [getAccounts]);
getAppVersion().catch(console.error);
}, [getAppVersion]);
return (
<div className="flex h-full flex-col items-center justify-between px-2 pt-12 pb-4">
<div className="flex h-full flex-col items-center justify-between px-2 pt-4 pb-4">
<div className="flex flex-col gap-4">
{users.map((user, index) => (
<Account key={index} user={user} current={currentUser.id} />
))}
<Link
href="/explore"
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800"
>
<LumeSymbol className="h-6 w-auto text-zinc-400 group-hover:text-zinc-200" />
</Link>
<AccountList />
<Link
href="/onboarding"
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center overflow-hidden rounded-full border-2 border-dashed border-zinc-600 hover:border-zinc-400"
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-zinc-600 hover:border-zinc-400"
>
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-200" />
</Link>
</div>
<div className="flex flex-col gap-4">
<LumeSymbol className="h-8 w-auto text-zinc-700" />
<div className="flex flex-col gap-0.5 text-center">
<span className="animate-moveBg from-fuchsia-300 via-orange-100 to-amber-300 text-sm font-black uppercase leading-tight text-zinc-600 hover:bg-gradient-to-r hover:bg-clip-text hover:text-transparent">
Lume
</span>
<span className="text-xs font-medium text-zinc-700">v{version}</span>
</div>
</div>
);

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 CreatePost from '@components/columns/navigator/createPost';
import { UserDropdownMenu } from '@components/columns/navigator/userDropdownMenu';
import { PlusIcon } from '@radix-ui/react-icons';
import { useLocalStorage } from '@rehooks/local-storage';
import Messages from '@components/columns/navigator/messages';
import Newsfeed from '@components/columns/navigator/newsfeed';
export default function NavigatorColumn() {
const [currentUser]: any = useLocalStorage('current-user');
const profile = JSON.parse(currentUser.metadata);
return (
<div className="flex h-full flex-col flex-wrap justify-between overflow-hidden px-2 pt-3 pb-4">
<div className="flex flex-col gap-4">
{/* Create post */}
<div className="flex flex-col rounded-lg bg-zinc-900 ring-1 ring-white/10">
<div className="flex flex-col p-2">
<div className="flex items-center justify-between">
<h5 className="font-semibold leading-tight text-zinc-100">{profile.display_name || ''}</h5>
<UserDropdownMenu pubkey={currentUser.id} />
</div>
<span className="text-sm leading-tight text-zinc-500">@{profile.username || ''}</span>
</div>
<div className="p-2">
<CreatePost />
</div>
</div>
{/* Newsfeed */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between px-2">
<h3 className="text-sm font-bold text-zinc-400">Newsfeed</h3>
<button
type="button"
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900"
>
<PlusIcon className="h-3 w-3 text-zinc-400 group-hover:text-zinc-100" />
</button>
</div>
<div className="flex flex-col gap-1 text-zinc-500">
<ActiveLink
href={`/newsfeed/following`}
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
className="flex h-10 items-center gap-1 rounded-lg px-2.5 text-sm font-medium hover:bg-zinc-900"
>
<span>#</span>
<span>following</span>
</ActiveLink>
<ActiveLink
href={`/newsfeed/global`}
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
className="flex h-10 items-center gap-1 rounded-lg px-2.5 text-sm font-medium hover:bg-zinc-900"
>
<span>#</span>
<span>global</span>
</ActiveLink>
</div>
</div>
{/* Messages */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between px-2">
<h3 className="text-sm font-bold text-zinc-400">Direct Messages</h3>
<button
type="button"
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900"
>
<PlusIcon className="h-3 w-3 text-zinc-400 group-hover:text-zinc-100" />
</button>
</div>
<div></div>
</div>
</div>
<div className="relative flex h-full flex-col gap-1 overflow-hidden pt-4">
{/* Newsfeed */}
<Newsfeed />
{/* Messages */}
<Messages />
</div>
);
}

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}
name={alt}
variant="beam"
square={true}
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
/>
) : (
<Image alt={alt} onError={setError} src={src} fill={fill} className={className} />
<Image
src={src}
alt={alt}
fill={fill}
className={className}
onError={setError}
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
priority
/>
)}
</>
);

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/contexts/relay';
import { RelayContext } from '@components/relaysProvider';
import { dateToUnix, hoursAgo } from '@utils/getDate';
import { activeAccountAtom } from '@stores/account';
import { hasNewerNoteAtom } from '@stores/note';
import { relaysAtom } from '@stores/relays';
import { ReloadIcon } from '@radix-ui/react-icons';
import { useLocalStorage } from '@rehooks/local-storage';
import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { dateToUnix } from '@utils/getDate';
import { createCacheNote, getAllFollowsByID, updateLastLoginTime } from '@utils/storage';
import { pubkeyArray } from '@utils/transform';
export const NoteConnector = memo(function NoteConnector({
setParentReload,
setHasNewNote,
currentDate,
}: {
setParentReload: any;
setHasNewNote: any;
currentDate: any;
}) {
const { db }: any = useContext(DatabaseContext);
const relayPool: any = useContext(RelayContext);
import { TauriEvent } from '@tauri-apps/api/event';
import { appWindow, getCurrent } from '@tauri-apps/api/window';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
const [follows]: any = useLocalStorage('follows');
const [relays]: any = useLocalStorage('relays');
export default function NoteConnector() {
const pool: any = useContext(RelayContext);
const [reload, setReload] = useState(false);
const timeout = useRef(null);
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
const relays = useAtomValue(relaysAtom);
const [activeAccount] = useAtom(activeAccountAtom);
const reloadNewsfeed = () => {
setParentReload(true);
setReload(true);
timeout.current = setTimeout(() => {
setReload(false);
}, 2000);
};
const [isOnline] = useState(true);
const now = useRef(new Date());
const insertDB = useCallback(
async (event: any) => {
await db.execute(
`INSERT OR IGNORE INTO
cache_notes
(id, pubkey, created_at, kind, tags, content) VALUES
(
"${event.id}",
"${event.pubkey}",
"${event.created_at}",
"${event.kind}",
'${JSON.stringify(event.tags)}',
"${event.content}"
);`
);
},
[db]
);
const fetchEvent = useCallback(() => {
relayPool.subscribe(
[
{
kinds: [1],
authors: follows,
since: dateToUnix(hoursAgo(12, currentDate)),
},
],
relays,
(event: any) => {
// show trigger update newer event
if (event.created_at > dateToUnix(currentDate)) {
setHasNewNote(true);
const subscribe = useCallback(() => {
getAllFollowsByID(activeAccount.id).then((follows) => {
pool.subscribe(
[
{
kinds: [1],
authors: pubkeyArray(follows),
since: dateToUnix(now.current),
},
],
relays,
(event: any) => {
// insert event to local database
createCacheNote(event);
setHasNewerNote(true);
}
// insert event to local database
insertDB(event).catch(console.error);
},
undefined,
(events: any, relayURL: any) => {
console.log(events, relayURL);
}
);
}, [relayPool, follows, currentDate, relays, insertDB, setHasNewNote]);
);
});
}, [activeAccount.id, pool, relays, setHasNewerNote]);
useEffect(() => {
fetchEvent();
return () => {
clearTimeout(timeout.current);
};
}, [fetchEvent]);
subscribe();
getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
updateLastLoginTime(now.current);
appWindow.close();
});
}, [activeAccount.id, pool, relays, setHasNewerNote, subscribe]);
return (
<div className="relative flex h-12 items-center justify-between border-b border-zinc-800 px-6 shadow-input">
<div>
<h3 className="text-sm font-semibold text-zinc-500"># following</h3>
<>
<div className="inline-flex items-center gap-1 rounded-md py-1 px-1.5 hover:bg-zinc-900">
<span className="relative flex h-1.5 w-1.5">
<span
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${
isOnline ? 'bg-green-400' : 'bg-red-400'
}`}
></span>
<span
className={`relative inline-flex h-1.5 w-1.5 rounded-full ${isOnline ? 'bg-green-400' : 'bg-amber-400'}`}
></span>
</span>
<p className="text-xs font-medium text-zinc-500">{isOnline ? 'Online' : 'Offline'}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => reloadNewsfeed()}
className={`${reload ? 'animate-spin' : ''} rounded-full p-1 hover:bg-zinc-800`}
>
<ReloadIcon className="h-3.5 w-3.5 text-zinc-500" />
</button>
<div className="inline-flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-800 px-2.5 py-1">
{/* #TODO: get user network status */}
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-green-500"></span>
</span>
<p className="text-xs font-medium text-zinc-500">Online</p>
</div>
</div>
</div>
</>
);
});
}

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

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 Link from 'next/link';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function LinkCard({ data }: { data: object }) {
export default function LinkCard({ data }: { data: any }) {
return (
<Link
href={data['url']}
target={'_blank'}
className="relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-700"
className="relative mt-2 flex flex-col overflow-hidden rounded-lg border border-zinc-700"
>
<div className="relative aspect-video h-auto w-full">
<Image src={data['image']} alt="image preview" fill={true} className="object-cover" />

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 { UserRepost } from '@components/note/atoms/userRepost';
import { Content } from '@components/note/content';
import { Placeholder } from '@components/note/placeholder';
import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention';
import { LoopIcon } from '@radix-ui/react-icons';
import useLocalStorage from '@rehooks/local-storage';
import { memo, useContext, useState } from 'react';
import { relaysAtom } from '@stores/relays';
export const Repost = memo(function Repost({ root, user }: { root: any; user: string }) {
const relayPool: any = useContext(RelayContext);
const [relays]: any = useLocalStorage('relays');
const [events, setEvents] = useState([]);
import { createCacheNote, getNoteByID } from '@utils/storage';
relayPool.subscribe(
[
{
ids: [root[0][1]],
since: 0,
kinds: [1],
import destr from 'destr';
import { useAtomValue } from 'jotai';
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import reactStringReplace from 'react-string-replace';
export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const relays = useAtomValue(relaysAtom);
const [event, setEvent] = useState(null);
const unsubscribe = useRef(null);
const fetchEvent = useCallback(() => {
unsubscribe.current = pool.subscribe(
[
{
ids: [id],
kinds: [1],
},
],
relays,
(event: any) => {
// update state
setEvent(event);
// insert to database
createCacheNote(event);
},
],
relays,
(event: any) => {
setEvents((events) => [event, ...events]);
},
undefined,
(events: any, relayURL: any) => {
console.log(events, relayURL);
}
);
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
}, [id, pool, relays]);
if (events !== null && Object.keys(events).length > 0) {
useEffect(() => {
getNoteByID(id).then((res) => {
if (res) {
setEvent(res);
} else {
fetchEvent();
}
});
return () => {
unsubscribe.current;
};
}, [fetchEvent, id]);
const content = useMemo(() => {
let parsedContent = event ? event.content : null;
if (parsedContent !== null) {
// get data tags
const tags = destr(event.tags);
// handle urls
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => (
<a key={match + i} href={match} target="_blank" rel="noreferrer">
{match}
</a>
));
// handle #-hashtags
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
<span key={match + i} className="cursor-pointer text-fuchsia-500">
#{match}
</span>
));
// handle mentions
if (tags.length > 0) {
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
if (tags[match][0] === 'p') {
// @-mentions
return <UserMention key={match + i} pubkey={tags[match][1]} />;
} else {
return;
}
});
}
}
return parsedContent;
}, [event]);
if (event) {
return (
<div className="flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-6 px-6">
<div className="flex items-center gap-1 pl-8 text-sm">
<LoopIcon className="h-4 w-4 text-zinc-400" />
<div className="ml-2">
<UserRepost pubkey={user} />
<div className="relative mt-3 mb-2 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3">
<div className="relative z-10 flex flex-col">
<UserExtend pubkey={event.pubkey} time={event.created_at} />
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
{content}
</div>
</div>
</div>
</div>
{events[0].content && <Content data={events[0]} />}
</div>
);
} else {
return (
<div className="border-b border-zinc-800">
<Placeholder />
</div>
);
return <div className="mt-2 h-6 animate-pulse select-text flex-col rounded bg-zinc-700 pb-5"></div>;
}
});

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 default function RelayProvider({ relays, children }: { relays: any; children: React.ReactNode }) {
const value = useMemo(() => new RelayPool(relays, { useEventCache: true }), [relays]);
export default function RelayProvider({ relays, children }: { relays: Array<string>; children: React.ReactNode }) {
const value = useMemo(() => new RelayPool(relays, { useEventCache: false, logSubscriptions: false }), [relays]);
return <RelayContext.Provider value={value}>{children}</RelayContext.Provider>;
}

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

View File

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

View File

@ -1,102 +1,58 @@
import BaseLayout from '@layouts/base';
import FullscreenLayout from '@layouts/fullscreen';
import { getAccounts } from '@utils/storage';
import LumeSymbol from '@assets/icons/Lume';
import { useLocalStorage } from '@rehooks/local-storage';
import { motion } from 'framer-motion';
import { useRouter } from 'next/router';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useEffect, useRef, useState } from 'react';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useEffect } from 'react';
export default function Page() {
const router = useRouter();
const [currentUser]: any = useLocalStorage('current-user');
const [loading, setLoading] = useState(true);
const timer = useRef(null);
useEffect(() => {
if (currentUser) {
timer.current = setTimeout(() => {
setLoading(false);
router.push('/newsfeed/following');
}, 1000);
} else {
timer.current = setTimeout(() => {
setLoading(false);
router.push('/onboarding');
}, 1000);
}
// clean up
return () => {
clearTimeout(timer.current);
};
}, [currentUser, router]);
getAccounts()
.then((res: any) => {
if (res.length > 0) {
router.push('/init');
} else {
router.push('/onboarding');
}
})
.catch(console.error);
}, [router]);
return (
<div className="relative flex h-full flex-col items-center justify-between">
<div>{/* spacer */}</div>
<div className="flex flex-col items-center gap-4">
<motion.div layoutId="logo" className="relative">
<LumeSymbol className="h-16 w-16 text-white" />
</motion.div>
<div className="flex flex-col items-center gap-0.5">
<motion.h2
layoutId="subtitle"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-4xl font-medium text-transparent"
>
A censorship-resistant social network
</motion.h2>
<motion.h1
layoutId="title"
className="bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 bg-clip-text text-5xl font-bold text-transparent"
>
built on nostr
</motion.h1>
<div className="relative h-full overflow-hidden">
{/* dragging area */}
<div data-tauri-drag-region className="absolute top-0 left-0 z-20 h-16 w-full bg-transparent" />
{/* end dragging area */}
<div className="relative flex h-full flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2">
<LumeSymbol className="h-16 w-16 text-black dark:text-white" />
<div className="text-center">
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">Did you know?</h3>
<p className="font-medium text-zinc-300 dark:text-zinc-600">
No one can&apos;t stop you use bitcoin and nostr
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 pb-16">
<div className="h-10">
{loading ? (
<svg
className="h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<></>
)}
</div>
</div>
{/* background */}
<div className="absolute inset-0 bg-gradient-to-r from-fuchsia-400/10 to-orange-100/10 opacity-100 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)]">
<svg
aria-hidden="true"
className="dark:fill-white/2.5 absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:stroke-white/5"
>
<defs>
<pattern id=":R11d6:" width="72" height="56" patternUnits="userSpaceOnUse" x="-12" y="4">
<path d="M.5 56V.5H72" fill="none"></path>
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth="0" fill="url(#:R11d6:)"></rect>
<svg x="-12" y="4" className="overflow-visible">
<rect strokeWidth="0" width="73" height="57" x="288" y="168"></rect>
<rect strokeWidth="0" width="73" height="57" x="144" y="56"></rect>
<rect strokeWidth="0" width="73" height="57" x="504" y="168"></rect>
<rect strokeWidth="0" width="73" height="57" x="720" y="336"></rect>
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<svg
className="h-5 w-5 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</svg>
</div>
</div>
{/* end background */}
</div>
);
}
@ -110,9 +66,5 @@ Page.getLayout = function getLayout(
| ReactFragment
| ReactPortal
) {
return (
<BaseLayout>
<FullscreenLayout>{page}</FullscreenLayout>
</BaseLayout>
);
return <BaseLayout>{page}</BaseLayout>;
};

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

View File

@ -1,127 +1,48 @@
import BaseLayout from '@layouts/base';
import NewsFeedLayout from '@layouts/newsfeed';
import WithSidebarLayout from '@layouts/withSidebar';
import { DatabaseContext } from '@components/contexts/database';
import { NoteConnector } from '@components/note/connector';
import FormBase from '@components/form/base';
import { NoteBase } from '@components/note/base';
import { Placeholder } from '@components/note/placeholder';
import { Repost } from '@components/note/repost';
import { Single } from '@components/note/single';
import { dateToUnix } from '@utils/getDate';
import { notesAtom } from '@stores/note';
import { ArrowUpIcon } from '@radix-ui/react-icons';
import { writeStorage } from '@rehooks/local-storage';
import { useCallback, useState } from 'react';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useEffect, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom } from 'jotai';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, Suspense, useRef } from 'react';
export default function Page() {
const { db }: any = useContext(DatabaseContext);
const [data]: any = useAtom(notesAtom);
const parentRef = useRef(null);
const [data, setData] = useState([]);
const [parentReload, setParentReload] = useState(false);
const [hasNewNote, setHasNewNote] = useState(false);
const now = useRef(new Date());
const limit = useRef(30);
const offset = useRef(0);
const loadMore = useCallback(async () => {
offset.current += limit.current;
// next query
const result = await db.select(
`SELECT * FROM
cache_notes
WHERE created_at <= ${dateToUnix(now.current)}
ORDER BY created_at DESC
LIMIT ${limit.current} OFFSET ${offset.current}`
);
setData((data) => [...data, ...result]);
}, [db]);
const loadNewest = useCallback(async () => {
const result = await db.select(
`SELECT * FROM
cache_notes
WHERE created_at > ${dateToUnix(now.current)}
ORDER BY created_at DESC
LIMIT ${limit.current}`
);
setData((data) => [...result, ...data]);
setHasNewNote(false);
}, [db]);
const ItemContent = useCallback(
(index: string | number) => {
const event = data[index];
if (event.content.includes('#[0]') && event.tags[0][0] == 'e') {
// type: repost
return <Repost root={event.tags} user={event.pubkey} />;
} else {
// type: default
return <Single event={event} />;
}
},
[data]
);
useEffect(() => {
const getData = async () => {
const result = await db.select(
`SELECT * FROM cache_notes WHERE created_at <= ${dateToUnix(now.current)} ORDER BY created_at DESC LIMIT ${
limit.current
}`
);
if (result) {
setData(result);
writeStorage('settings', new Date());
}
};
getData().catch(console.error);
}, [db, parentReload]);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
const virtualizer = useVirtualizer({
count: data.length,
estimateSize: () => 500,
getScrollElement: () => parentRef.current,
getItemKey: (index) => data[index].id,
});
const items = virtualizer.getVirtualItems();
return (
<div className="relative h-full w-full">
<NoteConnector setParentReload={setParentReload} setHasNewNote={setHasNewNote} currentDate={now.current} />
{hasNewNote && (
<div className="absolute top-16 left-1/2 z-50 -translate-x-1/2 transform">
<button
onClick={() => loadNewest()}
className="inline-flex h-8 transform items-center justify-center gap-1 rounded-full bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 pl-3 pr-3.5 text-sm shadow-lg active:translate-y-1"
>
<ArrowUpIcon className="h-4 w-4" />
<span className="drop-shadow-md">Load newest</span>
</button>
<div ref={parentRef} className="scrollbar-hide h-full w-full overflow-y-auto" style={{ contain: 'strict' }}>
<div className="relative">
<FormBase />
</div>
<Suspense fallback={<Placeholder />}>
<div>
{items.length > 0 && (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
<div className="absolute top-0 left-0 w-full" style={{ transform: `translateY(${items[0].start}px)` }}>
{items.map((virtualRow) => (
<div key={virtualRow.key} data-index={virtualRow.index} ref={virtualizer.measureElement}>
<NoteBase event={data[virtualRow.index]} />
</div>
))}
</div>
</div>
)}
</div>
)}
<Virtuoso
data={data}
itemContent={ItemContent}
components={{
EmptyPlaceholder: () => <Placeholder />,
ScrollSeekPlaceholder: () => <Placeholder />,
}}
computeItemKey={computeItemKey}
scrollSeekConfiguration={{
enter: (velocity) => Math.abs(velocity) > 800,
exit: (velocity) => Math.abs(velocity) < 500,
}}
endReached={loadMore}
overscan={800}
increaseViewportBy={1000}
className="relative h-full w-full"
style={{
contain: 'strict',
}}
/>
</Suspense>
</div>
);
}
@ -137,7 +58,7 @@ Page.getLayout = function getLayout(
) {
return (
<BaseLayout>
<NewsFeedLayout>{page}</NewsFeedLayout>
<WithSidebarLayout>{page}</WithSidebarLayout>
</BaseLayout>
);
};

View File

@ -1,25 +1,17 @@
import BaseLayout from '@layouts/base';
import OnboardingLayout from '@layouts/onboarding';
import { DatabaseContext } from '@components/contexts/database';
import { RelayContext } from '@components/contexts/relay';
import { RelayContext } from '@components/relaysProvider';
import { relaysAtom } from '@stores/relays';
import { createAccount } from '@utils/storage';
import { EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons';
import { useLocalStorage, writeStorage } from '@rehooks/local-storage';
import { motion } from 'framer-motion';
import { useAtomValue } from 'jotai';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent } from 'nostr-tools';
import {
JSXElementConstructor,
ReactElement,
ReactFragment,
ReactPortal,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useMemo, useState } from 'react';
import { Config, names, uniqueNamesGenerator } from 'unique-names-generator';
const config: Config = {
@ -28,12 +20,9 @@ const config: Config = {
export default function Page() {
const router = useRouter();
const pool: any = useContext(RelayContext);
const { db }: any = useContext(DatabaseContext);
const relayPool: any = useContext(RelayContext);
const [relays] = useLocalStorage('relays');
const relays = useAtomValue(relaysAtom);
const [type, setType] = useState('password');
const [loading, setLoading] = useState(false);
@ -44,6 +33,30 @@ export default function Page() {
const npub = nip19.npubEncode(pubKey);
const nsec = nip19.nsecEncode(privKey);
// auto-generated profile metadata
const metadata = useMemo(
() => ({
display_name: name,
name: name,
username: name.toLowerCase(),
picture: 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89',
}),
[name]
);
// build profile
const data = useMemo(
() => ({
pubkey: pubKey,
privkey: privKey,
npub: npub,
nsec: nsec,
metadata: metadata,
}),
[metadata, npub, nsec, privKey, pubKey]
);
// toggle privatek key
const showPrivateKey = () => {
if (type === 'password') {
setType('text');
@ -52,32 +65,13 @@ export default function Page() {
}
};
// auto-generated profile
const data = useMemo(
() => ({
display_name: name,
name: name,
username: name.toLowerCase(),
picture: 'https://bafybeidfsbrzqbvontmucteomoz2rkrxugu462l5hyhh6uioslkfzzs4oq.ipfs.w3s.link/avatar-11.png',
banner: 'https://bafybeiacwit7hjmdefqggxqtgh6ht5dhth7ndptwn2msl5kpkodudsr7py.ipfs.w3s.link/banner-1.jpg',
}),
[name]
);
const insertDB = useCallback(async () => {
await db.execute(
`INSERT INTO accounts (id, privkey, npub, nsec, metadata) VALUES ("${pubKey}", "${privKey}", "${npub}", "${nsec}", '${JSON.stringify(
data
)}')`
);
}, [data, db, npub, nsec, privKey, pubKey]);
const createAccount = async () => {
// create account and broadcast to all relays
const submit = () => {
setLoading(true);
// build event
const event: any = {
content: JSON.stringify(data),
content: JSON.stringify(metadata),
created_at: Math.floor(Date.now() / 1000),
kind: 0,
pubkey: pubKey,
@ -86,129 +80,113 @@ export default function Page() {
event.id = getEventHash(event);
event.sig = signEvent(event, privKey);
insertDB()
// insert to database then broadcast
createAccount(data)
.then(() => {
// publish to relays
relayPool.publish(event, relays);
// set currentUser in global state
writeStorage('current-user', {
metadata: JSON.stringify(data),
npub: npub,
privkey: privKey,
pubkey: pubKey,
pool.publish(event, relays);
router.push({
pathname: '/onboarding/create/step-2',
query: { id: pubKey, privkey: privKey },
});
// redirect to pre-follow
setTimeout(() => {
setLoading(false);
router.push('/onboarding/create/pre-follows');
}, 1500);
})
.catch(console.error);
};
return (
<div className="flex h-full flex-col justify-between px-8">
<div>{/* spacer */}</div>
<motion.div layoutId="form">
<div className="mb-8 flex flex-col gap-3">
<motion.h1
layoutId="title"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent"
>
Create new key
</motion.h1>
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
Lume will generate key with default profile for you, you can edit it later, and please store your key safely
so you can restore your account or use other client
</motion.h2>
<div className="grid h-full w-full grid-rows-5">
<div className="row-span-1 flex items-center justify-center">
<div>
<h1 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent">
Create new account
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">Public Key</label>
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
readOnly
value={npub}
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
/>
</div>
<div className="row-span-4">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">Public Key</label>
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
readOnly
value={npub}
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
/>
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">Private Key</label>
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
readOnly
type={type}
value={nsec}
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
/>
<button
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === 'password' ? (
<EyeClosedIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
) : (
<EyeOpenIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
)}
</button>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">Private Key</label>
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
readOnly
type={type}
value={nsec}
className="relative w-full rounded-lg border border-black/5 py-2.5 pl-3.5 pr-11 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
/>
<button
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === 'password' ? (
<EyeClosedIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
) : (
<EyeOpenIcon className="h-5 w-5 text-zinc-500 group-hover:text-zinc-200" />
)}
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">Default Profile (you can change it later)</label>
<div className="relative max-w-sm shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<div className="relative max-w-sm rounded-lg border border-black/5 px-3.5 py-4 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600">
<div className="flex space-x-4">
<div className="relative h-10 w-10 rounded-full">
<Image className="inline-block rounded-full" src={data.picture} alt="" fill={true} />
</div>
<div className="flex-1 space-y-4 py-1">
<div className="flex items-center gap-2">
<p className="font-semibold">{data.display_name}</p>
<p className="text-zinc-400">@{data.username}</p>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">Default Profile (you can change it later)</label>
<div className="relative w-full shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<div className="relative w-full rounded-lg border border-black/5 px-3.5 py-4 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600">
<div className="flex space-x-2">
<div className="relative h-11 w-11 rounded-md">
<Image className="inline-block rounded-md" src={metadata.picture} alt="" fill={true} />
</div>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 h-2 rounded bg-zinc-700"></div>
<div className="col-span-1 h-2 rounded bg-zinc-700"></div>
<div className="flex-1 space-y-2 py-1">
<div className="flex items-center gap-1">
<p className="font-semibold">{metadata.display_name}</p>
<p className="text-zinc-400">@{metadata.username}</p>
</div>
<div className="space-y-1">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 h-2 rounded bg-zinc-700"></div>
<div className="col-span-1 h-2 rounded bg-zinc-700"></div>
</div>
<div className="h-2 rounded bg-zinc-700"></div>
</div>
<div className="h-2 rounded bg-zinc-700"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</motion.div>
<motion.div layoutId="action" className="pb-5">
<div className="flex h-10 items-center">
{loading === true ? (
<svg
className="h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<div className="flex h-10 items-center justify-center">
{loading === true ? (
<svg
className="h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<button
onClick={() => createAccount()}
className="transform rounded-lg border border-white/10 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium shadow-input shadow-black/5 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-50 dark:shadow-black/10"
onClick={() => submit()}
className="w-full transform rounded-lg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 px-3.5 py-2.5 font-medium text-zinc-800 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
<span className="drop-shadow-lg">Continue </span>
</button>
</div>
)}
)}
</div>
</div>
</motion.div>
</div>
</div>
);
}
@ -222,9 +200,5 @@ Page.getLayout = function getLayout(
| ReactFragment
| ReactPortal
) {
return (
<BaseLayout>
<OnboardingLayout>{page}</OnboardingLayout>
</BaseLayout>
);
return <BaseLayout>{page}</BaseLayout>;
};

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 OnboardingLayout from '@layouts/onboarding';
import { motion } from 'framer-motion';
import { ArrowRightIcon } from '@radix-ui/react-icons';
import Image from 'next/image';
import Link from 'next/link';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
const PLEBS = [
'https://133332.xyz/p.jpg',
'https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp',
'https://i.imgur.com/f8SyhRL.jpg',
'http://nostr.build/i/6369.jpg',
'https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg',
'https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif',
'https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg',
'https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg',
'https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp',
'https://avatars.githubusercontent.com/u/89577423',
'https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg',
'https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy',
'https://phase1.attract-eu.com/wp-content/uploads/2020/03/ATTRACT_HPLM.png',
'https://www.retro-synthwave.com/wp-content/uploads/2017/01/PowerGlove-23.jpg',
'https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp',
'https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif',
'https://i.imgur.com/VGpUNFS.jpg',
'https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg',
'https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg',
'https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg',
'https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg',
'https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg',
'https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png',
'https://pbs.twimg.com/profile_images/1569570198348337152/-n1KD74u_400x400.jpg',
'https://pbs.twimg.com/profile_images/1600149653898596354/5PVe-r-J_400x400.jpg',
'https://pbs.twimg.com/profile_images/1639659216372658178/Dnn-Ysp-_400x400.jpg',
'https://pbs.twimg.com/profile_images/1554429112978120706/yr1hXl6R_400x400.jpg',
'https://pbs.twimg.com/profile_images/1615478486688272385/q2ECeZDX_400x400.jpg',
'https://pbs.twimg.com/profile_images/1638644441773748226/tNsA6RpG_400x400.jpg',
'https://pbs.twimg.com/profile_images/1607882836740120576/3Tg1mTYJ_400x400.jpg',
'https://pbs.twimg.com/profile_images/1401907430339002369/WKrP9Esn_400x400.jpg',
'https://pbs.twimg.com/profile_images/1523971278478131200/TMPzfvhE_400x400.jpg',
'https://pbs.twimg.com/profile_images/1626421539884204032/aj4tmzsk_400x400.png',
'https://pbs.twimg.com/profile_images/1582771691779985408/C9MHYIgt_400x400.jpg',
'https://pbs.twimg.com/profile_images/1409612480465276931/38Vyx4e8_400x400.jpg',
'https://pbs.twimg.com/profile_images/1549826566787588098/MlduJCZO_400x400.jpg',
'https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q_400x400.jpeg',
'https://pbs.twimg.com/profile_images/1548660003522887682/1QMHmles_400x400.jpg',
'https://pbs.twimg.com/profile_images/1362497143999787013/KLUoN1Vn_400x400.png',
'https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg',
];
const DURATION = 50000;
const ROWS = 7;
const PLEBS_PER_ROW = 20;
const random = (min, max) => Math.floor(Math.random() * (max - min)) + min;
const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random());
const InfiniteLoopSlider = ({ children, duration, reverse }: { children: any; duration: any; reverse: any }) => {
return (
<div>
<div
className="flex w-fit"
style={{
animationName: 'loop',
animationIterationCount: 'infinite',
animationDirection: reverse ? 'reverse' : 'normal',
animationDuration: duration + 'ms',
animationTimingFunction: 'linear',
}}
>
{children}
{children}
</div>
</div>
);
};
export default function Page() {
return (
<div className="flex h-full flex-col justify-between px-8">
<div>{/* spacer */}</div>
<div className="flex flex-col gap-3">
<motion.h1
layoutId="title"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent"
>
Other social network require email/password
<br />
nostr use{' '}
<span className="bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 bg-clip-text text-transparent">
public/private key instead
</span>
</motion.h1>
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
If you have used nostr before, you can import your own private key. Otherwise, you can create a new key or use
auto-generated account created by system.
</motion.h2>
<motion.div layoutId="form"></motion.div>
<motion.div layoutId="action" className="mt-4 flex gap-2">
<div className="grid h-full w-full grid-rows-5">
<div className="row-span-3 overflow-hidden">
<div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
{[...new Array(ROWS)].map((_, i) => (
<InfiniteLoopSlider key={i} duration={random(DURATION - 5000, DURATION + 20000)} reverse={i % 2}>
{shuffle(PLEBS)
.slice(0, PLEBS_PER_ROW)
.map((tag) => (
<div
key={tag}
className="relative mr-4 flex h-11 w-11 items-center gap-2 rounded-md bg-zinc-900 px-4 py-1.5 shadow-xl"
>
<Image
src={tag}
alt={tag}
fill={true}
className="rounded-md border border-zinc-900"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
priority
/>
</div>
))}
</InfiniteLoopSlider>
))}
<div className="pointer-events-none absolute inset-0 bg-fade" />
</div>
</div>
<div className="row-span-2 flex w-full flex-col items-center gap-8 overflow-hidden pt-10">
<h1 className="animate-moveBg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-5xl font-bold leading-none text-transparent">
Let&apos;s start!
</h1>
<div className="mt-4 flex flex-col items-center gap-1.5">
<Link
href="/onboarding/create"
className="hover:bg-zinc-900/2.5 transform rounded-lg border border-black/5 bg-zinc-800 px-3.5 py-2 font-medium ring-1 ring-inset ring-zinc-900/10 hover:text-zinc-900 active:translate-y-1 dark:text-zinc-300 dark:ring-white/10 dark:hover:bg-zinc-700 dark:hover:text-white"
className="relative inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full bg-zinc-900 px-6 text-lg font-medium ring-1 ring-zinc-800 hover:bg-zinc-800"
>
Create new key
<ArrowRightIcon className="h-5 w-5" />
</Link>
<Link
href="/onboarding/login"
className="hover:bg-zinc-900/2.5 transform rounded-lg border border-black/5 bg-zinc-800 px-3.5 py-2 font-medium ring-1 ring-inset ring-zinc-900/10 hover:text-zinc-900 active:translate-y-1 dark:text-zinc-300 dark:ring-white/10 dark:hover:bg-zinc-700 dark:hover:text-white"
className="inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full px-6 text-base font-medium text-zinc-300 hover:bg-zinc-800"
>
Login with private key
</Link>
</motion.div>
</div>
</div>
<div>{/* spacer */}</div>
</div>
);
}
@ -55,9 +138,5 @@ Page.getLayout = function getLayout(
| ReactFragment
| ReactPortal
) {
return (
<BaseLayout>
<OnboardingLayout>{page}</OnboardingLayout>
</BaseLayout>
);
return <BaseLayout>{page}</BaseLayout>;
};

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 OnboardingLayout from '@layouts/onboarding';
import { motion } from 'framer-motion';
import { LightningBoltIcon } from '@radix-ui/react-icons';
import { useRouter } from 'next/router';
import { nip19 } from 'nostr-tools';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
@ -43,7 +42,7 @@ export default function Page() {
try {
router.push({
pathname: '/onboarding/login/fetch',
pathname: '/onboarding/login/step-2',
query: { privkey: privkey },
});
} catch (error) {
@ -55,60 +54,72 @@ export default function Page() {
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col justify-between px-8">
<div>{/* spacer */}</div>
<motion.div layoutId="form">
<div className="mb-8 flex flex-col gap-3">
<motion.h1
layoutId="title"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-3xl font-medium text-transparent"
>
Import your private key
</motion.h1>
<motion.h2 layoutId="subtitle" className="w-3/4 text-zinc-400">
You can import private key format as hex string or nsec. If you have installed Nostr Connect compality
wallet in your mobile, you can connect by scan QR Code below
</motion.h2>
<form onSubmit={handleSubmit(onSubmit)} className="grid h-full w-full grid-rows-5">
<div className="row-span-1 flex items-center justify-center">
<div>
<h1 className="bg-gradient-to-br from-zinc-200 via-white to-zinc-300 bg-clip-text text-3xl font-semibold text-transparent">
Login with Private Key
</h1>
</div>
<div className="flex flex-col gap-2">
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
{...register('key', { required: true, minLength: 32 })}
type={'password'}
placeholder="Paste nsec or hex key here..."
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
<div className="row-span-4">
<div className="mx-auto w-full max-w-md">
<div className="flex flex-col gap-4">
<div>
{/* #TODO: add function */}
<button className="inline-flex w-full transform items-center justify-center gap-1.5 rounded-lg bg-zinc-700 px-3.5 py-2.5 font-medium text-zinc-200 shadow-input ring-1 ring-zinc-600 active:translate-y-1">
{/* #TODO: change to nostr connect logo */}
<LightningBoltIcon className="h-5 w-5 text-fuchsia-500" />
<span>Continue with Nostr Connect</span>
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800"></div>
</div>
<div className="relative flex justify-center">
<span className="bg-black px-2 text-sm text-zinc-500">or</span>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
{...register('key', { required: true, minLength: 32 })}
type={'password'}
placeholder="Paste private key here..."
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 text-center shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
<span className="text-sm text-red-400">{errors.key && <p>{errors.key.message}</p>}</span>
</div>
</div>
<div className="mt-1 flex h-10 items-center justify-center">
{isSubmitting ? (
<svg
className="h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<button
type="submit"
disabled={!isDirty || !isValid}
className="w-full transform rounded-lg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 px-3.5 py-2.5 font-medium text-zinc-800 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
<span className="drop-shadow-lg">Continue </span>
</button>
)}
</div>
<span className="text-sm text-red-400">{errors.key && <p>{errors.key.message}</p>}</span>
</div>
</motion.div>
<motion.div layoutId="action" className="pb-5">
<div className="flex h-10 items-center">
{isSubmitting ? (
<svg
className="h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<button
type="submit"
disabled={!isDirty || !isValid}
className="transform rounded-lg bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 px-3.5 py-2 font-medium active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
<span className="drop-shadow-lg">Continue </span>
</button>
)}
</div>
</motion.div>
</div>
</form>
);
}
@ -122,9 +133,5 @@ Page.getLayout = function getLayout(
| ReactFragment
| ReactPortal
) {
return (
<BaseLayout>
<OnboardingLayout>{page}</OnboardingLayout>
</BaseLayout>
);
return <BaseLayout>{page}</BaseLayout>;
};

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 8px 24px rgb(4 4 7 / 60%)
`,
button: `
rgba(112, 26, 117, 0.5) 0px 2px 8px,
rgb(112, 26, 117) 0px 2px 4px,
rgb(112, 26, 117) 0px 0px 0px 1px,
rgba(255, 255, 255, 0.2) 0px 0px 0px 1px inset
`,
},
backgroundColor: {
'near-black': '#07070d',
@ -35,6 +41,7 @@ module.exports = {
},
backgroundImage: {
'gradient-conic': 'conic-gradient(var(--tw-gradient-stops))',
fade: 'linear-gradient(120deg, #000, transparent 30%, transparent 70%, #000)',
},
keyframes: {
disco: {
@ -65,6 +72,14 @@ module.exports = {
from: { opacity: 0, transform: 'translateX(2px)' },
to: { opacity: 1, transform: 'translateX(0)' },
},
moveBg: {
'0%': { backgroundPosition: '50px' },
'20%': { backgroundPosition: '150px' },
'40%': { backgroundPosition: '250px' },
'60%': { backgroundPosition: '350px' },
'80%': { backgroundPosition: '450px' },
'100%': { backgroundPosition: '550px' },
},
},
animation: {
disco: 'disco 1.5s linear infinite',
@ -74,6 +89,7 @@ module.exports = {
slideLeftAndFade: 'slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
slideUpAndFade: 'slideUpAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
slideRightAndFade: 'slideRightAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
moveBg: 'moveBg 3s ease-in-out infinite alternate running forwards',
},
},
},

View File

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