feat: new post editor

This commit is contained in:
reya 2024-09-26 10:57:58 +07:00
parent bacfaed48a
commit 0a8eed9a46
8 changed files with 382 additions and 464 deletions

View File

@ -52,8 +52,7 @@
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-i18next": "^15.0.2", "react-i18next": "^15.0.2",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"slate": "^0.103.0", "rich-textarea": "^0.26.3",
"slate-react": "^0.107.1",
"use-debounce": "^10.0.3", "use-debounce": "^10.0.3",
"virtua": "^0.33.7" "virtua": "^0.33.7"
}, },

101
pnpm-lock.yaml generated
View File

@ -134,12 +134,9 @@ importers:
react-string-replace: react-string-replace:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
slate: rich-textarea:
specifier: ^0.103.0 specifier: ^0.26.3
version: 0.103.0 version: 0.26.3(react@19.0.0-rc-d025ddd3-20240722)
slate-react:
specifier: ^0.107.1
version: 0.107.1(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(slate@0.103.0)
use-debounce: use-debounce:
specifier: ^10.0.3 specifier: ^10.0.3
version: 10.0.3(react@19.0.0-rc-d025ddd3-20240722) version: 10.0.3(react@19.0.0-rc-d025ddd3-20240722)
@ -725,9 +722,6 @@ packages:
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
'@lightninglabs/lnc-core@0.3.1-alpha': '@lightninglabs/lnc-core@0.3.1-alpha':
resolution: {integrity: sha512-I/hThdItLWJ6RU8Z27ZIXhpBS2JJuD3+TjtaQXX2CabaUYXlcN4sk+Kx8N/zG/fk8qZvjlRWum4vHu4ZX554Fg==} resolution: {integrity: sha512-I/hThdItLWJ6RU8Z27ZIXhpBS2JJuD3+TjtaQXX2CabaUYXlcN4sk+Kx8N/zG/fk8qZvjlRWum4vHu4ZX554Fg==}
@ -1405,9 +1399,6 @@ packages:
'@types/estree@1.0.5': '@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
'@types/is-hotkey@0.1.10':
resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==}
'@types/istanbul-lib-coverage@2.0.6': '@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@ -1417,9 +1408,6 @@ packages:
'@types/istanbul-reports@1.1.2': '@types/istanbul-reports@1.1.2':
resolution: {integrity: sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==} resolution: {integrity: sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==}
'@types/lodash@4.17.7':
resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==}
'@types/prop-types@15.7.13': '@types/prop-types@15.7.13':
resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==}
@ -1562,9 +1550,6 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
compute-scroll-into-view@3.1.0:
resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -1601,10 +1586,6 @@ packages:
didyoumean@1.2.2: didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
direction@1.0.4:
resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==}
hasBin: true
dlv@1.1.3: dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@ -1762,17 +1743,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-hotkey@0.2.0:
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
is-number@7.0.0: is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@ -2094,6 +2068,11 @@ packages:
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rich-textarea@0.26.3:
resolution: {integrity: sha512-3IGAzvM9yyIOQR5GmV/zoofKfo2KCMy5yecPDNstxTDTaEAOcKSlgQcUXZBSO5jg9RvHAEgSNxqsJu/7ktCiPw==}
peerDependencies:
react: '>=16.14.0'
rollup@4.22.0: rollup@4.22.0:
resolution: {integrity: sha512-W21MUIFPZ4+O2Je/EU+GP3iz7PH4pVPUXSbEZdatQnxo29+3rsUjgrJmzuAZU24z7yRAnFN6ukxeAhZh/c7hzg==} resolution: {integrity: sha512-W21MUIFPZ4+O2Je/EU+GP3iz7PH4pVPUXSbEZdatQnxo29+3rsUjgrJmzuAZU24z7yRAnFN6ukxeAhZh/c7hzg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -2105,9 +2084,6 @@ packages:
scheduler@0.25.0-rc-d025ddd3-20240722: scheduler@0.25.0-rc-d025ddd3-20240722:
resolution: {integrity: sha512-W+CjyTUXoOf/l6b2C9uWAFA696ib1s40vKoLnVQ7o34Cgi9t18mJ7ak4AiVsKBy4pibxZAlmAZJvlKr2ra2p0w==} resolution: {integrity: sha512-W+CjyTUXoOf/l6b2C9uWAFA696ib1s40vKoLnVQ7o34Cgi9t18mJ7ak4AiVsKBy4pibxZAlmAZJvlKr2ra2p0w==}
scroll-into-view-if-needed@3.1.0:
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
@ -2124,16 +2100,6 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
slate-react@0.107.1:
resolution: {integrity: sha512-CDIFzeSkTqwOaFHIxRg4MnOsv0Ml8/PoaWiM5zL5hvDYFqVXQUEhMNQqpPEFTWJ5xVLzEv/rd9N3WloiCyEWYQ==}
peerDependencies:
react: '>=18.2.0'
react-dom: '>=18.2.0'
slate: '>=0.99.0'
slate@0.103.0:
resolution: {integrity: sha512-eCUOVqUpADYMZ59O37QQvUdnFG+8rin0OGQAXNHvHbQeVJ67Bu0spQbcy621vtf8GQUXTEQBlk6OP9atwwob4w==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2204,9 +2170,6 @@ packages:
thenify@3.3.1: thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-invariant@1.3.1:
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -2834,8 +2797,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@juggle/resize-observer@3.4.0': {}
'@lightninglabs/lnc-core@0.3.1-alpha': {} '@lightninglabs/lnc-core@0.3.1-alpha': {}
'@lightninglabs/lnc-web@0.3.1-alpha': '@lightninglabs/lnc-web@0.3.1-alpha':
@ -3470,8 +3431,6 @@ snapshots:
'@types/estree@1.0.5': {} '@types/estree@1.0.5': {}
'@types/is-hotkey@0.1.10': {}
'@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3': '@types/istanbul-lib-report@3.0.3':
@ -3483,8 +3442,6 @@ snapshots:
'@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-lib-report': 3.0.3 '@types/istanbul-lib-report': 3.0.3
'@types/lodash@4.17.7': {}
'@types/prop-types@15.7.13': {} '@types/prop-types@15.7.13': {}
'@types/react@18.3.8': '@types/react@18.3.8':
@ -3639,8 +3596,6 @@ snapshots:
commander@4.1.1: {} commander@4.1.1: {}
compute-scroll-into-view@3.1.0: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cross-spawn@7.0.3: cross-spawn@7.0.3:
@ -3665,8 +3620,6 @@ snapshots:
didyoumean@1.2.2: {} didyoumean@1.2.2: {}
direction@1.0.4: {}
dlv@1.1.3: {} dlv@1.1.3: {}
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
@ -3829,7 +3782,8 @@ snapshots:
dependencies: dependencies:
'@babel/runtime': 7.25.6 '@babel/runtime': 7.25.6
immer@10.1.1: {} immer@10.1.1:
optional: true
invariant@2.2.4: invariant@2.2.4:
dependencies: dependencies:
@ -3851,12 +3805,8 @@ snapshots:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
is-hotkey@0.2.0: {}
is-number@7.0.0: {} is-number@7.0.0: {}
is-plain-object@5.0.0: {}
isexe@2.0.0: {} isexe@2.0.0: {}
jackspeak@3.4.3: jackspeak@3.4.3:
@ -4118,6 +4068,10 @@ snapshots:
reusify@1.0.4: {} reusify@1.0.4: {}
rich-textarea@0.26.3(react@19.0.0-rc-d025ddd3-20240722):
dependencies:
react: 19.0.0-rc-d025ddd3-20240722
rollup@4.22.0: rollup@4.22.0:
dependencies: dependencies:
'@types/estree': 1.0.5 '@types/estree': 1.0.5
@ -4146,10 +4100,6 @@ snapshots:
scheduler@0.25.0-rc-d025ddd3-20240722: {} scheduler@0.25.0-rc-d025ddd3-20240722: {}
scroll-into-view-if-needed@3.1.0:
dependencies:
compute-scroll-into-view: 3.1.0
semver@6.3.1: {} semver@6.3.1: {}
shebang-command@2.0.0: shebang-command@2.0.0:
@ -4160,27 +4110,6 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
slate-react@0.107.1(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(slate@0.103.0):
dependencies:
'@juggle/resize-observer': 3.4.0
'@types/is-hotkey': 0.1.10
'@types/lodash': 4.17.7
direction: 1.0.4
is-hotkey: 0.2.0
is-plain-object: 5.0.0
lodash: 4.17.21
react: 19.0.0-rc-d025ddd3-20240722
react-dom: 19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722)
scroll-into-view-if-needed: 3.1.0
slate: 0.103.0
tiny-invariant: 1.3.1
slate@0.103.0:
dependencies:
immer: 10.1.1
is-plain-object: 5.0.0
tiny-warning: 1.0.3
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map@0.5.7: {} source-map@0.5.7: {}
@ -4272,8 +4201,6 @@ snapshots:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
tiny-invariant@1.3.1: {}
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tiny-warning@1.0.3: {} tiny-warning@1.0.3: {}

View File

@ -20,6 +20,14 @@ pub struct Profile {
website: Option<String>, website: Option<String>,
} }
#[derive(Clone, Serialize, Deserialize, Type)]
pub struct Mention {
pubkey: String,
avatar: String,
display_name: String,
name: String,
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<String, String> { pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<String, String> {
@ -195,6 +203,36 @@ pub async fn toggle_contact(
} }
} }
#[tauri::command]
#[specta::specta]
pub async fn get_mention_list(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
let client = &state.client;
let filter = Filter::new().kind(Kind::Metadata);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|e| e.to_string())?;
let data: Vec<Mention> = events
.iter()
.map(|event| {
let pubkey = event.pubkey.to_bech32().unwrap();
let metadata = Metadata::from_json(&event.content).unwrap_or(Metadata::new());
Mention {
pubkey,
avatar: metadata.picture.unwrap_or_else(|| "".to_string()),
display_name: metadata.display_name.unwrap_or_else(|| "".to_string()),
name: metadata.name.unwrap_or_else(|| "".to_string()),
}
})
.collect();
Ok(data)
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn set_lume_store( pub async fn set_lume_store(

View File

@ -115,6 +115,7 @@ fn main() {
is_contact_list_empty, is_contact_list_empty,
check_contact, check_contact,
toggle_contact, toggle_contact,
get_mention_list,
get_lume_store, get_lume_store,
set_lume_store, set_lume_store,
set_wallet, set_wallet,

View File

@ -160,6 +160,14 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getMentionList() : Promise<Result<Mention[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_mention_list") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getLumeStore(key: string) : Promise<Result<string, string>> { async getLumeStore(key: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_lume_store", { key }) }; return { status: "ok", data: await TAURI_INVOKE("get_lume_store", { key }) };
@ -466,6 +474,7 @@ subscription: "subscription"
/** user-defined types **/ /** user-defined types **/
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number } export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
export type Mention = { pubkey: string; avatar: string; display_name: string; name: string }
export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] } export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type NewSettings = Settings export type NewSettings = Settings
export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null } export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null }

View File

@ -15,8 +15,6 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale"; import updateLocale from "dayjs/plugin/updateLocale";
import { decode } from "light-bolt11-decoder"; import { decode } from "light-bolt11-decoder";
import { type BaseEditor, Transforms } from "slate";
import { ReactEditor } from "slate-react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { RichEvent, Settings } from "./commands.gen"; import type { RichEvent, Settings } from "./commands.gen";
import { LumeEvent } from "./system"; import { LumeEvent } from "./system";
@ -59,51 +57,6 @@ export const isImageUrl = (url: string) => {
} }
}; };
export const insertImage = (editor: ReactEditor | BaseEditor, url: string) => {
const text = { text: "" };
const image = [
{
type: "image",
url,
children: [text],
},
];
const extraText = [
{
type: "paragraph",
children: [text],
},
];
// @ts-ignore, idk
ReactEditor.focus(editor);
Transforms.insertNodes(editor, image);
Transforms.insertNodes(editor, extraText);
};
export const insertNostrEvent = (
editor: ReactEditor | BaseEditor,
eventId: string,
) => {
const text = { text: "" };
const event = [
{
type: "event",
eventId: `nostr:${eventId}`,
children: [text],
},
];
const extraText = [
{
type: "paragraph",
children: [text],
},
];
Transforms.insertNodes(editor, event);
Transforms.insertNodes(editor, extraText);
};
export function formatCreatedAt(time: number, message = false) { export function formatCreatedAt(time: number, message = false) {
let formated: string; let formated: string;
@ -257,18 +210,16 @@ export async function upload(filePath?: string) {
]; ];
const selected = const selected =
filePath || filePath ??
( (await open({
await open({ multiple: false,
multiple: false, filters: [
filters: [ {
{ name: "Media",
name: "Media", extensions: allowExts,
extensions: allowExts, },
}, ],
], }));
})
).path;
// User cancelled action // User cancelled action
if (!selected) return null; if (!selected) return null;
@ -331,6 +282,7 @@ export const appSettings = new Store<Settings>({
image_resize_service: "https://wsrv.nl", image_resize_service: "https://wsrv.nl",
use_relay_hint: true, use_relay_hint: true,
content_warning: true, content_warning: true,
trusted_only: true,
display_avatar: true, display_avatar: true,
display_zap_button: true, display_zap_button: true,
display_repost_button: true, display_repost_button: true,

View File

@ -1,20 +1,31 @@
import { insertImage, isImagePath, upload } from "@/commons"; import { isImagePath, upload } from "@/commons";
import { Spinner } from "@/components"; import { Spinner } from "@/components";
import { Images } from "@phosphor-icons/react"; import { Images } from "@phosphor-icons/react";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog"; import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useTransition } from "react"; import {
import { useSlateStatic } from "slate-react"; type Dispatch,
type SetStateAction,
useEffect,
useTransition,
} from "react";
export function MediaButton() { export function MediaButton({
const editor = useSlateStatic(); setText,
setAttaches,
}: {
setText: Dispatch<SetStateAction<string>>;
setAttaches: Dispatch<SetStateAction<string[]>>;
}) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const uploadMedia = () => { const uploadMedia = () => {
startTransition(async () => { startTransition(async () => {
try { try {
const image = await upload(); const image = await upload();
return insertImage(editor, image); setText((prev) => `${prev}\n${image}`);
setAttaches((prev) => [...prev, image]);
return;
} catch (e) { } catch (e) {
await message(String(e), { title: "Upload", kind: "error" }); await message(String(e), { title: "Upload", kind: "error" });
return; return;
@ -32,7 +43,8 @@ export function MediaButton() {
for (const item of items) { for (const item of items) {
if (isImagePath(item)) { if (isImagePath(item)) {
const image = await upload(item); const image = await upload(item);
insertImage(editor, image); setText((prev) => `${prev}\n${image}`);
setAttaches((prev) => [...prev, image]);
} }
} }

View File

@ -1,23 +1,20 @@
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@/commons"; // @ts-nocheck
import { type Mention, commands } from "@/commands.gen";
import { cn } from "@/commons";
import { Spinner } from "@/components"; import { Spinner } from "@/components";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { MentionNote } from "@/components/note/mentions/note";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { LumeEvent, useEvent } from "@/system"; import { LumeEvent, useEvent } from "@/system";
import { Feather } from "@phosphor-icons/react"; import { Feather } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { type Descendant, Node, Transforms, createEditor } from "slate"; import { createPortal } from "react-dom";
import { import {
Editable, RichTextarea,
ReactEditor, type RichTextareaHandle,
Slate, createRegexRenderer,
useFocused, } from "rich-textarea";
useSelected,
useSlateStatic,
withReact,
} from "slate-react";
import { MediaButton } from "./-components/media"; import { MediaButton } from "./-components/media";
import { PowButton } from "./-components/pow"; import { PowButton } from "./-components/pow";
import { WarningButton } from "./-components/warning"; import { WarningButton } from "./-components/warning";
@ -27,11 +24,39 @@ type EditorSearch = {
quote: string; quote: string;
}; };
type EditorElement = { const MENTION_REG = /\B@([\-+\w]*)$/;
type: string; const MAX_LIST_LENGTH = 5;
children: Descendant[];
eventId?: string; const renderer = createRegexRenderer([
}; [
/https?:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+/g,
({ children, key, value }) => (
<a
key={key}
href={value}
target="_blank"
rel="noreferrer"
className="text-blue-500 !underline"
>
{children}
</a>
),
],
[
/(?:^|\W)nostr:(\w+)(?!\w)/g,
({ children, key, value }) => (
<a
key={key}
href={value}
target="_blank"
rel="noreferrer"
className="text-blue-500"
>
{children}
</a>
),
],
]);
export const Route = createFileRoute("/editor/")({ export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => { validateSearch: (search: Record<string, string>): EditorSearch => {
@ -40,201 +65,295 @@ export const Route = createFileRoute("/editor/")({
quote: search.quote, quote: search.quote,
}; };
}, },
beforeLoad: ({ search }) => { beforeLoad: async ({ search }) => {
let initialValue: EditorElement[]; let users: Mention[] = [];
let initialValue: string;
if (search?.quote?.length) { if (search?.quote?.length) {
const eventId = nip19.noteEncode(search.quote); initialValue = `\nnostr:${nip19.noteEncode(search.quote)}`;
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${eventId}`,
children: [{ text: "" }],
},
];
} else { } else {
initialValue = [ initialValue = "";
{
type: "paragraph",
children: [{ text: "" }],
},
];
} }
return { initialValue }; const res = await commands.getMentionList();
if (res.status === "ok") {
users = res.data;
}
return { users, initialValue };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { reply_to } = Route.useSearch(); const { reply_to } = Route.useSearch();
const { initialValue } = Route.useRouteContext(); const { users, initialValue } = Route.useRouteContext();
const [editorValue, setEditorValue] = useState<EditorElement[]>(null); const [isPending, startTransition] = useTransition();
const [loading, setLoading] = useState(false); const [text, setText] = useState("");
const [attaches, setAttaches] = useState<string[]>(null);
const [warning, setWarning] = useState({ enable: false, reason: "" }); const [warning, setWarning] = useState({ enable: false, reason: "" });
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 }); const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
const [editor] = useState(() => const [index, setIndex] = useState<number>(0);
withMentions(withNostrEvent(withImages(withReact(createEditor())))), const [pos, setPos] = useState<{
top: number;
left: number;
caret: number;
} | null>(null);
const ref = useRef<RichTextareaHandle>(null);
const targetText = pos ? text.slice(0, pos.caret) : text;
const match = pos && targetText.match(MENTION_REG);
const name = match?.[1] ?? "";
const filtered = useMemo(
() =>
users
.filter((u) => u.name.toLowerCase().startsWith(name.toLowerCase()))
.slice(0, MAX_LIST_LENGTH),
[name],
); );
const reset = () => { const insert = (i: number) => {
// @ts-expect-error, backlog if (!ref.current || !pos) return;
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const serialize = (nodes: Descendant[]) => { const selected = filtered[i];
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
// @ts-expect-error, backlog ref.current.setRangeText(
if (n.children.length) { `nostr:${selected.pubkey} `,
// @ts-expect-error, backlog pos.caret - name.length - 1,
return n.children pos.caret,
.map((n) => { "end",
if (n.type === "mention") return n.npub; );
return Node.string(n).trim();
})
.join(" ");
}
return Node.string(n); setPos(null);
}) setIndex(0);
.join("\n");
}; };
const publish = async () => { const publish = async () => {
try { startTransition(async () => {
// start loading try {
setLoading(true); const content = text.trim();
const content = serialize(editor.children); await LumeEvent.publish(
const eventId = await LumeEvent.publish( content,
content, warning.enable && warning.reason.length ? warning.reason : null,
warning.enable && warning.reason.length ? warning.reason : null, difficulty.num,
difficulty.enable && difficulty.num > 0 ? difficulty.num : null, reply_to,
reply_to, );
);
if (eventId) { setText("");
// stop loading } catch {
setLoading(false); return;
// reset form
reset();
} }
} catch (e) { });
setLoading(false);
}
}; };
useEffect(() => { useEffect(() => {
setEditorValue(initialValue); if (initialValue?.length) {
setText(initialValue);
}
}, [initialValue]); }, [initialValue]);
if (!editorValue) return null;
return ( return (
<div className="flex flex-col w-full h-full"> <div className="flex flex-col w-full h-full">
<Slate editor={editor} initialValue={editorValue}> <div data-tauri-drag-region className="h-11 shrink-0" />
<div data-tauri-drag-region className="h-11 shrink-0" /> <div className="flex flex-col flex-1 overflow-y-auto">
<div className="flex flex-col flex-1 overflow-y-auto"> {reply_to?.length ? (
{reply_to?.length ? ( <div className="flex flex-col gap-2 px-3.5 pb-3 border-b border-black/5 dark:border-white/5">
<div className="flex flex-col gap-2 px-3.5 pb-3 border-b border-black/5 dark:border-white/5"> <span className="text-sm font-semibold">Reply to:</span>
<span className="text-sm font-semibold">Reply to:</span> <EmbedNote id={reply_to} />
<ChildNote id={reply_to} />
</div>
) : null}
<div className="px-4 py-4 overflow-y-auto">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={
reply_to ? "Type your reply..." : "What're you up to?"
}
className="focus:outline-none"
/>
</div>
</div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
setWarning((prev) => ({ ...prev, reason: e.target.value }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div> </div>
) : null} ) : null}
{difficulty.enable ? ( <div className="p-4 overflow-y-auto h-full">
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5"> <RichTextarea
<span className="text-sm shrink-0 text-black/50 dark:text-white/50"> ref={ref}
Difficulty: value={text}
</span> placeholder={reply_to ? "Type your reply..." : "What're you up to?"}
<input style={{ width: "100%", height: "100%" }}
type="text" className="text-[15px] leading-normal resize-none border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 placeholder:pt-[1.5px] placeholder:pl-2"
inputMode="numeric" onChange={(e) => setText(e.target.value)}
pattern="[0-9]" onKeyDown={(e) => {
onKeyDown={(event) => { if (!pos || !filtered.length) return;
if (!/[0-9]/.test(event.key)) { switch (e.code) {
event.preventDefault(); case "ArrowUp": {
e.preventDefault();
const nextIndex =
index <= 0 ? filtered.length - 1 : index - 1;
setIndex(nextIndex);
break;
} }
}} case "ArrowDown": {
placeholder="21" e.preventDefault();
defaultValue={difficulty.num} const prevIndex =
onChange={(e) => index >= filtered.length - 1 ? 0 : index + 1;
setWarning((prev) => ({ ...prev, num: Number(e.target.value) })) setIndex(prevIndex);
break;
}
case "Enter":
e.preventDefault();
insert(index);
break;
case "Escape":
e.preventDefault();
setPos(null);
setIndex(0);
break;
default:
break;
} }
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50" }}
/> onSelectionChange={(r) => {
</div> if (
) : null} r.focused &&
<div MENTION_REG.test(text.slice(0, r.selectionStart))
data-tauri-drag-region ) {
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5" setPos({
> top: r.top + r.height,
<button left: r.left,
type="button" caret: r.selectionStart,
onClick={() => publish()} });
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" setIndex(0);
} else {
setPos(null);
setIndex(0);
}
}}
> >
{loading ? ( {renderer}
<Spinner className="size-4" /> </RichTextarea>
) : ( {pos
<Feather className="size-4" weight="fill" /> ? createPortal(
)} <Menu
Publish top={pos.top}
</button> left={pos.left}
<div className="inline-flex items-center flex-1 gap-2 pl-4"> users={filtered}
<MediaButton /> index={index}
<WarningButton setWarning={setWarning} /> insert={insert}
<PowButton setDifficulty={setDifficulty} /> />,
</div> document.body,
)
: null}
</div> </div>
</Slate> </div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
setWarning((prev) => ({ ...prev, reason: e.target.value }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault();
}
}}
placeholder="21"
defaultValue={difficulty.num}
onChange={(e) =>
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
<div
data-tauri-drag-region
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
>
<button
type="button"
onClick={() => publish()}
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
{isPending ? (
<Spinner className="size-4" />
) : (
<Feather className="size-4" weight="fill" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton setText={setText} setAttaches={setAttaches} />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
</div>
</div> </div>
); );
} }
function ChildNote({ id }: { id: string }) { function Menu({
users,
index,
top,
left,
insert,
}: {
users: Mention[];
index: number;
top: number;
left: number;
insert: (index: number) => void;
}) {
return (
<div
style={{
top: top,
left: left,
}}
className="fixed w-[200px] text-sm bg-white dark:bg-black shadow-lg shadow-neutral-500/20 rounded-lg overflow-hidden"
>
{users.map((u, i) => (
<div
key={u.pubkey}
className={cn(
"flex items-center gap-1.5 p-2",
index === i ? "bg-neutral-100 dark:bg-neutral-900" : null,
)}
onMouseDown={(e) => {
e.preventDefault();
insert(i);
}}
>
<div className="size-7 shrink-0">
{u.avatar?.length ? (
<img
src={u.avatar}
className="size-7 rounded-full outline outline-1 -outline-offset-1 outline-black/15"
loading="lazy"
decoding="async"
/>
) : (
<div className="size-7 rounded-full bg-blue-500" />
)}
</div>
{u.name}
</div>
))}
</div>
);
}
function EmbedNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id); const { isLoading, isError, data } = useEvent(id);
if (isLoading) { if (isLoading) {
@ -258,142 +377,3 @@ function ChildNote({ id }: { id: string }) {
</Note.Provider> </Note.Provider>
); );
} }
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (text.startsWith("nevent") || text.startsWith("note")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const withMentions = (editor: ReactEditor) => {
const { isInline, isVoid, markableVoid } = editor;
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
return editor;
};
const withImages = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const Image = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const selected = useSelected();
const focused = useFocused();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
<img
src={element.url}
alt={element.url}
className={cn(
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
/>
</div>
);
};
const Mention = ({ attributes, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
const Event = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
<div
contentEditable={false}
className="relative my-2 user-select-none"
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
>
<MentionNote eventId={element.eventId} />
</div>
</div>
);
};
const Element = (props) => {
const { attributes, children, element } = props;
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-[15px]">
{children}
</p>
);
}
};