mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
Bump to v0.6.1 — native file upload, mention names, connection stability
- Native file picker (+) in compose box uploads via Rust backend (reqwest) - Pasting a local file path auto-uploads instead of inserting text - @mentions resolve to profile display names via useProfile hook - Connection indicator uses 15s grace period before showing offline - Upload uses correct nostr.build v2 API; Rust-side multipart for native picks - Content parser extracted to src/lib/parsing.ts
This commit is contained in:
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -66,7 +66,14 @@ jobs:
|
|||||||
|
|
||||||
> **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install.
|
> **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install.
|
||||||
|
|
||||||
### New in v0.6.0 — Long-form Article Experience
|
### New in v0.6.1 — Media Upload & Feed Polish
|
||||||
|
- **Native file picker** — "+" button in compose box opens OS file browser to attach images/videos; uploaded via Rust backend (bypasses WebView limitations)
|
||||||
|
- **File path paste** — pasting a local file path (e.g. `/home/user/photo.jpg`) auto-reads and uploads the file instead of inserting the path as text
|
||||||
|
- **Mention name resolution** — @mentions now show profile display names instead of raw bech32 strings
|
||||||
|
- **Connection stability** — relay status indicator uses a 15-second grace period before showing offline; auto-reconnects when all relays drop
|
||||||
|
- **Upload reliability** — image uploads use correct nostr.build v2 API field name; Rust-side multipart upload for native file picks
|
||||||
|
|
||||||
|
### Previous: v0.6.0 — Long-form Article Experience
|
||||||
- **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors
|
- **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors
|
||||||
- **Article cards** — title, summary snippet, author avatar+name, cover image thumbnail, reading time, tag chips
|
- **Article cards** — title, summary snippet, author avatar+name, cover image thumbnail, reading time, tag chips
|
||||||
- **Article search** — search notes, articles, and people in parallel; articles tab in search results; supports full-text (NIP-50) and hashtag search for articles
|
- **Article search** — search notes, articles, and people in parallel; articles tab in search results; supports full-text (NIP-50) and hashtag search for articles
|
||||||
|
|||||||
2
PKGBUILD
2
PKGBUILD
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||||
pkgname=wrystr
|
pkgname=wrystr
|
||||||
pkgver=0.6.0
|
pkgver=0.6.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "wrystr",
|
"name": "wrystr",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wrystr",
|
"name": "wrystr",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nostr-dev-kit/ndk": "^3.0.3",
|
"@nostr-dev-kit/ndk": "^3.0.3",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-http": "^2.5.7",
|
"@tauri-apps/plugin-http": "^2.5.7",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
@@ -2123,6 +2125,24 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-fs": {
|
||||||
|
"version": "2.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||||
|
"integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-http": {
|
"node_modules/@tauri-apps/plugin-http": {
|
||||||
"version": "2.5.7",
|
"version": "2.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wrystr",
|
"name": "wrystr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
"@nostr-dev-kit/ndk": "^3.0.3",
|
"@nostr-dev-kit/ndk": "^3.0.3",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-http": "^2.5.7",
|
"@tauri-apps/plugin-http": "^2.5.7",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
|||||||
116
src-tauri/Cargo.lock
generated
116
src-tauri/Cargo.lock
generated
@@ -234,6 +234,28 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-rs"
|
||||||
|
version = "1.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-sys",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-sys"
|
||||||
|
version = "0.38.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cmake",
|
||||||
|
"dunce",
|
||||||
|
"fs_extra",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -414,6 +436,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -468,6 +492,15 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.57"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -1083,6 +1116,12 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1957,6 +1996,16 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -2203,6 +2252,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minisign-verify"
|
name = "minisign-verify"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -2954,6 +3013,7 @@ version = "0.11.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
@@ -3259,8 +3319,10 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
@@ -3280,6 +3342,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3342,6 +3428,7 @@ version = "0.23.37"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -3405,6 +3492,7 @@ version = "0.103.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
@@ -4173,6 +4261,24 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-fs"
|
name = "tauri-plugin-fs"
|
||||||
version = "2.4.5"
|
version = "2.4.5"
|
||||||
@@ -4826,6 +4932,12 @@ dependencies = [
|
|||||||
"unic-common",
|
"unic-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -5827,11 +5939,15 @@ name = "wrystr"
|
|||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"mime_guess",
|
||||||
|
"reqwest 0.13.2",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wrystr"
|
name = "wrystr"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
description = "Cross-platform Nostr desktop client with Lightning integration"
|
description = "Cross-platform Nostr desktop client with Lightning integration"
|
||||||
authors = ["hoornet"]
|
authors = ["hoornet"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -27,4 +27,8 @@ serde_json = "1"
|
|||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
tauri-plugin-http = "2.5.7"
|
tauri-plugin-http = "2.5.7"
|
||||||
|
tauri-plugin-dialog = "2.6.0"
|
||||||
|
tauri-plugin-fs = "2.4.5"
|
||||||
|
reqwest = { version = "0.13.2", default-features = false, features = ["multipart", "rustls"] }
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
{ "url": "https://nostr.build/**" }
|
{ "url": "https://nostr.build/**" }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"dialog:default",
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "**" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,51 @@ fn delete_nsec(pubkey: String) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── File upload ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn upload_file(path: String) -> Result<String, String> {
|
||||||
|
let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read file: {e}"))?;
|
||||||
|
let file_name = std::path::Path::new(&path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("file")
|
||||||
|
.to_string();
|
||||||
|
let mime = mime_guess::from_path(&path)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||||
|
.file_name(file_name)
|
||||||
|
.mime_str(&mime)
|
||||||
|
.map_err(|e| format!("MIME error: {e}"))?;
|
||||||
|
let form = reqwest::multipart::Form::new().part("file", part);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post("https://nostr.build/api/v2/upload/files")
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Upload request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("Upload failed (HTTP {})", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||||
|
|
||||||
|
if data["status"] == "success" {
|
||||||
|
if let Some(url) = data["data"][0]["url"].as_str() {
|
||||||
|
return Ok(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(data["message"].as_str().unwrap_or("Upload failed — no URL returned").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
// ── SQLite note/profile cache ────────────────────────────────────────────────
|
// ── SQLite note/profile cache ────────────────────────────────────────────────
|
||||||
|
|
||||||
struct DbState(Mutex<Connection>);
|
struct DbState(Mutex<Connection>);
|
||||||
@@ -142,6 +187,8 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// ── SQLite ───────────────────────────────────────────────────────
|
// ── SQLite ───────────────────────────────────────────────────────
|
||||||
let data_dir = app.path().app_data_dir()?;
|
let data_dir = app.path().app_data_dir()?;
|
||||||
@@ -203,6 +250,7 @@ pub fn run() {
|
|||||||
store_nsec,
|
store_nsec,
|
||||||
load_nsec,
|
load_nsec,
|
||||||
delete_nsec,
|
delete_nsec,
|
||||||
|
upload_file,
|
||||||
db_save_notes,
|
db_save_notes,
|
||||||
db_load_feed,
|
db_load_feed,
|
||||||
db_save_profile,
|
db_save_profile,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Wrystr",
|
"productName": "Wrystr",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"identifier": "com.hoornet.wrystr",
|
"identifier": "com.hoornet.wrystr",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { publishNote } from "../../lib/nostr";
|
import { publishNote } from "../../lib/nostr";
|
||||||
import { uploadImage } from "../../lib/upload";
|
import { uploadImage } from "../../lib/upload";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useFeedStore } from "../../stores/feed";
|
import { useFeedStore } from "../../stores/feed";
|
||||||
import { shortenPubkey } from "../../lib/utils";
|
import { shortenPubkey } from "../../lib/utils";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
const COMPOSE_DRAFT_KEY = "wrystr_compose_draft";
|
||||||
|
|
||||||
export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () => void; onNoteInjected?: (event: import("@nostr-dev-kit/ndk").NDKEvent) => void }) {
|
export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () => void; onNoteInjected?: (event: import("@nostr-dev-kit/ndk").NDKEvent) => void }) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState(() => {
|
||||||
|
try { return localStorage.getItem(COMPOSE_DRAFT_KEY) || ""; }
|
||||||
|
catch { return ""; }
|
||||||
|
});
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -16,31 +23,46 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
const avatar = profile?.picture;
|
const avatar = profile?.picture;
|
||||||
const name = profile?.displayName || profile?.name || (npub ? shortenPubkey(npub) : "");
|
const name = profile?.displayName || profile?.name || (npub ? shortenPubkey(npub) : "");
|
||||||
|
|
||||||
|
// Auto-save draft with debounce
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (text.trim()) {
|
||||||
|
localStorage.setItem(COMPOSE_DRAFT_KEY, text);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(COMPOSE_DRAFT_KEY);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
const charCount = text.length;
|
const charCount = text.length;
|
||||||
const overLimit = charCount > 280;
|
const overLimit = charCount > 280;
|
||||||
const canPost = text.trim().length > 0 && !overLimit && !publishing && !uploading;
|
const canPost = text.trim().length > 0 && !overLimit && !publishing && !uploading;
|
||||||
|
|
||||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
// Insert a URL at the current cursor position in the textarea
|
||||||
const file = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/"));
|
const insertUrl = (url: string) => {
|
||||||
if (!file) return;
|
const ta = textareaRef.current;
|
||||||
e.preventDefault();
|
if (ta) {
|
||||||
|
const start = ta.selectionStart ?? text.length;
|
||||||
|
const end = ta.selectionEnd ?? text.length;
|
||||||
|
const next = text.slice(0, start) + url + text.slice(end);
|
||||||
|
setText(next);
|
||||||
|
setTimeout(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + url.length;
|
||||||
|
ta.focus();
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
setText((t) => t + url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload a web File object (from clipboard/drag-drop)
|
||||||
|
const handleImageUpload = async (file: File) => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const url = await uploadImage(file);
|
const url = await uploadImage(file);
|
||||||
const ta = textareaRef.current;
|
insertUrl(url);
|
||||||
if (ta) {
|
|
||||||
const start = ta.selectionStart ?? text.length;
|
|
||||||
const end = ta.selectionEnd ?? text.length;
|
|
||||||
const next = text.slice(0, start) + url + text.slice(end);
|
|
||||||
setText(next);
|
|
||||||
setTimeout(() => {
|
|
||||||
ta.selectionStart = ta.selectionEnd = start + url.length;
|
|
||||||
ta.focus();
|
|
||||||
}, 0);
|
|
||||||
} else {
|
|
||||||
setText((t) => t + url);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(`Image upload failed: ${err}`);
|
setError(`Image upload failed: ${err}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,6 +70,79 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Upload a file by path using the Rust backend (bypasses WebView FormData issues)
|
||||||
|
const handleNativeUpload = async (filePath: string) => {
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const url = await invoke<string>("upload_file", { path: filePath });
|
||||||
|
insertUrl(url);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Upload failed: ${err}`);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Try clipboardData.files first (works on Windows, some Linux DEs)
|
||||||
|
const fileFromFiles = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/"));
|
||||||
|
if (fileFromFiles) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleImageUpload(fileFromFiles);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try clipboardData.items (needed for Linux/Wayland screenshot paste)
|
||||||
|
const items = Array.from(e.clipboardData.items ?? []);
|
||||||
|
const imageItem = items.find((item) => item.type.startsWith("image/"));
|
||||||
|
if (imageItem) {
|
||||||
|
const file = imageItem.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleImageUpload(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pasted text looks like a local file path to a media file, upload it directly
|
||||||
|
const pastedText = e.clipboardData.getData("text/plain");
|
||||||
|
if (pastedText && /\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)$/i.test(pastedText.trim()) && /^(\/|[A-Z]:\\)/.test(pastedText.trim())) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNativeUpload(pastedText.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
|
const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/"));
|
||||||
|
if (!file) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleImageUpload(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (Array.from(e.dataTransfer.types).includes("Files")) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilePicker = async () => {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [
|
||||||
|
{ name: "Media", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg", "mp4", "webm", "mov", "ogg", "m4v"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!selected) return;
|
||||||
|
const path = typeof selected === "string" ? selected : selected;
|
||||||
|
handleNativeUpload(path);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`File picker failed: ${err}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -71,6 +166,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setText("");
|
setText("");
|
||||||
|
localStorage.removeItem(COMPOSE_DRAFT_KEY);
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
onPublished?.();
|
onPublished?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -108,6 +204,8 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
placeholder="What's on your mind?"
|
placeholder="What's on your mind?"
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none"
|
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none"
|
||||||
@@ -120,8 +218,19 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}>
|
<span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}>
|
||||||
{uploading ? "uploading image…" : charCount > 0 ? `${charCount}/280` : ""}
|
{uploading ? "uploading image…" : charCount > 0 ? `${charCount}/280` : ""}
|
||||||
|
{!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && (
|
||||||
|
<span className="ml-1 text-text-dim">(draft)</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleFilePicker}
|
||||||
|
disabled={uploading}
|
||||||
|
title="Attach image or video"
|
||||||
|
className="text-text-dim hover:text-text text-[13px] transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
<span className="text-text-dim text-[10px]">Ctrl+Enter to post</span>
|
<span className="text-text-dim text-[10px]">Ctrl+Enter to post</span>
|
||||||
<button
|
<button
|
||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { SkeletonNoteList } from "../shared/Skeleton";
|
|||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
|
||||||
export function Feed() {
|
export function Feed() {
|
||||||
const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, focusedNoteIndex } = useFeedStore();
|
const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, trendingNotes, trendingLoading, loadTrendingFeed, focusedNoteIndex } = useFeedStore();
|
||||||
const { loggedIn, follows } = useUserStore();
|
const { loggedIn, follows } = useUserStore();
|
||||||
const { mutedPubkeys } = useMuteStore();
|
const { mutedPubkeys } = useMuteStore();
|
||||||
const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore();
|
const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore();
|
||||||
@@ -29,6 +29,9 @@ export function Feed() {
|
|||||||
if (tab === "following" && loggedIn && follows.length > 0) {
|
if (tab === "following" && loggedIn && follows.length > 0) {
|
||||||
loadFollowFeed();
|
loadFollowFeed();
|
||||||
}
|
}
|
||||||
|
if (tab === "trending") {
|
||||||
|
loadTrendingFeed();
|
||||||
|
}
|
||||||
}, [tab, follows]);
|
}, [tab, follows]);
|
||||||
|
|
||||||
const loadFollowFeed = async () => {
|
const loadFollowFeed = async () => {
|
||||||
@@ -42,8 +45,9 @@ export function Feed() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isFollowing = tab === "following";
|
const isFollowing = tab === "following";
|
||||||
const activeNotes = isFollowing ? followNotes : notes;
|
const isTrending = tab === "trending";
|
||||||
const isLoading = isFollowing ? followLoading : loading;
|
const activeNotes = isTrending ? trendingNotes : isFollowing ? followNotes : notes;
|
||||||
|
const isLoading = isTrending ? trendingLoading : isFollowing ? followLoading : loading;
|
||||||
|
|
||||||
const filteredNotes = activeNotes.filter((event) => {
|
const filteredNotes = activeNotes.filter((event) => {
|
||||||
if (mutedPubkeys.includes(event.pubkey)) return false;
|
if (mutedPubkeys.includes(event.pubkey)) return false;
|
||||||
@@ -105,6 +109,16 @@ export function Feed() {
|
|||||||
Following
|
Following
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("trending")}
|
||||||
|
className={`px-3 py-1 text-[12px] transition-colors ${
|
||||||
|
tab === "trending"
|
||||||
|
? "text-text border-b-2 border-accent"
|
||||||
|
: "text-text-muted hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Trending
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<select
|
<select
|
||||||
@@ -124,7 +138,7 @@ export function Feed() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={isFollowing ? loadFollowFeed : loadFeed}
|
onClick={isTrending ? () => loadTrendingFeed(true) : isFollowing ? loadFollowFeed : loadFeed}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="text-text-muted hover:text-text text-[11px] px-2 py-1 border border-border hover:border-text-dim transition-colors disabled:opacity-40"
|
className="text-text-muted hover:text-text text-[11px] px-2 py-1 border border-border hover:border-text-dim transition-colors disabled:opacity-40"
|
||||||
>
|
>
|
||||||
@@ -140,7 +154,7 @@ export function Feed() {
|
|||||||
|
|
||||||
{/* Feed */}
|
{/* Feed */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{error && !isFollowing && (
|
{error && !isFollowing && !isTrending && (
|
||||||
<div className="px-4 py-3 text-danger text-[12px] border-b border-border bg-danger/5">
|
<div className="px-4 py-3 text-danger text-[12px] border-b border-border bg-danger/5">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
|||||||
const [liked, setLiked] = useState(() => getLiked().has(event.id));
|
const [liked, setLiked] = useState(() => getLiked().has(event.id));
|
||||||
const [liking, setLiking] = useState(false);
|
const [liking, setLiking] = useState(false);
|
||||||
const [reactionCount, adjustReactionCount] = useReactionCount(event.id);
|
const [reactionCount, adjustReactionCount] = useReactionCount(event.id);
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
const [replyCount, adjustReplyCount] = useReplyCount(event.id);
|
const [replyCount, adjustReplyCount] = useReplyCount(event.id);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const zapData = useZapCount(event.id);
|
const zapData = useZapCount(event.id);
|
||||||
@@ -78,14 +79,17 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
|||||||
const [reposted, setReposted] = useState(false);
|
const [reposted, setReposted] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
const handleLike = async () => {
|
const REACTION_EMOJIS = ["❤️", "🤙", "🔥", "😂", "🫡", "👀", "⚡"];
|
||||||
|
|
||||||
|
const handleReact = async (emoji?: string) => {
|
||||||
if (!loggedIn || liked || liking) return;
|
if (!loggedIn || liked || liking) return;
|
||||||
setLiking(true);
|
setLiking(true);
|
||||||
|
setShowEmojiPicker(false);
|
||||||
try {
|
try {
|
||||||
await publishReaction(event.id, event.pubkey);
|
await publishReaction(event.id, event.pubkey, emoji || "+");
|
||||||
const liked = getLiked();
|
const likedSet = getLiked();
|
||||||
liked.add(event.id);
|
likedSet.add(event.id);
|
||||||
localStorage.setItem(likedKey, JSON.stringify(Array.from(liked)));
|
localStorage.setItem(likedKey, JSON.stringify(Array.from(likedSet)));
|
||||||
setLiked(true);
|
setLiked(true);
|
||||||
adjustReactionCount(1);
|
adjustReactionCount(1);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -249,15 +253,34 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
|||||||
>
|
>
|
||||||
reply{replyCount !== null && replyCount > 0 ? ` ${replyCount}` : ""}
|
reply{replyCount !== null && replyCount > 0 ? ` ${replyCount}` : ""}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={handleLike}
|
<button
|
||||||
disabled={liked || liking}
|
onClick={() => handleReact("❤️")}
|
||||||
className={`text-[11px] transition-colors ${
|
onContextMenu={(e) => { e.preventDefault(); if (!liked && !liking) setShowEmojiPicker((v) => !v); }}
|
||||||
liked ? "text-accent" : "text-text-dim hover:text-accent"
|
disabled={liked || liking}
|
||||||
} disabled:cursor-default`}
|
className={`text-[11px] transition-colors ${
|
||||||
>
|
liked ? "text-accent" : "text-text-dim hover:text-accent"
|
||||||
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
|
} disabled:cursor-default`}
|
||||||
</button>
|
>
|
||||||
|
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
|
||||||
|
</button>
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[9]" onClick={() => setShowEmojiPicker(false)} />
|
||||||
|
<div className="absolute bottom-6 left-0 bg-bg-raised border border-border shadow-lg z-10 flex gap-0.5 px-1.5 py-1">
|
||||||
|
{REACTION_EMOJIS.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
onClick={() => handleReact(emoji)}
|
||||||
|
className="text-[16px] hover:scale-125 transition-transform px-0.5"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleRepost}
|
onClick={handleRepost}
|
||||||
disabled={reposting || reposted}
|
disabled={reposting || reposted}
|
||||||
|
|||||||
@@ -5,181 +5,108 @@ import { fetchNoteById } from "../../lib/nostr";
|
|||||||
import { useProfile } from "../../hooks/useProfile";
|
import { useProfile } from "../../hooks/useProfile";
|
||||||
import { shortenPubkey } from "../../lib/utils";
|
import { shortenPubkey } from "../../lib/utils";
|
||||||
import { ImageLightbox } from "../shared/ImageLightbox";
|
import { ImageLightbox } from "../shared/ImageLightbox";
|
||||||
|
import { parseContent } from "../../lib/parsing";
|
||||||
|
|
||||||
// Regex patterns
|
function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (index: number) => void }) {
|
||||||
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
const count = images.length;
|
||||||
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?$/i;
|
if (count === 0) return null;
|
||||||
const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|ogg|m4v|avi)(\?[^\s]*)?$/i;
|
|
||||||
const AUDIO_EXTENSIONS = /\.(mp3|wav|flac|aac|m4a|opus|ogg)(\?[^\s]*)?$/i;
|
|
||||||
const YOUTUBE_REGEX = /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
|
||||||
const TIDAL_REGEX = /tidal\.com\/(?:browse\/)?(?:track|album|playlist)\/([a-zA-Z0-9-]+)/;
|
|
||||||
const SPOTIFY_REGEX = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/;
|
|
||||||
const VIMEO_REGEX = /vimeo\.com\/(\d+)/;
|
|
||||||
const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/g;
|
|
||||||
const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g;
|
|
||||||
|
|
||||||
interface ContentSegment {
|
const maxVisible = Math.min(count, 4);
|
||||||
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "mention" | "hashtag" | "quote";
|
const extraCount = count - 4;
|
||||||
value: string; // for "quote": the hex event ID
|
const visible = images.slice(0, maxVisible);
|
||||||
display?: string;
|
|
||||||
mediaId?: string; // video/embed ID for youtube/vimeo
|
if (count === 1) {
|
||||||
mediaType?: string; // e.g. "track", "album" for spotify/tidal
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<img
|
||||||
|
src={images[0]}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onImageClick(0); }}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 2) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||||
|
{visible.map((src, idx) => (
|
||||||
|
<img
|
||||||
|
key={idx}
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 3) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 grid grid-cols-2 grid-rows-2 gap-1" style={{ gridTemplateRows: "1fr 1fr" }}>
|
||||||
|
<img
|
||||||
|
src={visible[0]}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in row-span-2"
|
||||||
|
style={{ aspectRatio: "3/4" }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onImageClick(0); }}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={visible[1]}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onImageClick(1); }}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={visible[2]}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onImageClick(2); }}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4+ images: 2x2 grid with "+N more" overlay on 4th
|
||||||
|
return (
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||||
|
{visible.map((src, idx) => (
|
||||||
|
<div key={idx} className="relative">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
{idx === 3 && extraCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 flex items-center justify-center rounded-sm cursor-zoom-in"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||||
|
>
|
||||||
|
<span className="text-white text-lg font-semibold">+{extraCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseContent(content: string): ContentSegment[] {
|
|
||||||
const segments: ContentSegment[] = [];
|
|
||||||
const allMatches: { index: number; length: number; segment: ContentSegment }[] = [];
|
|
||||||
|
|
||||||
// Find URLs
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
const urlRegex = new RegExp(URL_REGEX.source, "g");
|
|
||||||
while ((match = urlRegex.exec(content)) !== null) {
|
|
||||||
const url = match[0];
|
|
||||||
// Clean trailing punctuation that's likely not part of the URL
|
|
||||||
const cleaned = url.replace(/[.,;:!?)]+$/, "");
|
|
||||||
|
|
||||||
if (IMAGE_EXTENSIONS.test(cleaned)) {
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: cleaned.length,
|
|
||||||
segment: { type: "image", value: cleaned },
|
|
||||||
});
|
|
||||||
} else if (VIDEO_EXTENSIONS.test(cleaned)) {
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: cleaned.length,
|
|
||||||
segment: { type: "video", value: cleaned },
|
|
||||||
});
|
|
||||||
} else if (AUDIO_EXTENSIONS.test(cleaned)) {
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: cleaned.length,
|
|
||||||
segment: { type: "audio", value: cleaned },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Check for embeddable media URLs
|
|
||||||
const ytMatch = cleaned.match(YOUTUBE_REGEX);
|
|
||||||
const vimeoMatch = cleaned.match(VIMEO_REGEX);
|
|
||||||
const spotifyMatch = cleaned.match(SPOTIFY_REGEX);
|
|
||||||
const tidalMatch = cleaned.match(TIDAL_REGEX);
|
|
||||||
|
|
||||||
if (ytMatch) {
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: cleaned.length,
|
|
||||||
segment: { type: "youtube", value: cleaned, mediaId: ytMatch[1] },
|
|
||||||
});
|
|
||||||
} else if (vimeoMatch) {
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: cleaned.length,
|
|
||||||
segment: { type: "vimeo", value: cleaned, mediaId: vimeoMatch[1] },
|
|
||||||
});
|
|
||||||
} else if (spotifyMatch) {
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: cleaned.length,
|
|
||||||
segment: { type: "spotify", value: cleaned, mediaType: spotifyMatch[1], mediaId: spotifyMatch[2] },
|
|
||||||
});
|
|
||||||
} else if (tidalMatch) {
|
|
||||||
// Extract the type (track/album/playlist) from the URL
|
|
||||||
const tidalTypeMatch = cleaned.match(/tidal\.com\/(?:browse\/)?(track|album|playlist)\//);
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: cleaned.length,
|
|
||||||
segment: { type: "tidal", value: cleaned, mediaType: tidalTypeMatch?.[1] ?? "track", mediaId: tidalMatch[1] },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Shorten display URL
|
|
||||||
let display = cleaned;
|
|
||||||
try {
|
|
||||||
const u = new URL(cleaned);
|
|
||||||
display = u.hostname + (u.pathname !== "/" ? u.pathname : "");
|
|
||||||
if (display.length > 50) display = display.slice(0, 47) + "…";
|
|
||||||
} catch { /* keep as-is */ }
|
|
||||||
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: cleaned.length,
|
|
||||||
segment: { type: "link", value: cleaned, display },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find nostr: mentions
|
|
||||||
const mentionRegex = new RegExp(NOSTR_MENTION_REGEX.source, "g");
|
|
||||||
while ((match = mentionRegex.exec(content)) !== null) {
|
|
||||||
const raw = match[1];
|
|
||||||
let display = raw.slice(0, 12) + "…";
|
|
||||||
|
|
||||||
let isQuote = false;
|
|
||||||
let eventId = "";
|
|
||||||
try {
|
|
||||||
const decoded = nip19.decode(raw);
|
|
||||||
if (decoded.type === "npub") {
|
|
||||||
display = raw.slice(0, 12) + "…";
|
|
||||||
} else if (decoded.type === "note") {
|
|
||||||
// Always treat note1 references as inline quotes
|
|
||||||
isQuote = true;
|
|
||||||
eventId = decoded.data as string;
|
|
||||||
} else if (decoded.type === "nevent") {
|
|
||||||
const d = decoded.data as { id: string; kind?: number };
|
|
||||||
// Only quote kind-1 notes (or unknown kind)
|
|
||||||
if (!d.kind || d.kind === 1) {
|
|
||||||
isQuote = true;
|
|
||||||
eventId = d.id;
|
|
||||||
} else {
|
|
||||||
display = "event:" + raw.slice(7, 15) + "…";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* keep default */ }
|
|
||||||
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: match[0].length,
|
|
||||||
segment: isQuote
|
|
||||||
? { type: "quote", value: eventId }
|
|
||||||
: { type: "mention", value: raw, display },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find hashtags
|
|
||||||
const hashtagRegex = new RegExp(HASHTAG_REGEX.source, "g");
|
|
||||||
while ((match = hashtagRegex.exec(content)) !== null) {
|
|
||||||
allMatches.push({
|
|
||||||
index: match.index,
|
|
||||||
length: match[0].length,
|
|
||||||
segment: { type: "hashtag", value: match[1], display: `#${match[1]}` },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort matches by index, remove overlaps
|
|
||||||
allMatches.sort((a, b) => a.index - b.index);
|
|
||||||
const filtered: typeof allMatches = [];
|
|
||||||
let lastEnd = 0;
|
|
||||||
for (const m of allMatches) {
|
|
||||||
if (m.index >= lastEnd) {
|
|
||||||
filtered.push(m);
|
|
||||||
lastEnd = m.index + m.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build segments
|
|
||||||
let cursor = 0;
|
|
||||||
for (const m of filtered) {
|
|
||||||
if (m.index > cursor) {
|
|
||||||
segments.push({ type: "text", value: content.slice(cursor, m.index) });
|
|
||||||
}
|
|
||||||
segments.push(m.segment);
|
|
||||||
cursor = m.index + m.length;
|
|
||||||
}
|
|
||||||
if (cursor < content.length) {
|
|
||||||
segments.push({ type: "text", value: content.slice(cursor) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if we handled the URL internally (njump.me interception).
|
// Returns true if we handled the URL internally (njump.me interception).
|
||||||
function tryHandleUrlInternally(url: string): boolean {
|
function tryHandleUrlInternally(url: string): boolean {
|
||||||
@@ -251,6 +178,13 @@ function QuotePreview({ eventId }: { eventId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MentionName({ pubkey, fallback }: { pubkey?: string; fallback: string }) {
|
||||||
|
const profile = useProfile(pubkey ?? "");
|
||||||
|
if (!pubkey) return <>{fallback}</>;
|
||||||
|
const name = profile?.displayName || profile?.name;
|
||||||
|
return <>{name || fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
interface NoteContentProps {
|
interface NoteContentProps {
|
||||||
content: string;
|
content: string;
|
||||||
/** Render only inline text (no media blocks). Used inside the clickable area. */
|
/** Render only inline text (no media blocks). Used inside the clickable area. */
|
||||||
@@ -303,7 +237,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
|||||||
className="text-accent cursor-pointer hover:text-accent-hover"
|
className="text-accent cursor-pointer hover:text-accent-hover"
|
||||||
onClick={(e) => { e.stopPropagation(); tryOpenNostrEntity(seg.value); }}
|
onClick={(e) => { e.stopPropagation(); tryOpenNostrEntity(seg.value); }}
|
||||||
>
|
>
|
||||||
@{seg.display}
|
@<MentionName pubkey={seg.mentionPubkey} fallback={seg.display ?? seg.value.slice(0, 12) + "…"} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -328,21 +262,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
|||||||
{inlineElements}
|
{inlineElements}
|
||||||
</div>
|
</div>
|
||||||
{/* Images stay inside the clickable area (they have their own stopPropagation) */}
|
{/* Images stay inside the clickable area (they have their own stopPropagation) */}
|
||||||
{images.length > 0 && (
|
<ImageGrid images={images} onImageClick={setLightboxIndex} />
|
||||||
<div className={`mt-2 ${images.length > 1 ? "grid grid-cols-2 gap-1" : ""}`}>
|
|
||||||
{images.map((src, idx) => (
|
|
||||||
<img
|
|
||||||
key={idx}
|
|
||||||
src={src}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex(idx); }}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{lightboxIndex !== null && (
|
{lightboxIndex !== null && (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
images={images}
|
images={images}
|
||||||
@@ -538,21 +458,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
|||||||
{inlineElements}
|
{inlineElements}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{images.length > 0 && (
|
<ImageGrid images={images} onImageClick={setLightboxIndex} />
|
||||||
<div className={`mt-2 ${images.length > 1 ? "grid grid-cols-2 gap-1" : ""}`}>
|
|
||||||
{images.map((src, idx) => (
|
|
||||||
<img
|
|
||||||
key={idx}
|
|
||||||
src={src}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex(idx); }}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lightboxIndex !== null && (
|
{lightboxIndex !== null && (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
|
|||||||
180
src/lib/parsing.ts
Normal file
180
src/lib/parsing.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { nip19 } from "@nostr-dev-kit/ndk";
|
||||||
|
|
||||||
|
// Regex patterns
|
||||||
|
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
||||||
|
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?$/i;
|
||||||
|
const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|ogg|m4v|avi)(\?[^\s]*)?$/i;
|
||||||
|
const AUDIO_EXTENSIONS = /\.(mp3|wav|flac|aac|m4a|opus|ogg)(\?[^\s]*)?$/i;
|
||||||
|
const YOUTUBE_REGEX = /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||||
|
const TIDAL_REGEX = /tidal\.com\/(?:browse\/)?(?:track|album|playlist)\/([a-zA-Z0-9-]+)/;
|
||||||
|
const SPOTIFY_REGEX = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/;
|
||||||
|
const VIMEO_REGEX = /vimeo\.com\/(\d+)/;
|
||||||
|
const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/g;
|
||||||
|
const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g;
|
||||||
|
|
||||||
|
export interface ContentSegment {
|
||||||
|
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "mention" | "hashtag" | "quote";
|
||||||
|
value: string; // for "quote": the hex event ID
|
||||||
|
display?: string;
|
||||||
|
mediaId?: string; // video/embed ID for youtube/vimeo
|
||||||
|
mediaType?: string; // e.g. "track", "album" for spotify/tidal
|
||||||
|
mentionPubkey?: string; // hex pubkey for npub/nprofile mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseContent(content: string): ContentSegment[] {
|
||||||
|
const segments: ContentSegment[] = [];
|
||||||
|
const allMatches: { index: number; length: number; segment: ContentSegment }[] = [];
|
||||||
|
|
||||||
|
// Find URLs
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const urlRegex = new RegExp(URL_REGEX.source, "g");
|
||||||
|
while ((match = urlRegex.exec(content)) !== null) {
|
||||||
|
const url = match[0];
|
||||||
|
// Clean trailing punctuation that's likely not part of the URL
|
||||||
|
const cleaned = url.replace(/[.,;:!?)]+$/, "");
|
||||||
|
|
||||||
|
if (IMAGE_EXTENSIONS.test(cleaned)) {
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: cleaned.length,
|
||||||
|
segment: { type: "image", value: cleaned },
|
||||||
|
});
|
||||||
|
} else if (VIDEO_EXTENSIONS.test(cleaned)) {
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: cleaned.length,
|
||||||
|
segment: { type: "video", value: cleaned },
|
||||||
|
});
|
||||||
|
} else if (AUDIO_EXTENSIONS.test(cleaned)) {
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: cleaned.length,
|
||||||
|
segment: { type: "audio", value: cleaned },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check for embeddable media URLs
|
||||||
|
const ytMatch = cleaned.match(YOUTUBE_REGEX);
|
||||||
|
const vimeoMatch = cleaned.match(VIMEO_REGEX);
|
||||||
|
const spotifyMatch = cleaned.match(SPOTIFY_REGEX);
|
||||||
|
const tidalMatch = cleaned.match(TIDAL_REGEX);
|
||||||
|
|
||||||
|
if (ytMatch) {
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: cleaned.length,
|
||||||
|
segment: { type: "youtube", value: cleaned, mediaId: ytMatch[1] },
|
||||||
|
});
|
||||||
|
} else if (vimeoMatch) {
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: cleaned.length,
|
||||||
|
segment: { type: "vimeo", value: cleaned, mediaId: vimeoMatch[1] },
|
||||||
|
});
|
||||||
|
} else if (spotifyMatch) {
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: cleaned.length,
|
||||||
|
segment: { type: "spotify", value: cleaned, mediaType: spotifyMatch[1], mediaId: spotifyMatch[2] },
|
||||||
|
});
|
||||||
|
} else if (tidalMatch) {
|
||||||
|
// Extract the type (track/album/playlist) from the URL
|
||||||
|
const tidalTypeMatch = cleaned.match(/tidal\.com\/(?:browse\/)?(track|album|playlist)\//);
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: cleaned.length,
|
||||||
|
segment: { type: "tidal", value: cleaned, mediaType: tidalTypeMatch?.[1] ?? "track", mediaId: tidalMatch[1] },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Shorten display URL
|
||||||
|
let display = cleaned;
|
||||||
|
try {
|
||||||
|
const u = new URL(cleaned);
|
||||||
|
display = u.hostname + (u.pathname !== "/" ? u.pathname : "");
|
||||||
|
if (display.length > 50) display = display.slice(0, 47) + "…";
|
||||||
|
} catch { /* keep as-is */ }
|
||||||
|
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: cleaned.length,
|
||||||
|
segment: { type: "link", value: cleaned, display },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find nostr: mentions
|
||||||
|
const mentionRegex = new RegExp(NOSTR_MENTION_REGEX.source, "g");
|
||||||
|
while ((match = mentionRegex.exec(content)) !== null) {
|
||||||
|
const raw = match[1];
|
||||||
|
let display = raw.slice(0, 12) + "…";
|
||||||
|
let mentionPubkey: string | undefined;
|
||||||
|
|
||||||
|
let isQuote = false;
|
||||||
|
let eventId = "";
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(raw);
|
||||||
|
if (decoded.type === "npub") {
|
||||||
|
mentionPubkey = decoded.data as string;
|
||||||
|
} else if (decoded.type === "nprofile") {
|
||||||
|
mentionPubkey = (decoded.data as { pubkey: string }).pubkey;
|
||||||
|
} else if (decoded.type === "note") {
|
||||||
|
// Always treat note1 references as inline quotes
|
||||||
|
isQuote = true;
|
||||||
|
eventId = decoded.data as string;
|
||||||
|
} else if (decoded.type === "nevent") {
|
||||||
|
const d = decoded.data as { id: string; kind?: number };
|
||||||
|
// Only quote kind-1 notes (or unknown kind)
|
||||||
|
if (!d.kind || d.kind === 1) {
|
||||||
|
isQuote = true;
|
||||||
|
eventId = d.id;
|
||||||
|
} else {
|
||||||
|
display = "event:" + raw.slice(7, 15) + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* keep default */ }
|
||||||
|
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: match[0].length,
|
||||||
|
segment: isQuote
|
||||||
|
? { type: "quote", value: eventId }
|
||||||
|
: { type: "mention", value: raw, display, mentionPubkey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find hashtags
|
||||||
|
const hashtagRegex = new RegExp(HASHTAG_REGEX.source, "g");
|
||||||
|
while ((match = hashtagRegex.exec(content)) !== null) {
|
||||||
|
allMatches.push({
|
||||||
|
index: match.index,
|
||||||
|
length: match[0].length,
|
||||||
|
segment: { type: "hashtag", value: match[1], display: `#${match[1]}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort matches by index, remove overlaps
|
||||||
|
allMatches.sort((a, b) => a.index - b.index);
|
||||||
|
const filtered: typeof allMatches = [];
|
||||||
|
let lastEnd = 0;
|
||||||
|
for (const m of allMatches) {
|
||||||
|
if (m.index >= lastEnd) {
|
||||||
|
filtered.push(m);
|
||||||
|
lastEnd = m.index + m.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build segments
|
||||||
|
let cursor = 0;
|
||||||
|
for (const m of filtered) {
|
||||||
|
if (m.index > cursor) {
|
||||||
|
segments.push({ type: "text", value: content.slice(cursor, m.index) });
|
||||||
|
}
|
||||||
|
segments.push(m.segment);
|
||||||
|
cursor = m.index + m.length;
|
||||||
|
}
|
||||||
|
if (cursor < content.length) {
|
||||||
|
segments.push({ type: "text", value: content.slice(cursor) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
@@ -3,10 +3,26 @@ import { fetch } from "@tauri-apps/plugin-http";
|
|||||||
/**
|
/**
|
||||||
* Upload an image file to nostr.build and return the hosted URL.
|
* Upload an image file to nostr.build and return the hosted URL.
|
||||||
* Uses Tauri's HTTP plugin to bypass WebView CORS/fetch restrictions.
|
* Uses Tauri's HTTP plugin to bypass WebView CORS/fetch restrictions.
|
||||||
|
*
|
||||||
|
* Clipboard-pasted images sometimes arrive as File objects that Tauri's
|
||||||
|
* HTTP plugin can't serialize correctly, so we read the bytes ourselves
|
||||||
|
* and build a proper Blob with the correct MIME type.
|
||||||
*/
|
*/
|
||||||
export async function uploadImage(file: File): Promise<string> {
|
export async function uploadImage(file: File): Promise<string> {
|
||||||
|
// Read file bytes — ensures clipboard-pasted images are properly serialized
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
return uploadBytes(bytes, file.name || "image.png", file.type || "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload raw bytes to nostr.build. Used by the native file picker path
|
||||||
|
* where we already have a Uint8Array from tauri-plugin-fs.
|
||||||
|
*/
|
||||||
|
export async function uploadBytes(bytes: Uint8Array, fileName: string, mimeType: string): Promise<string> {
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("fileToUpload", file);
|
form.append("file", blob, fileName);
|
||||||
|
|
||||||
const resp = await fetch("https://nostr.build/api/v2/upload/files", {
|
const resp = await fetch("https://nostr.build/api/v2/upload/files", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
import { connectToRelays, fetchGlobalFeed, getNDK } from "../lib/nostr";
|
import { connectToRelays, fetchGlobalFeed, fetchBatchEngagement, getNDK } from "../lib/nostr";
|
||||||
import { dbLoadFeed, dbSaveNotes } from "../lib/db";
|
import { dbLoadFeed, dbSaveNotes } from "../lib/db";
|
||||||
|
|
||||||
|
const TRENDING_CACHE_KEY = "wrystr_trending_cache";
|
||||||
|
const TRENDING_TTL = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
interface FeedState {
|
interface FeedState {
|
||||||
notes: NDKEvent[];
|
notes: NDKEvent[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
focusedNoteIndex: number;
|
focusedNoteIndex: number;
|
||||||
|
trendingNotes: NDKEvent[];
|
||||||
|
trendingLoading: boolean;
|
||||||
connect: () => Promise<void>;
|
connect: () => Promise<void>;
|
||||||
loadCachedFeed: () => Promise<void>;
|
loadCachedFeed: () => Promise<void>;
|
||||||
loadFeed: () => Promise<void>;
|
loadFeed: () => Promise<void>;
|
||||||
|
loadTrendingFeed: (force?: boolean) => Promise<void>;
|
||||||
setFocusedNoteIndex: (n: number) => void;
|
setFocusedNoteIndex: (n: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +27,8 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
|||||||
connected: false,
|
connected: false,
|
||||||
error: null,
|
error: null,
|
||||||
focusedNoteIndex: -1,
|
focusedNoteIndex: -1,
|
||||||
|
trendingNotes: [],
|
||||||
|
trendingLoading: false,
|
||||||
setFocusedNoteIndex: (n: number) => set({ focusedNoteIndex: n }),
|
setFocusedNoteIndex: (n: number) => set({ focusedNoteIndex: n }),
|
||||||
|
|
||||||
connect: async () => {
|
connect: async () => {
|
||||||
@@ -29,16 +37,28 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
|||||||
await connectToRelays();
|
await connectToRelays();
|
||||||
set({ connected: true });
|
set({ connected: true });
|
||||||
|
|
||||||
// Monitor relay connectivity — update status if all relays disconnect
|
// Monitor relay connectivity with grace period.
|
||||||
|
// NDK relays can briefly show connected=false during WebSocket
|
||||||
|
// reconnection cycles, so we require multiple consecutive "offline"
|
||||||
|
// checks before flipping the indicator, and attempt reconnection.
|
||||||
const ndk = getNDK();
|
const ndk = getNDK();
|
||||||
|
let offlineStreak = 0;
|
||||||
const checkConnection = () => {
|
const checkConnection = () => {
|
||||||
const relays = Array.from(ndk.pool?.relays?.values() ?? []);
|
const relays = Array.from(ndk.pool?.relays?.values() ?? []);
|
||||||
const hasConnected = relays.some((r) => r.connected);
|
const hasConnected = relays.some((r) => r.connected);
|
||||||
if (get().connected !== hasConnected) {
|
if (hasConnected) {
|
||||||
set({ connected: hasConnected });
|
offlineStreak = 0;
|
||||||
|
if (!get().connected) set({ connected: true });
|
||||||
|
} else {
|
||||||
|
offlineStreak++;
|
||||||
|
// Only mark offline after 3 consecutive checks (15s grace)
|
||||||
|
if (offlineStreak >= 3 && get().connected) {
|
||||||
|
set({ connected: false });
|
||||||
|
// Attempt reconnection
|
||||||
|
ndk.connect().catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Re-check periodically (relay reconnects, disconnects)
|
|
||||||
setInterval(checkConnection, 5000);
|
setInterval(checkConnection, 5000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: `Connection failed: ${err}` });
|
set({ error: `Connection failed: ${err}` });
|
||||||
@@ -79,4 +99,55 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
|||||||
set({ error: `Feed failed: ${err}`, loading: false });
|
set({ error: `Feed failed: ${err}`, loading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadTrendingFeed: async (force?: boolean) => {
|
||||||
|
if (get().trendingLoading) return;
|
||||||
|
|
||||||
|
// Check cache first (skip if forced refresh)
|
||||||
|
if (!force) {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(TRENDING_CACHE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
const { timestamp } = JSON.parse(cached) as { noteIds: string[]; timestamp: number };
|
||||||
|
if (Date.now() - timestamp < TRENDING_TTL && get().trendingNotes.length > 0) {
|
||||||
|
return; // Cache still valid and notes already in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore cache errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ trendingLoading: true });
|
||||||
|
try {
|
||||||
|
const notes = await fetchGlobalFeed(200);
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
set({ trendingNotes: [], trendingLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventIds = notes.map((n) => n.id).filter(Boolean) as string[];
|
||||||
|
const engagement = await fetchBatchEngagement(eventIds);
|
||||||
|
|
||||||
|
const scored = notes
|
||||||
|
.map((note) => {
|
||||||
|
const eng = engagement.get(note.id) ?? { reactions: 0, replies: 0, zapSats: 0 };
|
||||||
|
const score = eng.reactions * 1 + eng.replies * 3 + eng.zapSats * 0.01;
|
||||||
|
return { note, score };
|
||||||
|
})
|
||||||
|
.filter((s) => s.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((s) => s.note);
|
||||||
|
|
||||||
|
set({ trendingNotes: scored, trendingLoading: false });
|
||||||
|
|
||||||
|
// Cache note IDs + timestamp
|
||||||
|
localStorage.setItem(TRENDING_CACHE_KEY, JSON.stringify({
|
||||||
|
noteIds: scored.map((n) => n.id),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
set({ error: `Trending failed: ${err}`, trendingLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user