Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e8678e4c6 | |||
| 97569016fc | |||
| fe72798592 | |||
| 4583c4e028 | |||
| 0b98197a86 | |||
| 0e94a9c33f | |||
| 3dff1fcb4d | |||
| e163286dd4 | |||
| a99e12f12e | |||
| c3dd997e57 | |||
| a730384baf | |||
| 43cf91e877 | |||
| 75bee027e1 | |||
| 5cbf69a8bd | |||
| ecbb3086d8 | |||
| 7476767aa7 | |||
| e5b8987a9d | |||
| 6ca74c21bf | |||
| e0099141aa | |||
| d0491ed202 | |||
| cbc2137ced | |||
| f9ac13ba11 | |||
| b3533c285f | |||
| a636ae6592 | |||
| 69e3ee0aff | |||
| a39a87ba6d | |||
| 5b22d6ac01 |
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
# 1.4.0
|
||||
|
||||
* Allow "editing" chat messages
|
||||
* Check for room create permission
|
||||
* Re-work space navigation
|
||||
* Show all messages in non-nip29 chat
|
||||
* Improve synchronization logic
|
||||
* Add connection status to space menu
|
||||
* Add icon picker to room create component
|
||||
* Improve mention suggestions
|
||||
* Improve storage adapter and relay list performance
|
||||
* Fix modals
|
||||
* Add room deletion
|
||||
* Fix zapper loading
|
||||
* Add support for relay/group member lists and join/leave events
|
||||
|
||||
# 1.3.1
|
||||
|
||||
* Fix memory leak in storage adapter
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "social.flotilla"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 28
|
||||
versionName "1.3.1"
|
||||
versionCode 29
|
||||
versionName "1.4.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -358,14 +358,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -384,14 +384,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
+14
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -10,7 +10,8 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check src && eslint src",
|
||||
"format": "prettier --write src",
|
||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte)$' | xargs -r prettier --write",
|
||||
"format:all": "prettier --write src",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -59,17 +60,16 @@
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@vite-pwa/sveltekit": "^0.6.8",
|
||||
"@welshman/app": "^0.5.3",
|
||||
"@welshman/content": "^0.5.3",
|
||||
"@welshman/editor": "^0.5.3",
|
||||
"@welshman/feeds": "^0.5.3",
|
||||
"@welshman/lib": "^0.5.3",
|
||||
"@welshman/net": "^0.5.3",
|
||||
"@welshman/relay": "^0.5.3",
|
||||
"@welshman/router": "^0.5.3",
|
||||
"@welshman/signer": "^0.5.3",
|
||||
"@welshman/store": "^0.5.3",
|
||||
"@welshman/util": "^0.5.3",
|
||||
"@welshman/app": "^0.6.2",
|
||||
"@welshman/content": "^0.6.2",
|
||||
"@welshman/editor": "^0.6.2",
|
||||
"@welshman/feeds": "^0.6.2",
|
||||
"@welshman/lib": "^0.6.2",
|
||||
"@welshman/net": "^0.6.2",
|
||||
"@welshman/router": "^0.6.2",
|
||||
"@welshman/signer": "^0.6.2",
|
||||
"@welshman/store": "^0.6.2",
|
||||
"@welshman/util": "^0.6.2",
|
||||
"compressorjs": "^1.2.1",
|
||||
"daisyui": "^4.12.24",
|
||||
"date-picker-svelte": "^2.16.0",
|
||||
@@ -79,7 +79,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"idb": "^8.0.3",
|
||||
"nostr-signer-capacitor-plugin": "^0.0.4",
|
||||
"nostr-tools": "^2.17.0",
|
||||
"nostr-tools": "^2.14.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
Generated
+81
-95
@@ -72,38 +72,35 @@ importers:
|
||||
specifier: ^0.6.8
|
||||
version: 0.6.8(@sveltejs/kit@2.46.5(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.39.12)(vite@5.4.20(@types/node@24.7.2)(terser@5.44.0)))(svelte@5.39.12)(vite@5.4.20(@types/node@24.7.2)(terser@5.44.0)))(@vite-pwa/assets-generator@0.2.6)(vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@0.2.6)(vite@5.4.20(@types/node@24.7.2)(terser@5.44.0))(workbox-build@7.3.0)(workbox-window@7.3.0))
|
||||
'@welshman/app':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/content':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(typescript@5.9.3)
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2(typescript@5.9.3)
|
||||
'@welshman/editor':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(linkifyjs@4.3.2)(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(typescript@5.9.3)
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(linkifyjs@4.3.2)(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(typescript@5.9.3)
|
||||
'@welshman/feeds':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/lib':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2
|
||||
'@welshman/net':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/relay':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(typescript@5.9.3)
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/router':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(typescript@5.9.3)
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/signer':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/store':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(typescript@5.9.3)
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/util':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(typescript@5.9.3)
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2(typescript@5.9.3)
|
||||
compressorjs:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
@@ -132,7 +129,7 @@ importers:
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4(@capacitor/core@7.4.3)
|
||||
nostr-tools:
|
||||
specifier: ^2.17.0
|
||||
specifier: ^2.14.2
|
||||
version: 2.17.0(typescript@5.9.3)
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.6.14
|
||||
@@ -1695,41 +1692,38 @@ packages:
|
||||
'@vite-pwa/assets-generator':
|
||||
optional: true
|
||||
|
||||
'@welshman/app@0.5.3':
|
||||
resolution: {integrity: sha512-uygqUotknMYPEE7jkpBEWJXEfwHrowbMYuR1TygdqqKIWv/BzlZ7HO/infIHZHWy1xuR/9gEFhEY1sukxkNEow==}
|
||||
'@welshman/app@0.6.2':
|
||||
resolution: {integrity: sha512-URvw8IMCkZpotyKyCKYyyHkNmF9ghYUAelZmfQrC0YttiDUVQGE0qq5/3gwZ1HAy6f9hbMWH2hfPLVnaEh4K2A==}
|
||||
|
||||
'@welshman/content@0.5.3':
|
||||
resolution: {integrity: sha512-o4zQReYP0dcE6ofom0nEbYqSBe+dlNdQAQ3LcaRY2Bgm+IG8mPtT8FZ6SFnAHJOG215MW5uQ8eUuqOtSKzlYcg==}
|
||||
'@welshman/content@0.6.2':
|
||||
resolution: {integrity: sha512-eiDpxKhm87i2JCsINtt8SGkt58JCL0UB6i0rzHsyZC1oOSM0W3OJ3NkZGDNooneDe02GpvgzDAAJa3m8QgnnzA==}
|
||||
|
||||
'@welshman/editor@0.5.3':
|
||||
resolution: {integrity: sha512-rIm3WquSVcBDwaa0tI9ppBHnEEHifPWAy6kTks1M2ZLUNrhyBMMCqsjEIJiWEmP7uVuzvsSH3LDk/Bs+tYUPcg==}
|
||||
'@welshman/editor@0.6.2':
|
||||
resolution: {integrity: sha512-+sCnDCvjPLy94maRPLJdZEdQgys6XyYPOmxpXaj/AmkhqGIGovA/7YiIB86vS1NHmRy02hgM/eMV311jaVe6ww==}
|
||||
|
||||
'@welshman/feeds@0.5.3':
|
||||
resolution: {integrity: sha512-ilE6Ui+nKlV+fyk8MDd+3fx5xU/K5hOLM5p0idE+cLeA0th2IanKm4JRS5vz9p/Niox4XhyvuaCxVNRLxXZ4zg==}
|
||||
'@welshman/feeds@0.6.2':
|
||||
resolution: {integrity: sha512-bOgcLwVd/n5HlhxuflBZLXJZfKhQAJDbYh40sNr1dtKh4eM6vd5ujMZyj8D2OwdfazOAVX3ZwRenJCdy5+gwVw==}
|
||||
|
||||
'@welshman/lib@0.5.3':
|
||||
resolution: {integrity: sha512-XElYDyJhuHLHwJdaoTKdq9a+evUoFxqguTh18cIOpsp55f8FOVcmF67PN3YZ7p/PTt6d3kWh7jQ2AIo3yJIPQg==}
|
||||
'@welshman/lib@0.6.2':
|
||||
resolution: {integrity: sha512-1GpCOr0pXTDdy87PEvAV0+lvjgQbFODU5RsNM0LTyeU0885uzPHrfoJbZoNXayY9klwuvS9NBHFkUN7lySITzA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
'@welshman/net@0.5.3':
|
||||
resolution: {integrity: sha512-XhGwkG35LuLo38pFJmkEa1/D0DaMP83KziZ9F0VMVDNOSgSCkLBuANbcCeZWcNuKNb/EwlvzJZLWA0ywqyq+1g==}
|
||||
'@welshman/net@0.6.2':
|
||||
resolution: {integrity: sha512-MX2nGqH2/P/Q9WeIiN9AHo4/n2S4mABE6gCACB5vA1v3FdwKjinN/otoUJGo+fTlaIMJHnxoDYV4vOT3F9mZuA==}
|
||||
|
||||
'@welshman/relay@0.5.3':
|
||||
resolution: {integrity: sha512-AWWDQ01TPMsakuET+hkarBQeeqk20mcKk4fmeR/bEUZWSGCDbCK6pD1L1ozilJ5dQKTmWrSzHGe+jJU4sFvZNw==}
|
||||
'@welshman/router@0.6.2':
|
||||
resolution: {integrity: sha512-dyTFn5h1oxP7bh/G+kydqfvXTPjmq2aZ1ikwJ2rOsCaNNqBnm/d9o6r9knZoWGJpLD6a5szY5lA9Vo9DtUuouA==}
|
||||
|
||||
'@welshman/router@0.5.3':
|
||||
resolution: {integrity: sha512-abowfhW14vDBZFcDZuTAj4ktIJyfmOkAWlkXAD3g1mu2amoJ0LZ2jDro8GEFa+qUv2dnxLh0yILdv/89SzUguA==}
|
||||
|
||||
'@welshman/signer@0.5.3':
|
||||
resolution: {integrity: sha512-XlXtVpF5Mj4bmYtf32p1eRCsBK3o8e+lfBFJ7kXIU0FWmZHVtOnl7+ZjU5SCRqREG4zmzn6Ad0G/JA4kYJMIWw==}
|
||||
'@welshman/signer@0.6.2':
|
||||
resolution: {integrity: sha512-UYCr0c9xj2qbknDD5HH94+cfdX7v27PGJPZ9V7JuQQHdDzK8LUOLXf5NvGqcTbAb6IlUnTGbvA19oJZP6l0vNw==}
|
||||
peerDependencies:
|
||||
nostr-signer-capacitor-plugin: ~0.0.4
|
||||
|
||||
'@welshman/store@0.5.3':
|
||||
resolution: {integrity: sha512-VB2r0NASwnFxzm/Io1qZ73yD3lHA8wEEjmlHOnH0FXNp/UNlJKGREWsgsjbLH/A+04lCjsMv2QmlkW2NqCBS/A==}
|
||||
'@welshman/store@0.6.2':
|
||||
resolution: {integrity: sha512-ZRELLUtadbUoLV678qJ8Lt2KmcCe+Qi3frsKJmPW5HeuPHKgNJBY9QgqYK2D07T42nLGqrqLdVfv3Mz0xp3eGA==}
|
||||
|
||||
'@welshman/util@0.5.3':
|
||||
resolution: {integrity: sha512-avMOK1AcO3V9qTQP+oAl4pgTdquD86Fkey74f1WOOu3BZRVgWN7LjBQCecszW9BQMg/TH4mUk2PuSqSoPy8zSw==}
|
||||
'@welshman/util@0.6.2':
|
||||
resolution: {integrity: sha512-stBlkzlso4A0mpicQjwrHvfXdbZ5XTT2K4iuTFe9a4DN5bKkDiHYEWvEjbd3751TJK1BKH9snOfgdKuxnkvSeg==}
|
||||
|
||||
'@xml-tools/parser@1.0.11':
|
||||
resolution: {integrity: sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA==}
|
||||
@@ -6210,12 +6204,12 @@ snapshots:
|
||||
'@scure/bip32@1.3.1':
|
||||
dependencies:
|
||||
'@noble/curves': 1.1.0
|
||||
'@noble/hashes': 1.3.1
|
||||
'@noble/hashes': 1.3.2
|
||||
'@scure/base': 1.1.1
|
||||
|
||||
'@scure/bip39@1.2.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.3.1
|
||||
'@noble/hashes': 1.3.2
|
||||
'@scure/base': 1.1.1
|
||||
|
||||
'@sentry-internal/browser-utils@8.55.0':
|
||||
@@ -6657,17 +6651,16 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@vite-pwa/assets-generator': 0.2.6
|
||||
|
||||
'@welshman/app@0.5.3(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
|
||||
'@welshman/app@0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
|
||||
dependencies:
|
||||
'@types/throttle-debounce': 5.0.2
|
||||
'@welshman/feeds': 0.5.3(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/lib': 0.5.3
|
||||
'@welshman/net': 0.5.3(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/relay': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/router': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/signer': 0.5.3(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/store': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/util': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/feeds': 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/lib': 0.6.2
|
||||
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/router': 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/signer': 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/store': 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/util': 0.6.2(typescript@5.9.3)
|
||||
fuse.js: 7.1.0
|
||||
svelte: 4.2.20
|
||||
throttle-debounce: 5.0.2
|
||||
@@ -6676,14 +6669,14 @@ snapshots:
|
||||
- typescript
|
||||
- ws
|
||||
|
||||
'@welshman/content@0.5.3(typescript@5.9.3)':
|
||||
'@welshman/content@0.6.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@braintree/sanitize-url': 7.1.1
|
||||
nostr-tools: 2.17.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@welshman/editor@0.5.3(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(linkifyjs@4.3.2)(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(typescript@5.9.3)':
|
||||
'@welshman/editor@0.6.2(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(linkifyjs@4.3.2)(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
|
||||
'@tiptap/extension-code': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
|
||||
@@ -6698,8 +6691,8 @@ snapshots:
|
||||
'@tiptap/extension-text': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
|
||||
'@tiptap/pm': 2.26.3
|
||||
'@tiptap/suggestion': 2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
|
||||
'@welshman/lib': 0.5.3
|
||||
'@welshman/util': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/lib': 0.6.2
|
||||
'@welshman/util': 0.6.2(typescript@5.9.3)
|
||||
nostr-editor: 1.0.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/extension-image@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.26.3(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)(linkifyjs@4.3.2)(nostr-tools@2.17.0(typescript@5.9.3))(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.3)(tiptap-markdown@0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))
|
||||
nostr-tools: 2.17.0(typescript@5.9.3)
|
||||
tippy.js: 6.3.7
|
||||
@@ -6714,78 +6707,71 @@ snapshots:
|
||||
- tiptap-markdown
|
||||
- typescript
|
||||
|
||||
'@welshman/feeds@0.5.3(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
|
||||
'@welshman/feeds@0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
|
||||
dependencies:
|
||||
'@welshman/lib': 0.5.3
|
||||
'@welshman/net': 0.5.3(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/relay': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/router': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/signer': 0.5.3(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/util': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/lib': 0.6.2
|
||||
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/router': 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/signer': 0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/util': 0.6.2(typescript@5.9.3)
|
||||
trava: 1.2.1
|
||||
transitivePeerDependencies:
|
||||
- nostr-signer-capacitor-plugin
|
||||
- typescript
|
||||
- ws
|
||||
|
||||
'@welshman/lib@0.5.3':
|
||||
'@welshman/lib@0.6.2':
|
||||
dependencies:
|
||||
'@scure/base': 1.2.6
|
||||
'@types/events': 3.0.3
|
||||
events: 3.3.0
|
||||
|
||||
'@welshman/net@0.5.3(typescript@5.9.3)(ws@8.18.3)':
|
||||
'@welshman/net@0.6.2(typescript@5.9.3)(ws@8.18.3)':
|
||||
dependencies:
|
||||
'@welshman/lib': 0.5.3
|
||||
'@welshman/relay': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/util': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/lib': 0.6.2
|
||||
'@welshman/util': 0.6.2(typescript@5.9.3)
|
||||
events: 3.3.0
|
||||
isomorphic-ws: 5.0.0(ws@8.18.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
- ws
|
||||
|
||||
'@welshman/relay@0.5.3(typescript@5.9.3)':
|
||||
'@welshman/router@0.6.2(typescript@5.9.3)(ws@8.18.3)':
|
||||
dependencies:
|
||||
'@welshman/lib': 0.5.3
|
||||
'@welshman/util': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/lib': 0.6.2
|
||||
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/util': 0.6.2(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
- ws
|
||||
|
||||
'@welshman/router@0.5.3(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@welshman/lib': 0.5.3
|
||||
'@welshman/relay': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/util': 0.5.3(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@welshman/signer@0.5.3(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
|
||||
'@welshman/signer@0.6.2(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
|
||||
dependencies:
|
||||
'@noble/curves': 1.9.7
|
||||
'@noble/hashes': 1.8.0
|
||||
'@welshman/lib': 0.5.3
|
||||
'@welshman/net': 0.5.3(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/util': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/lib': 0.6.2
|
||||
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/util': 0.6.2(typescript@5.9.3)
|
||||
nostr-signer-capacitor-plugin: 0.0.4(@capacitor/core@7.4.3)
|
||||
nostr-tools: 2.17.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
- ws
|
||||
|
||||
'@welshman/store@0.5.3(typescript@5.9.3)':
|
||||
'@welshman/store@0.6.2(typescript@5.9.3)(ws@8.18.3)':
|
||||
dependencies:
|
||||
'@welshman/lib': 0.5.3
|
||||
'@welshman/relay': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/util': 0.5.3(typescript@5.9.3)
|
||||
'@welshman/lib': 0.6.2
|
||||
'@welshman/net': 0.6.2(typescript@5.9.3)(ws@8.18.3)
|
||||
'@welshman/util': 0.6.2(typescript@5.9.3)
|
||||
svelte: 4.2.20
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
- ws
|
||||
|
||||
'@welshman/util@0.5.3(typescript@5.9.3)':
|
||||
'@welshman/util@0.6.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@types/ws': 8.18.1
|
||||
'@welshman/lib': 0.5.3
|
||||
'@welshman/lib': 0.6.2
|
||||
js-base64: 3.7.8
|
||||
nostr-tools: 2.17.0(typescript@5.9.3)
|
||||
nostr-wasm: 0.1.0
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {alerts, getMembershipUrls, userMembership} from "@app/core/state"
|
||||
import {alerts, userSpaceUrls} from "@app/core/state"
|
||||
import {requestRelayClaim} from "@app/core/requests"
|
||||
import {createAlert} from "@app/core/commands"
|
||||
import {canSendPushNotifications} from "@app/util/push"
|
||||
@@ -174,7 +174,7 @@
|
||||
{#snippet input()}
|
||||
<select bind:value={url} class="select select-bordered">
|
||||
<option value="" disabled selected>Choose a space URL</option>
|
||||
{#each getMembershipUrls($userMembership) as url (url)}
|
||||
{#each $userSpaceUrls as url (url)}
|
||||
<option value={url}>{displayRelayUrl(url)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeCalendarPath} from "@app/util/routes"
|
||||
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
|
||||
|
||||
const {
|
||||
url,
|
||||
event,
|
||||
showActivity = false,
|
||||
}: {
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
showRoom?: boolean
|
||||
showActivity?: boolean
|
||||
} = $props()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const room = getTagValue("h", event.tags)
|
||||
const path = makeCalendarPath(url, event.id)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
|
||||
|
||||
@@ -36,24 +38,27 @@
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Event">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $pubkey}
|
||||
<li>
|
||||
<Button onclick={editEvent}>
|
||||
<Icon size={4} icon={Pen2} />
|
||||
Edit Event
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EventActions>
|
||||
</div>
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
{#if room && showRoom}
|
||||
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<ChannelName {room} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Event">
|
||||
{#snippet customActions()}
|
||||
{#if event.pubkey === $pubkey}
|
||||
<li>
|
||||
<Button onclick={editEvent}>
|
||||
<Icon size={4} icon={Pen2} />
|
||||
Edit Event
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EventActions>
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
room?: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const {url, room}: Props = $props()
|
||||
</script>
|
||||
|
||||
<CalendarEventForm {url}>
|
||||
<CalendarEventForm {url} {room}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
{#snippet title()}
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
|
||||
const {event}: Props = $props()
|
||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||
const startDate = $derived(secondsToDate(parseInt(meta.start)))
|
||||
const start = $derived(parseInt(meta.start))
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
|
||||
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
|
||||
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
|
||||
<span class="text-xs opacity-75"
|
||||
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
|
||||
</div>
|
||||
{#if !isNaN(start)}
|
||||
{@const startDate = secondsToDate(start)}
|
||||
<div
|
||||
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
|
||||
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
|
||||
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
|
||||
<span class="text-xs opacity-75"
|
||||
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
room?: string
|
||||
header: Snippet
|
||||
initialValues?: {
|
||||
d: string
|
||||
@@ -34,7 +35,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const {url, header, initialValues}: Props = $props()
|
||||
const {url, room, header, initialValues}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -84,6 +85,10 @@
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (room) {
|
||||
tags.push(["h", room])
|
||||
}
|
||||
|
||||
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||
|
||||
pushToast({message: "Your event has been saved!"})
|
||||
|
||||
@@ -17,18 +17,20 @@
|
||||
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
|
||||
const start = $derived(parseInt(meta.start))
|
||||
const end = $derived(parseInt(meta.end))
|
||||
const startDateDisplay = $derived(formatTimestampAsDate(start))
|
||||
const endDateDisplay = $derived(formatTimestampAsDate(end))
|
||||
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
||||
<p class="text-xl">{meta.title || meta.name}</p>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Icon icon={ClockCircle} size={4} />
|
||||
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
|
||||
{formatTimestampAsTime(start)} — {isSingleDay
|
||||
? formatTimestampAsTime(end)
|
||||
: formatTimestamp(end)}
|
||||
</div>
|
||||
{#if !isNaN(start) && !isNaN(end)}
|
||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||
{@const endDateDisplay = formatTimestampAsDate(end)}
|
||||
{@const isSingleDay = startDateDisplay === endDateDisplay}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Icon icon={ClockCircle} size={4} />
|
||||
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
|
||||
{formatTimestampAsTime(start)} — {isSingleDay
|
||||
? formatTimestampAsTime(end)
|
||||
: formatTimestamp(end)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ChannelLink from "@app/components/ChannelLink.svelte"
|
||||
import {makeCalendarPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
@@ -12,6 +14,8 @@
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const room = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
|
||||
@@ -19,6 +23,9 @@
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||
{#if room}
|
||||
in <ChannelLink {url} {room} />
|
||||
{/if}
|
||||
</span>
|
||||
<CalendarEventActions showActivity {url} {event} />
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type {Instance} from "tippy.js"
|
||||
import {writable} from "svelte/store"
|
||||
import type {EventContent} from "@welshman/util"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||
import WidgetAdd from "@assets/icons/widget-add.svg?dataurl"
|
||||
import Plane from "@assets/icons/plane-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
|
||||
type Props = {
|
||||
url?: string
|
||||
room?: string
|
||||
content?: string
|
||||
onEditPrevious?: () => void
|
||||
onSubmit: (event: EventContent) => void
|
||||
}
|
||||
|
||||
const {onSubmit, url}: Props = $props()
|
||||
const {url, room, content, onEditPrevious, onSubmit}: Props = $props()
|
||||
|
||||
const autofocus = !isMobile
|
||||
|
||||
@@ -22,8 +30,21 @@
|
||||
|
||||
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
||||
|
||||
export const canEnterEditPrevious = () =>
|
||||
editor.then(ed => ed.getText({blockSeparator: "\n"}) === "")
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowUp" && (await canEnterEditPrevious())) {
|
||||
onEditPrevious?.()
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||
|
||||
const showPopover = () => popover?.show()
|
||||
|
||||
const hidePopover = () => popover?.hide()
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
|
||||
@@ -38,21 +59,48 @@
|
||||
ed.chain().clearContent().run()
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, autofocus, submit, uploading, aggressive: true})
|
||||
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true})
|
||||
|
||||
let popover: Instance | undefined = $state()
|
||||
|
||||
onMount(async () => {
|
||||
const ed = await editor
|
||||
ed.view.dom.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onDestroy(async () => {
|
||||
const ed = await editor
|
||||
ed?.view?.dom.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
||||
disabled={$uploading}
|
||||
onclick={uploadFiles}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Icon icon={GallerySend} />
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="join">
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="center join-item tooltip tooltip-right h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
|
||||
disabled={$uploading}
|
||||
onclick={uploadFiles}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Icon icon={GallerySend} />
|
||||
{/if}
|
||||
</Button>
|
||||
<Tippy
|
||||
bind:popover
|
||||
component={ComposeMenu}
|
||||
props={{url, room, onClick: hidePopover}}
|
||||
params={{trigger: "manual", interactive: true}}>
|
||||
<Button
|
||||
data-tip="More options"
|
||||
class="center join-item tooltip tooltip-right h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
|
||||
disabled={$uploading}
|
||||
onclick={showPopover}>
|
||||
<Icon icon={WidgetAdd} />
|
||||
</Button>
|
||||
</Tippy>
|
||||
</div>
|
||||
<div class="chat-editor flex-grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import {slide} from "@lib/transition"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
||||
const {
|
||||
clear,
|
||||
}: {
|
||||
clear: () => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
|
||||
transition:slide>
|
||||
<p class="text-primary">Editing message</p>
|
||||
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
|
||||
<Icon icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
|
||||
const {
|
||||
verb,
|
||||
@@ -19,16 +19,11 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
|
||||
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8"
|
||||
transition:slide>
|
||||
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
|
||||
<p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
|
||||
{#key event.id}
|
||||
<NoteContent
|
||||
{event}
|
||||
hideMediaAtDepth={0}
|
||||
minLength={100}
|
||||
maxLength={300}
|
||||
expandMode="disabled" />
|
||||
<NoteContentMinimal trimParent {event} />
|
||||
{/key}
|
||||
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
|
||||
<Icon icon={CloseCircle} />
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
<script lang="ts">
|
||||
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
|
||||
import cx from "classnames"
|
||||
import {hash, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
|
||||
import {MESSAGE, COMMENT} from "@welshman/util"
|
||||
import {
|
||||
thunks,
|
||||
pubkey,
|
||||
mergeThunks,
|
||||
deriveProfile,
|
||||
deriveProfileDisplay,
|
||||
displayProfileByPubkey,
|
||||
} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import ReplyAlt from "@assets/icons/reply.svg?dataurl"
|
||||
import TapTarget from "@lib/components/TapTarget.svelte"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte"
|
||||
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
|
||||
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
|
||||
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
|
||||
import {colors, ENABLE_ZAPS} from "@app/core/state"
|
||||
import ChannelItemZapButton from "@app/components/ChannelItemZapButton.svelte"
|
||||
import ChannelItemEmojiButton from "@app/components/ChannelItemEmojiButton.svelte"
|
||||
import ChannelItemMenuButton from "@app/components/ChannelItemMenuButton.svelte"
|
||||
import ChannelItemMenuMobile from "@app/components/ChannelItemMenuMobile.svelte"
|
||||
import ChannelItemContent from "@app/components/ChannelItemContent.svelte"
|
||||
import {colors, ENABLE_ZAPS, deriveEventsForUrl} from "@app/core/state"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {getChannelItemPath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
interface Props {
|
||||
@@ -26,20 +39,33 @@
|
||||
replyTo?: (event: TrustedEvent) => void
|
||||
showPubkey?: boolean
|
||||
inert?: boolean
|
||||
canEdit: (event: TrustedEvent) => boolean
|
||||
onEdit: (event: TrustedEvent) => void
|
||||
}
|
||||
|
||||
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
|
||||
const {
|
||||
url,
|
||||
event,
|
||||
replyTo = undefined,
|
||||
showPubkey = false,
|
||||
inert = false,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: Props = $props()
|
||||
|
||||
const thunk = $thunks[event.id]
|
||||
const path = getChannelItemPath(url, event)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const today = formatTimestampAsDate(now())
|
||||
const profile = deriveProfile(event.pubkey, [url])
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [event.id]}])
|
||||
|
||||
const reply = () => replyTo!(event)
|
||||
const edit = canEdit(event) ? () => onEdit(event) : undefined
|
||||
|
||||
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
|
||||
const onTap = () => pushModal(ChannelItemMenuMobile, {url, event, reply, edit})
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
||||
|
||||
@@ -53,7 +79,7 @@
|
||||
<TapTarget
|
||||
data-event={event.id}
|
||||
onTap={inert ? null : onTap}
|
||||
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
|
||||
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
|
||||
<div class="flex w-full gap-3 overflow-auto">
|
||||
{#if showPubkey}
|
||||
<Button onclick={openProfile} class="flex items-start">
|
||||
@@ -78,10 +104,10 @@
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-sm">
|
||||
<Content minimalQuote {event} {url} />
|
||||
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
|
||||
<ChannelItemContent {url} {event} />
|
||||
{#if thunk}
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-2" />
|
||||
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,21 +119,43 @@
|
||||
{deleteReaction}
|
||||
{createReaction}
|
||||
reactionClass="tooltip-right" />
|
||||
{#if path && $comments.length > 0}
|
||||
{@const pubkeys = $comments.map(e => e.pubkey)}
|
||||
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
|
||||
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
|
||||
{@const tooltip = `${info} commented`}
|
||||
<div data-tip={tooltip} class="tooltip tooltip-right flex">
|
||||
<Link
|
||||
href={path}
|
||||
class={cx("btn btn-xs gap-1 rounded-full", {
|
||||
"btn-neutral": !isOwn,
|
||||
"btn-primary": isOwn,
|
||||
})}>
|
||||
<Icon icon={ReplyAlt} />
|
||||
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
|
||||
</Link>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !isMobile}
|
||||
<button
|
||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
|
||||
class:group-hover:opacity-100={!isMobile}>
|
||||
{#if ENABLE_ZAPS}
|
||||
<ChannelMessageZapButton {url} {event} />
|
||||
<ChannelItemZapButton {url} {event} />
|
||||
{/if}
|
||||
<ChannelMessageEmojiButton {url} {event} />
|
||||
<ChannelItemEmojiButton {url} {event} />
|
||||
{#if replyTo}
|
||||
<Button class="btn join-item btn-xs" onclick={reply}>
|
||||
<Icon icon={Reply} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
<ChannelMessageMenuButton {url} {event} />
|
||||
{#if edit}
|
||||
<Button class="btn join-item btn-xs" onclick={edit}>
|
||||
<Icon icon={Pen} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
<ChannelItemMenuButton {url} {event} />
|
||||
</button>
|
||||
{/if}
|
||||
</TapTarget>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getPubkeyTagValues} from "@welshman/util"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
</script>
|
||||
|
||||
{#each getPubkeyTagValues(event.tags) as pubkey}
|
||||
<div class="py-1 text-center text-xs opacity-75">
|
||||
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> joined the room
|
||||
</div>
|
||||
{/each}
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {MESSAGE} from "@welshman/util"
|
||||
import {isMobile} from "@lib/html"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import {getChannelItemPath} from "@app/util/routes"
|
||||
|
||||
const props: ComponentProps<typeof NoteContent> = $props()
|
||||
|
||||
const path = getChannelItemPath(props.url!, props.event)
|
||||
</script>
|
||||
|
||||
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
|
||||
{#if path && !isMobile}
|
||||
<Link href={path}>
|
||||
<NoteContent {...props} />
|
||||
</Link>
|
||||
{:else}
|
||||
<NoteContent {...props} />
|
||||
{/if}
|
||||
</div>
|
||||
+12
-5
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import EventReport from "@app/components/EventReport.svelte"
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
|
||||
const {url, event, onClick} = $props()
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const {url, event, onClick}: Props = $props()
|
||||
|
||||
const report = () => {
|
||||
onClick()
|
||||
@@ -32,7 +39,7 @@
|
||||
<li>
|
||||
<Button onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
Message Details
|
||||
Show JSON
|
||||
</Button>
|
||||
</li>
|
||||
{#if event.pubkey === $pubkey}
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
import ChannelMessageMenu from "@app/components/ChannelMessageMenu.svelte"
|
||||
import ChannelItemMenu from "@app/components/ChannelItemMenu.svelte"
|
||||
|
||||
const {url, event} = $props()
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</Button>
|
||||
<Tippy
|
||||
bind:popover
|
||||
component={ChannelMessageMenu}
|
||||
component={ChannelItemMenu}
|
||||
props={{url, event, onClick}}
|
||||
params={{trigger: "manual", interactive: true}} />
|
||||
</div>
|
||||
+36
-25
@@ -2,7 +2,14 @@
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
@@ -10,12 +17,8 @@
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import {ENABLE_ZAPS} from "@app/core/state"
|
||||
import {publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {getChannelItemPath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -25,6 +28,8 @@
|
||||
|
||||
const {url, event, reply}: Props = $props()
|
||||
|
||||
const path = getChannelItemPath(url, event)
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const onEmoji = (async (event: TrustedEvent, url: string, emoji: NativeEmoji) => {
|
||||
@@ -49,29 +54,35 @@
|
||||
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||
</script>
|
||||
|
||||
<div class="col-2">
|
||||
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
|
||||
<Icon size={4} icon={SmileCircle} />
|
||||
Send Reaction
|
||||
</Button>
|
||||
{#if ENABLE_ZAPS}
|
||||
<ZapButton replaceState {url} {event} class="btn btn-secondary w-full">
|
||||
<Icon size={4} icon={Bolt} />
|
||||
Send Zap
|
||||
</ZapButton>
|
||||
{/if}
|
||||
<Button class="btn btn-neutral w-full" onclick={sendReply}>
|
||||
<Icon size={4} icon={Reply} />
|
||||
Send Reply
|
||||
</Button>
|
||||
<Button class="btn btn-neutral" onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
Message Details
|
||||
</Button>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if event.pubkey === $pubkey}
|
||||
<Button class="btn btn-neutral text-error" onclick={showDelete}>
|
||||
<Icon size={4} icon={TrashBin2} />
|
||||
Delete Message
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
<Button class="btn btn-neutral" onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
Show JSON
|
||||
</Button>
|
||||
{#if path}
|
||||
<Link class="btn btn-neutral" href={path}>
|
||||
<Icon size={4} icon={MenuDots} />
|
||||
View Details
|
||||
</Link>
|
||||
{/if}
|
||||
<Button class="btn btn-outline btn-neutral w-full" onclick={sendReply}>
|
||||
<Icon size={4} icon={Reply} />
|
||||
Reply
|
||||
</Button>
|
||||
<Button class="btn btn-secondary w-full" onclick={showEmojiPicker}>
|
||||
<Icon size={4} icon={SmileCircle} />
|
||||
React
|
||||
</Button>
|
||||
{#if ENABLE_ZAPS}
|
||||
<ZapButton replaceState {url} {event} class="btn btn-primary w-full">
|
||||
<Icon size={4} icon={Bolt} />
|
||||
Zap
|
||||
</ZapButton>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getPubkeyTagValues} from "@welshman/util"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
</script>
|
||||
|
||||
{#each getPubkeyTagValues(event.tags) as pubkey}
|
||||
<div class="py-1 text-center text-xs opacity-75">
|
||||
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
|
||||
</div>
|
||||
{/each}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
room: string
|
||||
url: string
|
||||
class?: string
|
||||
unstyled?: boolean
|
||||
}
|
||||
|
||||
const {room, url, unstyled, ...props}: Props = $props()
|
||||
|
||||
const path = makeSpacePath(url, room)
|
||||
</script>
|
||||
|
||||
<Link href={path} class={cx(props.class, {"link-content bg-alt": !unstyled})}>
|
||||
#<ChannelName {room} {url} />
|
||||
</Link>
|
||||
@@ -11,6 +11,7 @@
|
||||
MINUTE,
|
||||
sortBy,
|
||||
remove,
|
||||
enumerate,
|
||||
formatTimestampAsDate,
|
||||
} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
|
||||
@@ -30,7 +31,6 @@
|
||||
loadInboxRelaySelections,
|
||||
inboxRelaySelectionsByPubkey,
|
||||
} from "@welshman/app"
|
||||
import type {AbstractThunk} from "@welshman/app"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
@@ -126,14 +126,13 @@
|
||||
|
||||
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
|
||||
// Sleep 1 second between each one to make sure timestamps are distinct
|
||||
const thunks: AbstractThunk[] = []
|
||||
for (let i = 0; i < templates.length; i++) {
|
||||
const template = templates[i]
|
||||
|
||||
thunks.push(
|
||||
await sendWrapped({pubkeys, template, delay: $userSettingsValues.send_delay + ms(i)}),
|
||||
)
|
||||
}
|
||||
const thunks = Array.from(enumerate(templates)).map(([i, event]) =>
|
||||
sendWrapped({
|
||||
event,
|
||||
recipients: pubkeys,
|
||||
delay: $userSettingsValues.send_delay + ms(i),
|
||||
}),
|
||||
)
|
||||
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
|
||||
const {
|
||||
verb,
|
||||
@@ -19,16 +19,11 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
|
||||
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8"
|
||||
transition:slide>
|
||||
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
|
||||
<p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
|
||||
{#key event.id}
|
||||
<NoteContent
|
||||
{event}
|
||||
hideMediaAtDepth={0}
|
||||
minLength={100}
|
||||
maxLength={300}
|
||||
expandMode="disabled" />
|
||||
<NoteContentMinimal trimParent {event} />
|
||||
{/key}
|
||||
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
|
||||
<Icon icon={CloseCircle} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {shouldUnwrap} from "@welshman/app"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -9,7 +10,6 @@
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {PLATFORM_NAME} from "@app/core/state"
|
||||
import {enableGiftWraps} from "@app/core/commands"
|
||||
import {clearModals} from "@app/util/modal"
|
||||
|
||||
const {next} = $props()
|
||||
@@ -18,17 +18,13 @@
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
const enableChat = async () => {
|
||||
enableGiftWraps()
|
||||
clearModals()
|
||||
goto(nextUrl)
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await enableChat()
|
||||
shouldUnwrap.set(true)
|
||||
clearModals()
|
||||
goto(nextUrl)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import {type Instance} from "tippy.js"
|
||||
import {hash, formatTimestampAsTime} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
|
||||
import {
|
||||
thunks,
|
||||
mergeThunks,
|
||||
pubkey,
|
||||
deriveProfile,
|
||||
deriveProfileDisplay,
|
||||
sendWrapped,
|
||||
} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -29,19 +36,19 @@
|
||||
|
||||
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
|
||||
|
||||
const thunk = $thunks[event.id]
|
||||
const isOwn = event.pubkey === $pubkey
|
||||
const profile = deriveProfile(event.pubkey)
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
||||
const thunk = mergeThunks($thunks.filter(t => t.event.id === event.id))
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
|
||||
const reply = () => replyTo(event)
|
||||
|
||||
const deleteReaction = (event: TrustedEvent) =>
|
||||
sendWrapped({template: makeDelete({event, protect: false}), pubkeys})
|
||||
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys})
|
||||
|
||||
const createReaction = (template: EventContent) =>
|
||||
sendWrapped({template: makeReaction({event, protect: false, ...template}), pubkeys})
|
||||
sendWrapped({event: makeReaction({event, protect: false, ...template}), recipients: pubkeys})
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
const {event, pubkeys}: Props = $props()
|
||||
|
||||
const onEmoji = (emoji: NativeEmoji) =>
|
||||
sendWrapped({template: makeReaction({event, content: emoji.unicode, protect: false}), pubkeys})
|
||||
sendWrapped({
|
||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||
recipients: pubkeys,
|
||||
})
|
||||
</script>
|
||||
|
||||
<EmojiButton {onEmoji} class="btn join-item btn-xs">
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
|
||||
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
|
||||
history.back()
|
||||
sendWrapped({template: makeReaction({event, content: emoji.unicode, protect: false}), pubkeys})
|
||||
sendWrapped({
|
||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||
recipients: pubkeys,
|
||||
})
|
||||
}).bind(undefined, event, pubkeys)
|
||||
|
||||
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
|
||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
onClick: () => void
|
||||
room?: string
|
||||
}
|
||||
|
||||
const {url, room, onClick}: Props = $props()
|
||||
|
||||
const createGoal = () => pushModal(GoalCreate, {url, room})
|
||||
|
||||
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, room})
|
||||
|
||||
const createThread = () => pushModal(ThreadCreate, {url, room})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
|
||||
<li>
|
||||
<Button onclick={createGoal}>
|
||||
<Icon size={4} icon={StarFallMinimalistic} />
|
||||
Create Funding Goal
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={createCalendarEvent}>
|
||||
<Icon size={4} icon={CalendarMinimalistic} />
|
||||
Create Calendar Event
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={createThread}>
|
||||
<Icon size={4} icon={NotesMinimalistic} />
|
||||
Create Thread
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -18,6 +18,7 @@
|
||||
isAddress,
|
||||
isNewline,
|
||||
} from "@welshman/content"
|
||||
import type {Parsed} from "@welshman/content"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
@@ -39,10 +40,8 @@
|
||||
minLength?: number
|
||||
maxLength?: number
|
||||
showEntire?: boolean
|
||||
hideMediaAtDepth?: number
|
||||
expandMode?: string
|
||||
minimalQuote?: boolean
|
||||
depth?: number
|
||||
trimParent?: boolean
|
||||
url?: string
|
||||
}
|
||||
|
||||
@@ -51,10 +50,8 @@
|
||||
minLength = 500,
|
||||
maxLength = 700,
|
||||
showEntire = $bindable(false),
|
||||
hideMediaAtDepth = 1,
|
||||
expandMode = "block",
|
||||
minimalQuote = false,
|
||||
depth = 0,
|
||||
trimParent = false,
|
||||
url,
|
||||
}: Props = $props()
|
||||
|
||||
@@ -67,13 +64,13 @@
|
||||
const isBlock = (i: number) => {
|
||||
const parsed = fullContent[i]
|
||||
|
||||
if (!parsed || hideMediaAtDepth <= depth) return false
|
||||
if (!parsed) return false
|
||||
|
||||
if (isLink(parsed) && $userSettingsValues.show_media && isStartAndEnd(i)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ((isEvent(parsed) || isAddress(parsed)) && isStartAndEnd(i)) {
|
||||
if (isQuote(parsed) && isStartAndEnd(i)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -95,6 +92,8 @@
|
||||
|
||||
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
|
||||
|
||||
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
|
||||
|
||||
const ignoreWarning = () => {
|
||||
warning = null
|
||||
}
|
||||
@@ -103,15 +102,37 @@
|
||||
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
|
||||
)
|
||||
|
||||
const shortContent = $derived(
|
||||
showEntire
|
||||
? fullContent
|
||||
: truncate(fullContent, {
|
||||
minLength,
|
||||
maxLength,
|
||||
mediaLength: hideMediaAtDepth <= depth ? 20 : 200,
|
||||
}),
|
||||
)
|
||||
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
|
||||
const result: T[] = []
|
||||
|
||||
for (const x of xs) {
|
||||
if (result.length === 0 && f(x)) {
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(x)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const shortContent = $derived.by(() => {
|
||||
let result = fullContent
|
||||
|
||||
if (trimParent && result.length > 0 && isQuote(result[0])) {
|
||||
result = dropWhile(p => isQuote(p) || isNewline(p), result)
|
||||
}
|
||||
|
||||
if (!showEntire) {
|
||||
result = truncate(result, {
|
||||
minLength,
|
||||
maxLength,
|
||||
mediaLength: 200,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const hasEllipsis = $derived(shortContent.some(isEllipsis))
|
||||
const expandInline = $derived(hasEllipsis && expandMode === "inline")
|
||||
@@ -152,15 +173,9 @@
|
||||
{/if}
|
||||
{:else if isProfile(parsed)}
|
||||
<ContentMention value={parsed.value} {url} />
|
||||
{:else if isEvent(parsed) || isAddress(parsed)}
|
||||
{:else if isQuote(parsed)}
|
||||
{#if isBlock(i)}
|
||||
<ContentQuote
|
||||
{depth}
|
||||
{url}
|
||||
{hideMediaAtDepth}
|
||||
value={parsed.value}
|
||||
{event}
|
||||
minimal={minimalQuote} />
|
||||
<ContentQuote {url} value={parsed.value} {event} />
|
||||
{:else}
|
||||
<Link
|
||||
external
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import {fromNostrURI} from "@welshman/util"
|
||||
import {nthEq} from "@welshman/lib"
|
||||
import {
|
||||
parse,
|
||||
truncate,
|
||||
renderAsHtml,
|
||||
isText,
|
||||
isEmoji,
|
||||
isTopic,
|
||||
isCode,
|
||||
isCashu,
|
||||
isInvoice,
|
||||
isLink,
|
||||
isProfile,
|
||||
isEvent,
|
||||
isAddress,
|
||||
isNewline,
|
||||
} from "@welshman/content"
|
||||
import type {Parsed} from "@welshman/content"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ContentToken from "@app/components/ContentToken.svelte"
|
||||
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
||||
import ContentCode from "@app/components/ContentCode.svelte"
|
||||
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
|
||||
import ContentNewline from "@app/components/ContentNewline.svelte"
|
||||
import ContentTopic from "@app/components/ContentTopic.svelte"
|
||||
import ContentMention from "@app/components/ContentMention.svelte"
|
||||
import {entityLink, userSettingsValues} from "@app/core/state"
|
||||
|
||||
interface Props {
|
||||
event: any
|
||||
trimParent?: boolean
|
||||
url?: string
|
||||
}
|
||||
|
||||
const {event, trimParent = false, url}: Props = $props()
|
||||
|
||||
const fullContent = parse(event)
|
||||
|
||||
const isBoundary = (i: number) => {
|
||||
const parsed = fullContent[i]
|
||||
|
||||
if (!parsed || isNewline(parsed)) return true
|
||||
if (isText(parsed)) return Boolean(parsed.value.match(/^\s+$/))
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const isStart = (i: number) => isBoundary(i - 1)
|
||||
|
||||
const isEnd = (i: number) => isBoundary(i + 1)
|
||||
|
||||
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
|
||||
|
||||
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
|
||||
|
||||
const ignoreWarning = () => {
|
||||
warning = null
|
||||
}
|
||||
|
||||
let warning = $state(
|
||||
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
|
||||
)
|
||||
|
||||
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
|
||||
const result: T[] = []
|
||||
|
||||
for (const x of xs) {
|
||||
if (result.length === 0 && f(x)) {
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(x)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const shortContent = $derived.by(() => {
|
||||
let result = fullContent
|
||||
|
||||
if (trimParent && result.length > 0 && isQuote(result[0])) {
|
||||
result = dropWhile(p => isQuote(p) || isNewline(p), result)
|
||||
}
|
||||
|
||||
return truncate(result, {minLength: 200, maxLength: 300, mediaLength: 20})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
{#if warning}
|
||||
<div class="card2 card2-sm bg-alt row-2">
|
||||
<Icon icon={Danger} />
|
||||
<p>
|
||||
This note has been flagged by the author as "{warning}".<br />
|
||||
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden text-ellipsis break-words">
|
||||
{#each shortContent as parsed, i}
|
||||
{#if isNewline(parsed)}
|
||||
<ContentNewline value={parsed.value} />
|
||||
{:else if isTopic(parsed)}
|
||||
<ContentTopic value={parsed.value} />
|
||||
{:else if isEmoji(parsed)}
|
||||
<ContentEmoji value={parsed.value} />
|
||||
{:else if isCode(parsed)}
|
||||
<ContentCode
|
||||
value={parsed.value}
|
||||
isBlock={isStartAndEnd(i) || parsed.value.includes("\n")} />
|
||||
{:else if isCashu(parsed) || isInvoice(parsed)}
|
||||
<ContentToken value={parsed.value} />
|
||||
{:else if isLink(parsed)}
|
||||
<ContentLinkInline value={parsed.value} />
|
||||
{:else if isProfile(parsed)}
|
||||
<ContentMention value={parsed.value} {url} />
|
||||
{:else if isQuote(parsed)}
|
||||
<Link
|
||||
external
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap underline"
|
||||
href={entityLink(parsed.raw)}>
|
||||
{fromNostrURI(parsed.raw).slice(0, 16) + "…"}
|
||||
</Link>
|
||||
{:else}
|
||||
{@html renderAsHtml(parsed)}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6,20 +6,17 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import {deriveEvent, entityLink} from "@app/core/state"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
value: any
|
||||
hideMediaAtDepth: number
|
||||
event: TrustedEvent
|
||||
depth: number
|
||||
url?: string
|
||||
minimal?: boolean
|
||||
}
|
||||
|
||||
const {value, event, depth, hideMediaAtDepth, url, minimal}: Props = $props()
|
||||
const {value, event, url}: Props = $props()
|
||||
|
||||
const {id, identifier, kind, pubkey, relays = []} = value
|
||||
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
|
||||
@@ -43,17 +40,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button class="my-2 block max-w-full text-left" {onclick}>
|
||||
<Button class="my-2 block w-full max-w-full text-left" {onclick}>
|
||||
{#if $quote}
|
||||
{#if minimal && $quote.kind === MESSAGE}
|
||||
{#if $quote.kind === MESSAGE}
|
||||
<div
|
||||
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
|
||||
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
|
||||
<NoteContentMinimal trimParent {url} event={$quote} />
|
||||
</div>
|
||||
{:else}
|
||||
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
|
||||
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
|
||||
<NoteContentMinimal {url} event={$quote} />
|
||||
</NoteCard>
|
||||
{/if}
|
||||
{:else}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
@@ -36,7 +36,7 @@
|
||||
{/if}
|
||||
<span class="text-nowrap">{formatTimestamp(earliest.created_at)}</span>
|
||||
</div>
|
||||
<Content minimalQuote minLength={100} maxLength={400} event={earliest} />
|
||||
<NoteContentMinimal event={earliest} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-13 flex items-center justify-between">
|
||||
@@ -67,7 +67,7 @@
|
||||
{formatTimestamp(latest.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<Content minimalQuote minLength={100} maxLength={400} event={latest} />
|
||||
<NoteContentMinimal event={latest} />
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {Snippet} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import {pubkey, relaysByUrl} from "@welshman/app"
|
||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import {setKey} from "@lib/implicit"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import EventReport from "@app/components/EventReport.svelte"
|
||||
import EventShare from "@app/components/EventShare.svelte"
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import {hasNip29} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import {makeSpaceChatPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -32,7 +36,14 @@
|
||||
|
||||
const showInfo = () => pushModal(EventInfo, {url, event})
|
||||
|
||||
const share = () => pushModal(EventShare, {url, event})
|
||||
const share = async () => {
|
||||
if (hasNip29($relaysByUrl.get(url))) {
|
||||
pushModal(EventShare, {url, event})
|
||||
} else {
|
||||
setKey("share", event)
|
||||
goto(makeSpaceChatPath(url))
|
||||
}
|
||||
}
|
||||
|
||||
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import {preventDefault} from "@lib/html"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import {setKey} from "@lib/implicit"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
@@ -11,7 +12,6 @@
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import {channelsByUrl} from "@app/core/state"
|
||||
import {makeRoomPath} from "@app/util/routes"
|
||||
import {setKey} from "@lib/implicit"
|
||||
|
||||
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
|
||||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeGoalPath} from "@app/util/routes"
|
||||
import {makeGoalPath, makeSpacePath} from "@app/util/routes"
|
||||
|
||||
interface Props {
|
||||
url: any
|
||||
event: any
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
showRoom?: boolean
|
||||
showActivity?: boolean
|
||||
}
|
||||
|
||||
const {url, event, showActivity = false}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const path = makeGoalPath(url, event.id)
|
||||
const room = getTagValue("h", event.tags)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
@@ -26,13 +30,16 @@
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} hideZap noun="Goal" />
|
||||
</div>
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
{#if room && showRoom}
|
||||
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<ChannelName {room} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} hideZap noun="Goal" />
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {canEnforceNip70} from "@app/core/commands"
|
||||
|
||||
const {url} = $props()
|
||||
type Props = {
|
||||
url: string
|
||||
room?: string
|
||||
}
|
||||
|
||||
const {url, room}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -59,6 +64,10 @@
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (room) {
|
||||
tags.push(["h", room])
|
||||
}
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(ZAP_GOAL, {content, tags}),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import GoalActions from "@app/components/GoalActions.svelte"
|
||||
import GoalSummary from "@app/components/GoalSummary.svelte"
|
||||
import ChannelLink from "@app/components/ChannelLink.svelte"
|
||||
import {makeGoalPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
@@ -16,6 +17,7 @@
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const summary = getTagValue("summary", event.tags)
|
||||
const room = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
|
||||
@@ -30,6 +32,9 @@
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||
{#if room}
|
||||
in <ChannelLink {url} {room} />
|
||||
{/if}
|
||||
</span>
|
||||
<GoalActions showActivity {url} {event} />
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
url?: string
|
||||
event: TrustedEvent
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
const {url, event, ...props}: Props = $props()
|
||||
|
||||
const zaps = deriveEventsMapped<Zap>(repository, {
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
|
||||
@@ -27,7 +28,7 @@
|
||||
const daysOld = Math.ceil((now() - event.created_at) / DAY)
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-8 {props.class}">
|
||||
<div class="flex gap-8">
|
||||
<div>
|
||||
<p class="text-xl text-primary">{zapAmount} sats</p>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import {createSearch} from "@welshman/app"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
|
||||
const iconModules = import.meta.glob("@assets/icons/*.svg", {
|
||||
query: "?dataurl",
|
||||
eager: true,
|
||||
})
|
||||
|
||||
const icons = Object.entries(iconModules)
|
||||
.map(([path, module]) => {
|
||||
const name = path.split("/").pop()?.replace(".svg", "") || ""
|
||||
return {
|
||||
name,
|
||||
url: (module as any).default,
|
||||
searchText: name.replace(/[-_]/g, " ").toLowerCase(),
|
||||
}
|
||||
})
|
||||
.filter(icon => icon.name && !icon.name.startsWith("icon-") && icon.name !== "index")
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const iconSearch = createSearch(icons, {
|
||||
getValue: icon => icon.name,
|
||||
fuseOptions: {
|
||||
keys: ["name", "searchText"],
|
||||
threshold: 0.4,
|
||||
},
|
||||
})
|
||||
|
||||
type Props = {
|
||||
onSelect: (iconUrl: string) => void
|
||||
}
|
||||
|
||||
const {onSelect}: Props = $props()
|
||||
|
||||
let searchTerm = $state("")
|
||||
|
||||
const filteredIcons = $derived(searchTerm ? iconSearch.searchOptions(searchTerm) : icons)
|
||||
|
||||
const handleSelect = (iconUrl: string) => {
|
||||
onSelect(iconUrl)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-96 rounded-box bg-base-100 p-4 shadow-lg">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Magnifier} />
|
||||
<input bind:value={searchTerm} class="grow" type="text" placeholder="Search icons..." />
|
||||
</label>
|
||||
<div class="mt-2 max-h-80 overflow-y-auto">
|
||||
<div class="grid grid-cols-8 gap-2 p-2">
|
||||
{#each filteredIcons as icon}
|
||||
<button
|
||||
class="flex aspect-square items-center justify-center rounded-box transition-colors hover:bg-primary hover:text-primary-content"
|
||||
onclick={() => handleSelect(icon.url)}
|
||||
title={icon.name}>
|
||||
<Icon icon={icon.url} class="h-6 w-6" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,7 +17,6 @@
|
||||
import {pushModal, clearModals} from "@app/util/modal"
|
||||
import {PLATFORM_NAME, BURROW_URL} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {loadUserData} from "@app/core/requests"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
|
||||
let signers: any[] = $state([])
|
||||
@@ -27,9 +26,7 @@
|
||||
|
||||
const signUp = () => pushModal(SignUp)
|
||||
|
||||
const onSuccess = async (session: Session, relays: string[] = []) => {
|
||||
await loadUserData(session.pubkey, relays)
|
||||
|
||||
const onSuccess = async (session: Session) => {
|
||||
addSession(session)
|
||||
pushToast({message: "Successfully logged in!"})
|
||||
setChecked("*")
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import BunkerConnect from "@app/components/BunkerConnect.svelte"
|
||||
import BunkerUrl from "@app/components/BunkerUrl.svelte"
|
||||
import {Nip46Controller} from "@app/util/nip46"
|
||||
import {loadUserData} from "@app/core/requests"
|
||||
import {clearModals} from "@app/util/modal"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
@@ -33,9 +32,6 @@
|
||||
const pubkey = await controller.broker.getPublicKey()
|
||||
|
||||
loginWithNip46(pubkey, controller.clientSecret, response.event.pubkey, SIGNER_RELAYS)
|
||||
|
||||
await loadUserData(pubkey)
|
||||
|
||||
setChecked("*")
|
||||
clearModals()
|
||||
},
|
||||
@@ -75,8 +71,6 @@
|
||||
broker.cleanup()
|
||||
controller.stop()
|
||||
|
||||
await loadUserData(pubkey)
|
||||
|
||||
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
|
||||
} else {
|
||||
return pushToast({
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
|
||||
import {loadUserData} from "@app/core/requests"
|
||||
import {clearModals, pushModal} from "@app/util/modal"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
@@ -96,8 +95,6 @@
|
||||
const pubkey = await broker.getPublicKey()
|
||||
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
|
||||
|
||||
await loadUserData(pubkey)
|
||||
|
||||
addSession({...session, email})
|
||||
broker.cleanup()
|
||||
setChecked("*")
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {displayRelayUrl, getTagValue} from "@welshman/util"
|
||||
import {derived} from "svelte/store"
|
||||
import {displayRelayUrl, getTagValue, EVENT_TIME, ZAP_GOAL, THREAD} from "@welshman/util"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import {fly} from "@lib/transition"
|
||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Login from "@assets/icons/login-3.svg?dataurl"
|
||||
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
|
||||
import History from "@assets/icons/history.svg?dataurl"
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
@@ -16,32 +19,38 @@
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
||||
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
||||
import SpaceDetail from "@app/components/SpaceDetail.svelte"
|
||||
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
||||
import SpaceExit from "@app/components/SpaceExit.svelte"
|
||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import ProfileList from "@app/components/ProfileList.svelte"
|
||||
import AlertAdd from "@app/components/AlertAdd.svelte"
|
||||
import Alerts from "@app/components/Alerts.svelte"
|
||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
|
||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||
import {
|
||||
ENABLE_ZAPS,
|
||||
userRoomsByUrl,
|
||||
hasMembershipUrl,
|
||||
memberships,
|
||||
MESSAGE_FILTER,
|
||||
deriveSpaceMembers,
|
||||
deriveEventsForUrl,
|
||||
deriveUserRooms,
|
||||
deriveOtherRooms,
|
||||
userSpaceUrls,
|
||||
hasNip29,
|
||||
alerts,
|
||||
deriveUserCanCreateRoom,
|
||||
} from "@app/core/state"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {makeSpacePath, makeChatPath} from "@app/util/routes"
|
||||
|
||||
const {url} = $props()
|
||||
|
||||
@@ -52,8 +61,14 @@
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const members = deriveSpaceMembers(url)
|
||||
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
|
||||
|
||||
const spaceKinds = derived(
|
||||
deriveEventsForUrl(url, [MESSAGE_FILTER]),
|
||||
$events => new Set($events.map(e => e.kind)),
|
||||
)
|
||||
|
||||
const openMenu = () => {
|
||||
showMenu = true
|
||||
}
|
||||
@@ -62,13 +77,17 @@
|
||||
showMenu = !showMenu
|
||||
}
|
||||
|
||||
const showDetail = () => pushModal(SpaceDetail, {url}, {replaceState})
|
||||
|
||||
const showMembers = () =>
|
||||
pushModal(
|
||||
ProfileList,
|
||||
{url, pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)},
|
||||
{url, pubkeys: $members, title: `Members of`, subtitle: displayRelayUrl(url)},
|
||||
{replaceState},
|
||||
)
|
||||
|
||||
const canCreateRoom = deriveUserCanCreateRoom(url)
|
||||
|
||||
const createInvite = () => pushModal(SpaceInvite, {url}, {replaceState})
|
||||
|
||||
const leaveSpace = () => pushModal(SpaceExit, {url}, {replaceState})
|
||||
@@ -88,10 +107,6 @@
|
||||
let replaceState = $state(false)
|
||||
let element: Element | undefined = $state()
|
||||
|
||||
const members = $derived(
|
||||
$memberships.filter(l => hasMembershipUrl(l, url)).map(l => l.event.pubkey),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
replaceState = Boolean(element?.closest(".drawer"))
|
||||
})
|
||||
@@ -100,23 +115,22 @@
|
||||
<div bind:this={element} class="flex h-full flex-col justify-between">
|
||||
<SecondaryNavSection>
|
||||
<div>
|
||||
<SecondaryNavItem class="w-full !justify-between" onclick={openMenu}>
|
||||
<strong class="ellipsize flex items-center gap-3">
|
||||
{displayRelayUrl(url)}
|
||||
</strong>
|
||||
<Icon icon={AltArrowDown} />
|
||||
</SecondaryNavItem>
|
||||
<Button
|
||||
class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
||||
onclick={openMenu}>
|
||||
<div class="flex items-center justify-between">
|
||||
<strong class="ellipsize flex items-center gap-1">
|
||||
<RelayName {url} />
|
||||
</strong>
|
||||
<Icon icon={AltArrowDown} />
|
||||
</div>
|
||||
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
|
||||
</Button>
|
||||
{#if showMenu}
|
||||
<Popover hideOnClick onClose={toggleMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu absolute z-popover mt-2 w-full gap-1 rounded-box bg-base-100 p-2 shadow-xl">
|
||||
<li>
|
||||
<Button onclick={showMembers}>
|
||||
<Icon icon={UserRounded} />
|
||||
View Members ({members.length})
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={createInvite}>
|
||||
<Icon icon={LinkRound} />
|
||||
@@ -124,7 +138,27 @@
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
{#if $userRoomsByUrl.has(url)}
|
||||
<Button onclick={showDetail}>
|
||||
<Icon icon={RemoteControllerMinimalistic} />
|
||||
Space Information
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={showMembers}>
|
||||
<Icon icon={UserRounded} />
|
||||
View Members ({$members.length})
|
||||
</Button>
|
||||
</li>
|
||||
{#if $relay?.pubkey}
|
||||
<li>
|
||||
<Link href={makeChatPath([$relay.pubkey])}>
|
||||
<Icon icon={Letter} />
|
||||
Contact Owner
|
||||
</Link>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
{#if $userSpaceUrls.includes(url)}
|
||||
<Button onclick={leaveSpace} class="text-error">
|
||||
<Icon icon={Exit} />
|
||||
Leave Space
|
||||
@@ -141,10 +175,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto">
|
||||
<SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
|
||||
<Icon icon={HomeSmile} /> Home
|
||||
</SecondaryNavItem>
|
||||
{#if ENABLE_ZAPS}
|
||||
{#if hasNip29($relay)}
|
||||
<SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}>
|
||||
<Icon icon={History} /> Recent Activity
|
||||
</SecondaryNavItem>
|
||||
{:else}
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={chatPath}
|
||||
notification={$notifications.has(chatPath)}>
|
||||
<Icon icon={ChatRound} /> Chat
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={goalsPath}
|
||||
@@ -152,18 +195,22 @@
|
||||
<Icon icon={StarFallMinimalistic} /> Goals
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={threadsPath}
|
||||
notification={$notifications.has(threadsPath)}>
|
||||
<Icon icon={NotesMinimalistic} /> Threads
|
||||
</SecondaryNavItem>
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={calendarPath}
|
||||
notification={$notifications.has(calendarPath)}>
|
||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||
</SecondaryNavItem>
|
||||
{#if $spaceKinds.has(THREAD)}
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={threadsPath}
|
||||
notification={$notifications.has(threadsPath)}>
|
||||
<Icon icon={NotesMinimalistic} /> Threads
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(EVENT_TIME)}
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={calendarPath}
|
||||
notification={$notifications.has(calendarPath)}>
|
||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if hasNip29($relay)}
|
||||
{#if $userRooms.length > 0}
|
||||
<div class="h-2"></div>
|
||||
@@ -185,24 +232,22 @@
|
||||
{#each $otherRooms as room, i (room)}
|
||||
<MenuSpaceRoomItem {replaceState} {url} {room} />
|
||||
{/each}
|
||||
<SecondaryNavItem {replaceState} onclick={addRoom}>
|
||||
<Icon icon={AddCircle} />
|
||||
Create room
|
||||
</SecondaryNavItem>
|
||||
{:else}
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={chatPath}
|
||||
notification={$notifications.has(chatPath)}>
|
||||
<Icon icon={ChatRound} /> Chat
|
||||
</SecondaryNavItem>
|
||||
{#if $canCreateRoom}
|
||||
<SecondaryNavItem {replaceState} onclick={addRoom}>
|
||||
<Icon icon={AddCircle} />
|
||||
Create room
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</SecondaryNavSection>
|
||||
<div class="p-4">
|
||||
<button class="btn btn-neutral btn-sm w-full" onclick={manageAlerts}>
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<Button class="btn btn-neutral btn-sm" onclick={showDetail}>
|
||||
<SocketStatusIndicator {url} />
|
||||
</Button>
|
||||
<Button class="btn btn-neutral btn-sm" onclick={manageAlerts}>
|
||||
<Icon icon={Bell} />
|
||||
Manage Alerts
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,17 +6,22 @@
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {pushDrawer} from "@app/util/modal"
|
||||
import {deriveSocketStatus} from "@app/core/state"
|
||||
|
||||
const {url} = $props()
|
||||
|
||||
const path = makeSpacePath(url)
|
||||
const path = makeSpacePath(url) + ":mobile"
|
||||
|
||||
const status = deriveSocketStatus(url)
|
||||
|
||||
const openMenu = () => pushDrawer(MenuSpace, {url})
|
||||
</script>
|
||||
|
||||
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden">
|
||||
<Icon icon={MenuDots} />
|
||||
{#if $notifications.has(path)}
|
||||
{#if $status.theme !== "success"}
|
||||
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-{$status.theme}"></div>
|
||||
{:else if $notifications.has(path)}
|
||||
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-primary"></div>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@@ -25,8 +25,15 @@
|
||||
href={path}
|
||||
{replaceState}
|
||||
notification={notify ? $notifications.has(path) : false}>
|
||||
{#if $channel?.closed || $channel?.private}
|
||||
<Icon icon={Lock} size={4} />
|
||||
{#if $channel?.picture}
|
||||
{@const src = $channel.picture}
|
||||
{#if src.match("\.(png|svg)$") || src.match("image/(png|svg)")}
|
||||
<Icon icon={src} />
|
||||
{:else}
|
||||
<img alt="Room icon" {src} class="h-6 w-6 rounded-lg" />
|
||||
{/if}
|
||||
{:else if $channel?.closed || $channel?.private}
|
||||
<Icon icon={Lock} />
|
||||
{:else}
|
||||
<Icon icon={Hashtag} />
|
||||
{/if}
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import CardButton from "@lib/components/CardButton.svelte"
|
||||
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
|
||||
import {userRoomsByUrl, PLATFORM_RELAYS} from "@app/core/state"
|
||||
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
||||
</script>
|
||||
|
||||
<div class="column menu gap-2">
|
||||
{#each PLATFORM_RELAYS as url (url)}
|
||||
<MenuSpacesItem {url} />
|
||||
{:else}
|
||||
{#if $userRoomsByUrl.size > 0}
|
||||
{#each $userRoomsByUrl.keys() as url (url)}
|
||||
{#if $userSpaceUrls.length > 0}
|
||||
{#each $userSpaceUrls as url (url)}
|
||||
<MenuSpacesItem {url} />
|
||||
{/each}
|
||||
<Divider />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {onMount, mount, unmount, createRawSnippet} from "svelte"
|
||||
import Drawer from "@lib/components/Drawer.svelte"
|
||||
import Dialog from "@lib/components/Dialog.svelte"
|
||||
import {modal, clearModals} from "@app/util/modal"
|
||||
@@ -9,21 +10,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
const m = $derived($modal)
|
||||
let element: HTMLElement
|
||||
let instance: any | undefined
|
||||
|
||||
onMount(() => {
|
||||
return modal.subscribe($modal => {
|
||||
if (instance) {
|
||||
unmount(instance, {outro: true})
|
||||
instance = undefined
|
||||
}
|
||||
|
||||
if ($modal) {
|
||||
const {options, component, props} = $modal
|
||||
const wrapper = options.drawer ? Drawer : Dialog
|
||||
|
||||
instance = mount(wrapper as any, {
|
||||
target: element,
|
||||
props: {
|
||||
onClose: clearModals,
|
||||
children: createRawSnippet(() => ({
|
||||
render: () => "<div></div>",
|
||||
setup: (target: Element) => {
|
||||
const child = mount(component, {target, props})
|
||||
|
||||
return () => unmount(child)
|
||||
},
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
|
||||
{#if m?.options?.drawer}
|
||||
<Drawer onClose={clearModals} {...m.options}>
|
||||
{#key m.id}
|
||||
<m.component {...m.props} />
|
||||
{/key}
|
||||
</Drawer>
|
||||
{:else if m}
|
||||
<Dialog onClose={clearModals} {...m.options}>
|
||||
{#key m.id}
|
||||
<m.component {...m.props} />
|
||||
{/key}
|
||||
</Dialog>
|
||||
{/if}
|
||||
<div bind:this={element}></div>
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
enabled = false
|
||||
}
|
||||
})
|
||||
|
||||
let notificationCount = $state($notifications.size)
|
||||
|
||||
const playSound = () => {
|
||||
if (enabled && $userSettingsValues.play_notification_sound) {
|
||||
audioElement.play()
|
||||
audioElement?.play()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {EVENT_TIME} from "@welshman/util"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD} from "@welshman/util"
|
||||
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
||||
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
|
||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
</script>
|
||||
|
||||
{#if props.event.kind === EVENT_TIME}
|
||||
<div class="flex items-start gap-4">
|
||||
<CalendarEventDate event={props.event} />
|
||||
<div class="flex flex-grow flex-col">
|
||||
<CalendarEventHeader event={props.event} />
|
||||
<div class="flex py-2 opacity-50">
|
||||
<div class="h-px flex-grow bg-base-content opacity-25"></div>
|
||||
</div>
|
||||
<Content {...props} />
|
||||
</div>
|
||||
</div>
|
||||
<NoteContentEventTime {...props} />
|
||||
{:else if props.event.kind === THREAD}
|
||||
<NoteContentThread {...props} />
|
||||
{:else if props.event.kind === ZAP_GOAL}
|
||||
<NoteContentGoal {...props} />
|
||||
{:else}
|
||||
<Content {...props} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
|
||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<CalendarEventDate event={props.event} />
|
||||
<div class="flex flex-grow flex-col">
|
||||
<CalendarEventHeader event={props.event} />
|
||||
<div class="flex py-2 opacity-50">
|
||||
<div class="h-px flex-grow bg-base-content opacity-25"></div>
|
||||
</div>
|
||||
<Content {...props} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import GoalSummary from "@app/components/GoalSummary.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
const content = getTagValue("summary", props.event.tags)
|
||||
const fakeEvent = {content, tags: props.event.tags}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-2xl">{props.event.content}</p>
|
||||
<Content {...props} event={fakeEvent} expandMode="inline" minLength={50} maxLength={300} />
|
||||
<GoalSummary url={props.url} event={props.event} />
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD} from "@welshman/util"
|
||||
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
||||
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
||||
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
</script>
|
||||
|
||||
<div class="text-xs">
|
||||
{#if props.event.kind === EVENT_TIME}
|
||||
<NoteContentMinimalEventTime {...props} />
|
||||
{:else if props.event.kind === THREAD}
|
||||
<NoteContentMinimalThread {...props} />
|
||||
{:else if props.event.kind === ZAP_GOAL}
|
||||
<NoteContentMinimalGoal {...props} />
|
||||
{:else}
|
||||
<ContentMinimal {...props} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {
|
||||
fromPairs,
|
||||
formatTimestamp,
|
||||
formatTimestampAsDate,
|
||||
formatTimestampAsTime,
|
||||
} from "@welshman/lib"
|
||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
const meta = $derived(fromPairs(props.event.tags) as Record<string, string>)
|
||||
const start = $derived(parseInt(meta.start))
|
||||
const end = $derived(parseInt(meta.end))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
||||
<p class="text-sm">{meta.title || meta.name}</p>
|
||||
{#if !isNaN(start) && !isNaN(end)}
|
||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||
{@const endDateDisplay = formatTimestampAsDate(end)}
|
||||
{@const isSingleDay = startDateDisplay === endDateDisplay}
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={ClockCircle} size={4} />
|
||||
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
|
||||
{formatTimestampAsTime(start)} — {isSingleDay
|
||||
? formatTimestampAsTime(end)
|
||||
: formatTimestamp(end)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<ContentMinimal {...props} />
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {sum} from "@welshman/lib"
|
||||
import type {Zap, TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
|
||||
import {deriveEventsMapped} from "@welshman/store"
|
||||
import {repository, getValidZap} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
|
||||
const content = getTagValue("summary", props.event.tags)
|
||||
const fakeEvent = {content, tags: props.event.tags}
|
||||
|
||||
const zaps = deriveEventsMapped<Zap>(repository, {
|
||||
filters: [{kinds: [ZAP_RESPONSE], "#e": [props.event.id]}],
|
||||
itemToEvent: item => item.response,
|
||||
eventToItem: (response: TrustedEvent) => getValidZap(response, props.event),
|
||||
})
|
||||
|
||||
const goalAmount = parseInt(getTagValue("amount", props.event.tags) || "0")
|
||||
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm">{props.event.content}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon icon={Bolt} size={4} />
|
||||
{zapAmount}/{goalAmount} sats funded
|
||||
</div>
|
||||
</div>
|
||||
<ContentMinimal {...props} event={fakeEvent} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
|
||||
const title = getTagValue("title", props.event.tags)
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
<span class="text-sm">{title}</span>
|
||||
{/if}
|
||||
{#if props.event.content}
|
||||
<ContentMinimal {...props} />
|
||||
{/if}
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
const title = getTagValue("title", props.event.tags)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<p class="text-xl">{title}</p>
|
||||
<p class="text-sm opacity-75">
|
||||
{formatTimestamp(props.event.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 h-0 text-xs opacity-75">
|
||||
{formatTimestamp(props.event.created_at)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if props.event.content}
|
||||
<Content {...props} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
import {page} from "$app/stores"
|
||||
import {goto} from "$app/navigation"
|
||||
import {splitAt} from "@welshman/lib"
|
||||
import {userProfile} from "@welshman/app"
|
||||
import {userProfile, shouldUnwrap} from "@welshman/app"
|
||||
import Avatar from "@lib/components/Avatar.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
@@ -13,7 +13,7 @@
|
||||
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
|
||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||
import {userRoomsByUrl, canDecrypt, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
|
||||
import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
@@ -31,13 +31,14 @@
|
||||
|
||||
const {children}: Props = $props()
|
||||
|
||||
const showSpacesMenu = () => (spaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
|
||||
const showSpacesMenu = () =>
|
||||
$userSpaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)
|
||||
|
||||
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
|
||||
|
||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||
|
||||
const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
||||
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
|
||||
|
||||
const hasNotification = (url: string) => {
|
||||
const path = makeSpacePath(url)
|
||||
@@ -50,9 +51,8 @@
|
||||
const itemHeight = 56
|
||||
const navPadding = 6 * itemHeight
|
||||
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
|
||||
const spaceUrls = $derived(Array.from($userRoomsByUrl.keys()))
|
||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, spaceUrls))
|
||||
const anySpaceNotifications = $derived(spaceUrls.some(hasNotification))
|
||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
||||
const anySpaceNotifications = $derived($userSpaceUrls.some(hasNotification))
|
||||
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(hasNotification))
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
|
||||
import {encodeRelay} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {lastPageBySpaceUrl} from "@app/util/history"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
|
||||
const {url} = $props()
|
||||
|
||||
const path = makeSpacePath(url)
|
||||
|
||||
const onClick = () => goto(lastPageBySpaceUrl.get(encodeRelay(url)) || path)
|
||||
</script>
|
||||
|
||||
<PrimaryNavItem
|
||||
onclick={onClick}
|
||||
title={displayRelayUrl(url)}
|
||||
href={path}
|
||||
class="tooltip-right"
|
||||
notification={$notifications.has(path)}>
|
||||
<SpaceAvatar {url} />
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {deriveEvents} from "@welshman/store"
|
||||
import {formatTimestampRelative} from "@welshman/lib"
|
||||
import {NOTE, ROOMS, MESSAGE, THREAD, COMMENT, getRelayTags, getListTags} from "@welshman/util"
|
||||
import {NOTE, ROOMS, COMMENT} from "@welshman/util"
|
||||
import {repository, loadRelaySelections} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
|
||||
import {membershipsByPubkey} from "@app/core/state"
|
||||
import {
|
||||
deriveGroupSelections,
|
||||
getSpaceUrlsFromGroupSelections,
|
||||
MESSAGE_KINDS,
|
||||
} from "@app/core/state"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
@@ -21,8 +25,8 @@
|
||||
const {pubkey, url}: Props = $props()
|
||||
const filters: Filter[] = [{authors: [pubkey], limit: 1}]
|
||||
const events = deriveEvents(repository, {filters})
|
||||
const membership = $derived($membershipsByPubkey.get(pubkey))
|
||||
const relays = $derived(getRelayTags(getListTags(membership)))
|
||||
const selections = deriveGroupSelections(pubkey)
|
||||
const spaceUrls = $derived(getSpaceUrlsFromGroupSelections($selections))
|
||||
|
||||
const viewEvent = () => goToEvent($events[0]!)
|
||||
|
||||
@@ -36,7 +40,7 @@
|
||||
load({
|
||||
filters: [
|
||||
{authors: [pubkey], kinds: [ROOMS]},
|
||||
{authors: [pubkey], limit: 1, kinds: [NOTE, MESSAGE, THREAD, COMMENT]},
|
||||
{authors: [pubkey], limit: 1, kinds: [NOTE, COMMENT, ...MESSAGE_KINDS]},
|
||||
],
|
||||
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||
})
|
||||
@@ -49,10 +53,10 @@
|
||||
Last active {formatTimestampRelative($events[0].created_at)}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if relays.length > 0}
|
||||
{#if spaceUrls.length > 0}
|
||||
<Button onclick={openSpaces} class="badge badge-neutral">
|
||||
{relays.length}
|
||||
{relays.length === 1 ? "space" : "spaces"}
|
||||
{spaceUrls.length}
|
||||
{spaceUrls.length === 1 ? "space" : "spaces"}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -19,13 +19,7 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {logout} from "@app/core/commands"
|
||||
import {
|
||||
INDEXER_RELAYS,
|
||||
PLATFORM_NAME,
|
||||
userMembership,
|
||||
getMembershipUrls,
|
||||
userWriteRelays,
|
||||
} from "@app/core/state"
|
||||
import {INDEXER_RELAYS, PLATFORM_NAME, userSpaceUrls, userWriteRelays} from "@app/core/state"
|
||||
|
||||
let progress: number | undefined = $state(undefined)
|
||||
let confirmText = $state("")
|
||||
@@ -46,11 +40,7 @@
|
||||
const profileEvent = makeEvent(PROFILE, createProfile({name: "[deleted]"}))
|
||||
const vanishEvent = makeEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
|
||||
const denominator = chunks.length + 2
|
||||
const relays = uniq([
|
||||
...INDEXER_RELAYS,
|
||||
...$userWriteRelays,
|
||||
...getMembershipUrls($userMembership),
|
||||
])
|
||||
const relays = uniq([...INDEXER_RELAYS, ...$userWriteRelays, ...$userSpaceUrls])
|
||||
|
||||
let step = 0
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {shouldUnwrap} from "@welshman/app"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
@@ -11,7 +12,7 @@
|
||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||
import ChatEnable from "@app/components/ChatEnable.svelte"
|
||||
import {canDecrypt, pubkeyLink} from "@app/core/state"
|
||||
import {pubkeyLink} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {makeChatPath} from "@app/util/routes"
|
||||
|
||||
@@ -26,7 +27,7 @@
|
||||
|
||||
const chatPath = makeChatPath([pubkey])
|
||||
|
||||
const openChat = () => ($canDecrypt ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
|
||||
const openChat = () => ($shouldUnwrap ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {removeNil} from "@welshman/lib"
|
||||
import {deriveProfile} from "@welshman/app"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
export type Props = {
|
||||
pubkey: string
|
||||
@@ -14,5 +14,5 @@
|
||||
</script>
|
||||
|
||||
{#if $profile}
|
||||
<Content event={{content: $profile.about || "", tags: []}} hideMediaAtDepth={0} />
|
||||
<ContentMinimal event={{content: $profile.about || "", tags: []}} />
|
||||
{/if}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||
</script>
|
||||
|
||||
<Button onclick={preventDefault(openProfile)} class={cx(props.class, {"link-content": !unstyled})}>
|
||||
<Button
|
||||
onclick={preventDefault(openProfile)}
|
||||
class={cx(props.class, {"link-content bg-alt": !unstyled})}>
|
||||
@<ProfileName {pubkey} {url} />
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {getMembershipUrls, membershipsByPubkey} from "@app/core/state"
|
||||
import {deriveGroupSelections, getSpaceUrlsFromGroupSelections} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
const {pubkey}: Props = $props()
|
||||
|
||||
const spaceUrls = $derived(getMembershipUrls($membershipsByPubkey.get(pubkey)))
|
||||
const selections = deriveGroupSelections(pubkey)
|
||||
const spaceUrls = $derived(getSpaceUrlsFromGroupSelections($selections))
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {Snippet} from "svelte"
|
||||
import {groupBy, sum, uniq, uniqBy, batch, displayList} from "@welshman/lib"
|
||||
import {
|
||||
REPORT,
|
||||
REACTION,
|
||||
ZAP_RESPONSE,
|
||||
getReplyFilters,
|
||||
@@ -10,7 +11,6 @@
|
||||
getEmojiTag,
|
||||
fromMsats,
|
||||
getTag,
|
||||
REPORT,
|
||||
DELETE,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, EventContent, Zap} from "@welshman/util"
|
||||
@@ -96,7 +96,7 @@
|
||||
load({
|
||||
relays: [url],
|
||||
signal: controller.signal,
|
||||
filters: getReplyFilters([event], {kinds: [REPORT, DELETE, ...REACTION_KINDS]}),
|
||||
filters: getReplyFilters([event], {kinds: REACTION_KINDS}),
|
||||
onEvent: batch(300, (events: TrustedEvent[]) => {
|
||||
load({
|
||||
relays: [url],
|
||||
@@ -118,7 +118,7 @@
|
||||
<button
|
||||
type="button"
|
||||
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
|
||||
class="btn btn-error btn-xs tooltip-right flex items-center gap-1 rounded-full"
|
||||
class="btn btn-error btn-xs tooltip-right flex items-center gap-1 rounded-full font-normal"
|
||||
class:tooltip={!noTooltip && !isMobile}
|
||||
onclick={stopPropagation(preventDefault(onReportClick))}>
|
||||
<Icon icon={Danger} />
|
||||
@@ -134,11 +134,10 @@
|
||||
<button
|
||||
type="button"
|
||||
data-tip={tooltip}
|
||||
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full {reactionClass}"
|
||||
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full text-xs font-normal {reactionClass}"
|
||||
class:tooltip={!noTooltip && !isMobile}
|
||||
class:border={isOwn}
|
||||
class:border-solid={isOwn}
|
||||
class:border-primary={isOwn}>
|
||||
class:btn-neutral={!isOwn}
|
||||
class:btn-primary={isOwn}>
|
||||
<Reaction event={zaps[0].request} />
|
||||
<span>{amount}</span>
|
||||
</button>
|
||||
@@ -152,11 +151,10 @@
|
||||
<button
|
||||
type="button"
|
||||
data-tip={tooltip}
|
||||
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full {reactionClass}"
|
||||
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full font-normal {reactionClass}"
|
||||
class:tooltip={!noTooltip && !isMobile}
|
||||
class:border={isOwn}
|
||||
class:border-solid={isOwn}
|
||||
class:border-primary={isOwn}
|
||||
class:btn-neutral={!isOwn}
|
||||
class:btn-primary={isOwn}
|
||||
onclick={stopPropagation(preventDefault(onClick))}>
|
||||
<Reaction event={events[0]} />
|
||||
{#if events.length > 1}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
const relay = deriveRelay(props.url)
|
||||
</script>
|
||||
|
||||
{#if $relay?.profile?.description}
|
||||
<p class={props.class}>{$relay?.profile.description}</p>
|
||||
{#if $relay?.description}
|
||||
<p class={props.class}>{$relay.description}</p>
|
||||
{/if}
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import {displayUrl} from "@welshman/lib"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import {deriveRelay, deriveRelayStats} from "@welshman/app"
|
||||
|
||||
const {url, children} = $props()
|
||||
|
||||
const relay = deriveRelay(url)
|
||||
|
||||
const connections = $derived($relay?.stats?.open_count || 0)
|
||||
const relayStats = deriveRelayStats(url)
|
||||
const connections = $derived($relayStats?.open_count || 0)
|
||||
</script>
|
||||
|
||||
<div class="card2 card2-sm bg-alt column gap-2">
|
||||
@@ -21,20 +21,20 @@
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{#if $relay?.profile?.description}
|
||||
<p class="ellipsize">{$relay?.profile.description}</p>
|
||||
{#if $relay?.description}
|
||||
<p class="ellipsize">{$relay.description}</p>
|
||||
{/if}
|
||||
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
|
||||
{#if $relay?.profile?.contact}
|
||||
<Link external class="ellipsize underline" href={$relay.profile.contact}
|
||||
>{displayUrl($relay.profile.contact)}</Link>
|
||||
{#if $relay?.contact}
|
||||
<Link external class="ellipsize underline" href={$relay.contact}
|
||||
>{displayUrl($relay.contact)}</Link>
|
||||
•
|
||||
{/if}
|
||||
{#if Array.isArray($relay?.profile?.supported_nips)}
|
||||
{#if Array.isArray($relay?.supported_nips)}
|
||||
<span
|
||||
class="tooltip cursor-pointer underline"
|
||||
data-tip="NIPs supported: {$relay.profile.supported_nips.join(', ')}">
|
||||
{$relay.profile.supported_nips.length} NIPs
|
||||
data-tip="NIPs supported: {$relay.supported_nips.join(', ')}">
|
||||
{$relay.supported_nips.length} NIPs
|
||||
</span>
|
||||
•
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import {gt} from "@welshman/lib"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import Ghost from "@assets/icons/ghost-smile.svg?dataurl"
|
||||
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
|
||||
@@ -7,7 +6,7 @@
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import RelayDescription from "@app/components/RelayDescription.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {membersByUrl, userRoomsByUrl} from "@app/core/state"
|
||||
import {deriveSpaceMembers, deriveUserRooms} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -15,6 +14,8 @@
|
||||
|
||||
const {url}: Props = $props()
|
||||
const relay = deriveRelay(url)
|
||||
const rooms = deriveUserRooms(url)
|
||||
const members = deriveSpaceMembers(url)
|
||||
</script>
|
||||
|
||||
<div class="col-4 text-left">
|
||||
@@ -24,14 +25,14 @@
|
||||
<div class="avatar relative">
|
||||
<div
|
||||
class="center !flex h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
||||
{#if $relay?.profile?.icon}
|
||||
<img alt="" src={$relay.profile.icon} />
|
||||
{#if $relay?.icon}
|
||||
<img alt="" src={$relay.icon} />
|
||||
{:else}
|
||||
<Icon icon={Ghost} size={5} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if $userRoomsByUrl.has(url)}
|
||||
{#if $rooms.includes(url)}
|
||||
<div
|
||||
class="tooltip absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary"
|
||||
data-tip="You are already a member of this space.">
|
||||
@@ -48,10 +49,10 @@
|
||||
</div>
|
||||
<RelayDescription {url} />
|
||||
</div>
|
||||
{#if gt($membersByUrl.get(url)?.size, 0)}
|
||||
{#if $members.length > 0}
|
||||
<div class="row-2 card2 card2-sm bg-alt">
|
||||
Members:
|
||||
<ProfileCircles pubkeys={Array.from($membersByUrl.get(url) || [])} />
|
||||
<ProfileCircles pubkeys={$members} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,20 +3,24 @@
|
||||
import {uniqBy, nth} from "@welshman/lib"
|
||||
import {displayRelayUrl, makeRoomMeta} from "@welshman/util"
|
||||
import {deriveRelay, waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
|
||||
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
||||
import {preventDefault, compressFile} from "@lib/html"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import IconPickerButton from "@lib/components/IconPickerButton.svelte"
|
||||
import {hasNip29, loadChannel} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {uploadFile} from "@app/core/commands"
|
||||
|
||||
const {url} = $props()
|
||||
|
||||
@@ -28,6 +32,18 @@
|
||||
const tryCreate = async () => {
|
||||
room.tags = uniqBy(nth(0), [...room.tags, ["name", name]])
|
||||
|
||||
if (imageFile) {
|
||||
const {error, result} = await uploadFile(imageFile)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
room.tags.push(["picture", result.url, ...result.tags])
|
||||
} else if (selectedIcon) {
|
||||
room.tags.push(["picture", selectedIcon])
|
||||
}
|
||||
|
||||
const createMessage = await waitForThunkError(createRoom(url, room))
|
||||
|
||||
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
|
||||
@@ -63,6 +79,32 @@
|
||||
|
||||
let name = $state("")
|
||||
let loading = $state(false)
|
||||
let imageFile = $state<File | undefined>()
|
||||
let imagePreview = $state<string | undefined>()
|
||||
let selectedIcon = $state<string | undefined>()
|
||||
|
||||
const handleImageUpload = async (event: Event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
selectedIcon = undefined
|
||||
imageFile = await compressFile(file, {maxWidth: 64, maxHeight: 64})
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = e => {
|
||||
imagePreview = e.target?.result as string
|
||||
}
|
||||
|
||||
reader.readAsDataURL(imageFile)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIconSelect = (iconUrl: string) => {
|
||||
imageFile = undefined
|
||||
imagePreview = undefined
|
||||
selectedIcon = iconUrl
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="column gap-4" onsubmit={preventDefault(create)}>
|
||||
@@ -77,7 +119,7 @@
|
||||
{/snippet}
|
||||
</ModalHeader>
|
||||
{#if hasNip29($relay)}
|
||||
<Field>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Room Name</p>
|
||||
{/snippet}
|
||||
@@ -87,7 +129,39 @@
|
||||
<input bind:value={name} class="grow" type="text" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
</FieldInline>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-bold">Room Icon</p>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if imagePreview}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm opacity-75">Selected:</span>
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Room icon preview"
|
||||
class="h-8 w-8 rounded-lg object-cover" />
|
||||
</div>
|
||||
{:else if selectedIcon}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm opacity-75">Selected:</span>
|
||||
<Icon icon={selectedIcon} class="h-8 w-8" />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-sm opacity-75">No icon selected</span>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<IconPickerButton onSelect={handleIconSelect} class="btn btn-primary btn-sm">
|
||||
<Icon icon={StickerSmileSquare} size={4} />
|
||||
Select
|
||||
</IconPickerButton>
|
||||
<label class="btn btn-neutral btn-sm cursor-pointer">
|
||||
<Icon icon={UploadMinimalistic} size={4} />
|
||||
Upload
|
||||
<input type="file" accept="image/*" class="hidden" onchange={handleImageUpload} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="bg-alt card2 row-2">
|
||||
<Icon icon={Danger} />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import StatusIndicator from "@lib/components/StatusIndicator.svelte"
|
||||
import {deriveSocketStatus} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const status = deriveSocketStatus(url)
|
||||
</script>
|
||||
|
||||
<StatusIndicator class="bg-{$status.theme}">{$status.title}</StatusIndicator>
|
||||
@@ -17,4 +17,4 @@
|
||||
icon={RemoteControllerMinimalistic}
|
||||
class="!h-10 !w-10"
|
||||
alt={displayRelayUrl(url)}
|
||||
src={$relay?.profile?.icon} />
|
||||
src={$relay?.icon} />
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
|
||||
import BillList from "@assets/icons/bill-list.svg?dataurl"
|
||||
import Ghost from "@assets/icons/ghost-smile.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
|
||||
import RelayDescription from "@app/components/RelayDescription.svelte"
|
||||
import ProfileLatest from "@app/components/ProfileLatest.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const relay = deriveRelay(url)
|
||||
const owner = $derived($relay?.pubkey)
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<div class="column gap-4">
|
||||
<div class="relative flex gap-4">
|
||||
<div class="relative">
|
||||
<div class="avatar relative">
|
||||
<div
|
||||
class="center !flex h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
||||
{#if $relay?.icon}
|
||||
<img alt="" src={$relay.icon} />
|
||||
{:else}
|
||||
<Icon icon={Ghost} size={6} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">
|
||||
<RelayName {url} />
|
||||
</h1>
|
||||
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<RelayDescription {url} />
|
||||
{#if $relay?.terms_of_service || $relay?.privacy_policy}
|
||||
<div class="flex gap-3">
|
||||
{#if $relay.terms_of_service}
|
||||
<Link href={$relay.terms_of_service} class="badge badge-neutral flex gap-2">
|
||||
<Icon icon={BillList} size={4} />
|
||||
Terms of Service
|
||||
</Link>
|
||||
{/if}
|
||||
{#if $relay.privacy_policy}
|
||||
<Link href={$relay.privacy_policy} class="badge badge-neutral flex gap-2">
|
||||
<Icon icon={ShieldUser} size={4} />
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<SpaceRelayStatus {url} />
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if owner}
|
||||
<div class="card2 bg-alt">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||
<Icon icon={UserRounded} />
|
||||
Latest Updates
|
||||
</h3>
|
||||
<ProfileLatest {url} pubkey={owner}>
|
||||
{#snippet fallback()}
|
||||
<p class="text-sm opacity-60">No recent posts from the relay admin</p>
|
||||
{/snippet}
|
||||
</ProfileLatest>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Button class="btn btn-primary" onclick={back}>Got it</Button>
|
||||
</div>
|
||||
@@ -8,7 +8,7 @@
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {removeSpaceMembership, removeTrustedRelay} from "@app/core/commands"
|
||||
import {removeSpaceMembership, publishLeaveRequest, removeTrustedRelay} from "@app/core/commands"
|
||||
|
||||
const {url} = $props()
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
try {
|
||||
await removeSpaceMembership(url)
|
||||
await publishLeaveRequest({url})
|
||||
await removeTrustedRelay(url)
|
||||
} finally {
|
||||
loading = false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {onMount} from "svelte"
|
||||
import {sleep, nthEq} from "@welshman/lib"
|
||||
import {request} from "@welshman/net"
|
||||
import {displayRelayUrl, AUTH_INVITE} from "@welshman/util"
|
||||
import {displayRelayUrl, RELAY_INVITE} from "@welshman/util"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
@@ -38,7 +38,7 @@
|
||||
request({
|
||||
relays: [url],
|
||||
autoClose: true,
|
||||
filters: [{kinds: [AUTH_INVITE]}],
|
||||
filters: [{kinds: [RELAY_INVITE]}],
|
||||
}),
|
||||
sleep(2000),
|
||||
])
|
||||
@@ -83,7 +83,7 @@
|
||||
This invite link can be used by clicking "Add Space" and pasting it there.
|
||||
{#if !claim}
|
||||
This space did not issue a claim for this link, so additional steps might be
|
||||
required for people using this invite link.
|
||||
required.
|
||||
{/if}
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script module lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {dissoc} from "@welshman/lib"
|
||||
import {ROOM_META} from "@welshman/util"
|
||||
import {load} from "@welshman/net"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {addSpaceMembership, broadcastUserData} from "@app/core/commands"
|
||||
@@ -11,17 +9,8 @@
|
||||
export const confirmSpaceJoin = async (url: string) => {
|
||||
await addSpaceMembership(url)
|
||||
|
||||
const path = makeSpacePath(url)
|
||||
|
||||
if (window.location.pathname === path) {
|
||||
load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [ROOM_META]}],
|
||||
})
|
||||
}
|
||||
|
||||
broadcastUserData([url])
|
||||
goto(path, {replaceState: true})
|
||||
goto(makeSpacePath(url), {replaceState: true})
|
||||
relaysMostlyRestricted.update(dissoc(url))
|
||||
pushToast({message: "Welcome to the space!"})
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import {fade} from "@lib/transition"
|
||||
import CompassBig from "@assets/icons/compass-big.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import Lock from "@assets/icons/lock-keyhole.svg?dataurl"
|
||||
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
|
||||
import {
|
||||
hasNip29,
|
||||
deriveUserRooms,
|
||||
deriveOtherRooms,
|
||||
makeChannelId,
|
||||
channelsById,
|
||||
} from "@app/core/state"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const relay = deriveRelay(url)
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const chatPath = makeSpacePath(url, "chat")
|
||||
const goalsPath = makeSpacePath(url, "goals")
|
||||
const threadsPath = makeSpacePath(url, "threads")
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
|
||||
const addRoom = () => pushModal(RoomCreate, {url})
|
||||
|
||||
const filteredRooms = $derived(() => {
|
||||
if (!term) return [...$userRooms, ...$otherRooms]
|
||||
|
||||
const query = term.toLowerCase()
|
||||
const allRooms = [...$userRooms, ...$otherRooms]
|
||||
|
||||
return allRooms.filter(room => {
|
||||
const channel = $channelsById.get(makeChannelId(url, room))
|
||||
const roomName = channel?.name || room
|
||||
return roomName.toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
let term = $state("")
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt md:hidden">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||
<Icon icon={CompassBig} />
|
||||
Quick Links
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Link href={goalsPath} class="btn btn-neutral w-full justify-start">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Icon icon={StarFallMinimalistic} />
|
||||
Goals
|
||||
{#if $notifications.has(goalsPath)}
|
||||
<div
|
||||
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-neutral-content"
|
||||
transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
<Link href={threadsPath} class="btn btn-neutral w-full justify-start">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Icon icon={NotesMinimalistic} />
|
||||
Threads
|
||||
{#if $notifications.has(threadsPath)}
|
||||
<div
|
||||
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-neutral-content"
|
||||
transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
<Link href={calendarPath} class="btn btn-neutral w-full justify-start">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Icon icon={CalendarMinimalistic} />
|
||||
Calendar
|
||||
{#if $notifications.has(calendarPath)}
|
||||
<div
|
||||
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-neutral-content"
|
||||
transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
{#if hasNip29($relay)}
|
||||
{#if $userRooms.length + $otherRooms.length > 10}
|
||||
<label class="input input-sm input-bordered flex flex-grow items-center gap-2">
|
||||
<Icon icon={Magnifier} size={4} />
|
||||
<input bind:value={term} class="grow" type="text" placeholder="Search rooms..." />
|
||||
</label>
|
||||
{/if}
|
||||
{#each filteredRooms() as room (room)}
|
||||
{@const roomPath = makeRoomPath(url, room)}
|
||||
{@const channel = $channelsById.get(makeChannelId(url, room))}
|
||||
<Link href={roomPath} class="btn btn-neutral btn-sm relative w-full justify-start">
|
||||
<div class="flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
|
||||
{#if channel?.closed || channel?.private}
|
||||
<Icon icon={Lock} size={4} />
|
||||
{:else}
|
||||
<Icon icon={Hashtag} />
|
||||
{/if}
|
||||
<ChannelName {url} {room} />
|
||||
</div>
|
||||
{#if $notifications.has(roomPath)}
|
||||
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary" transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</Link>
|
||||
{/each}
|
||||
<Button onclick={addRoom} class="btn btn-neutral btn-sm w-full justify-start">
|
||||
<Icon icon={AddCircle} />
|
||||
Create Room
|
||||
</Button>
|
||||
{:else}
|
||||
<Link href={chatPath} class="btn btn-neutral w-full justify-start">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<Icon icon={ChatRound} />
|
||||
Chat
|
||||
{#if $notifications.has(chatPath)}
|
||||
<div class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary" transition:fade>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,106 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {derived} from "svelte/store"
|
||||
import {groupBy, ago, MONTH, first, last, uniq, avg, overlappingPairs} from "@welshman/lib"
|
||||
import {MESSAGE, getTagValue} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ConversationCard from "@app/components/ConversationCard.svelte"
|
||||
import {deriveEventsForUrl} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
}
|
||||
|
||||
const {url}: Props = $props()
|
||||
const since = ago(MONTH)
|
||||
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
|
||||
|
||||
const conversations = derived(messages, $messages => {
|
||||
const convs = []
|
||||
|
||||
for (const [room, messages] of groupBy(e => getTagValue("h", e.tags), $messages).entries()) {
|
||||
const avgTime = avg(overlappingPairs(messages).map(([a, b]) => a.created_at - b.created_at))
|
||||
const groups: TrustedEvent[][] = []
|
||||
const group: TrustedEvent[] = []
|
||||
|
||||
// Group conversations by time between messages
|
||||
let prevCreatedAt = messages[0].created_at
|
||||
for (const message of messages) {
|
||||
if (prevCreatedAt - message.created_at < avgTime) {
|
||||
group.push(message)
|
||||
} else {
|
||||
groups.push(group.splice(0))
|
||||
}
|
||||
|
||||
prevCreatedAt = message.created_at
|
||||
}
|
||||
|
||||
if (group.length > 0) {
|
||||
groups.push(group.splice(0))
|
||||
}
|
||||
|
||||
// Convert each group into a conversation
|
||||
for (const events of groups) {
|
||||
if (events.length < 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
const latest = first(events)!
|
||||
const earliest = last(events)!
|
||||
const participants = uniq(events.map(msg => msg.pubkey))
|
||||
|
||||
convs.push({room, events, latest, earliest, participants})
|
||||
}
|
||||
}
|
||||
|
||||
return convs
|
||||
})
|
||||
|
||||
const viewMore = () => {
|
||||
limit += 3
|
||||
}
|
||||
|
||||
let limit = $state(3)
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<Icon icon={ChatRound} />
|
||||
Recent Conversations
|
||||
</h3>
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if $conversations.length === 0}
|
||||
{#if $messages.length > 0}
|
||||
{@const events = $messages.slice(0, 1)}
|
||||
{@const event = events[0]}
|
||||
{@const room = getTagValue("h", event.tags)}
|
||||
<ConversationCard
|
||||
{url}
|
||||
{room}
|
||||
{events}
|
||||
latest={event}
|
||||
earliest={event}
|
||||
participants={[event.pubkey]} />
|
||||
{:else}
|
||||
<div class="py-8 text-center opacity-70">
|
||||
<p>No recent conversations</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each $conversations.slice(0, limit) as { room, events, latest, earliest, participants } (latest.id)}
|
||||
<ConversationCard {url} {room} {events} {latest} {earliest} {participants} />
|
||||
{/each}
|
||||
{#if $conversations.length > limit}
|
||||
<Button class="btn btn-primary" onclick={viewMore}>
|
||||
View more conversations
|
||||
<Icon icon={AltArrowDown} />
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,8 +2,8 @@
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import Server from "@assets/icons/server.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import SocketStatusIndicator from "@lib/components/SocketStatusIndicator.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
@@ -12,7 +12,6 @@
|
||||
const {url}: Props = $props()
|
||||
|
||||
const relay = deriveRelay(url)
|
||||
const owner = $derived($relay?.profile?.pubkey)
|
||||
</script>
|
||||
|
||||
<div class="card2 bg-alt flex flex-col gap-4">
|
||||
@@ -23,17 +22,17 @@
|
||||
</h3>
|
||||
<SocketStatusIndicator {url} />
|
||||
</div>
|
||||
{#if $relay?.profile}
|
||||
{@const {software, version, supported_nips, limitation} = $relay.profile}
|
||||
{#if $relay}
|
||||
{@const {pubkey, software, version, supported_nips, limitation} = $relay}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if owner}
|
||||
{#if pubkey}
|
||||
<div class="badge badge-neutral">
|
||||
<span class="ellipsize">Administrator: <ProfileLink unstyled pubkey={owner} /></span>
|
||||
<span class="ellipsize">Administrator: <ProfileLink unstyled {pubkey} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $relay?.profile?.contact}
|
||||
{#if $relay?.contact}
|
||||
<div class="badge badge-neutral">
|
||||
<span class="ellipsize">Contact: {$relay.profile.contact}</span>
|
||||
<span class="ellipsize">Contact: {$relay.contact}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if software}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ChannelName from "@app/components/ChannelName.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeThreadPath} from "@app/util/routes"
|
||||
import {makeThreadPath, makeSpacePath} from "@app/util/routes"
|
||||
|
||||
interface Props {
|
||||
url: any
|
||||
event: any
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
showRoom?: boolean
|
||||
showActivity?: boolean
|
||||
}
|
||||
|
||||
const {url, event, showActivity = false}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const path = makeThreadPath(url, event.id)
|
||||
const room = getTagValue("h", event.tags)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
@@ -26,13 +30,16 @@
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Thread" />
|
||||
</div>
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
{#if room && showRoom}
|
||||
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<ChannelName {room} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Thread" />
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {canEnforceNip70} from "@app/core/commands"
|
||||
|
||||
const {url} = $props()
|
||||
type Props = {
|
||||
url: string
|
||||
room?: string
|
||||
}
|
||||
|
||||
const {url, room}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -52,6 +57,10 @@
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (room) {
|
||||
tags.push(["h", room])
|
||||
}
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(THREAD, {content, tags}),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import {nthEq, formatTimestamp} from "@welshman/lib"
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ThreadActions from "@app/components/ThreadActions.svelte"
|
||||
import ChannelLink from "@app/components/ChannelLink.svelte"
|
||||
import {makeThreadPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
@@ -14,7 +16,8 @@
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const title = event.tags.find(nthEq(0, "title"))?.[1]
|
||||
const title = getTagValue("title", event.tags)
|
||||
const room = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeThreadPath(url, event.id)}>
|
||||
@@ -33,7 +36,11 @@
|
||||
<Content {event} {url} expandMode="inline" />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
||||
Posted by
|
||||
<ProfileLink pubkey={event.pubkey} {url} />
|
||||
{#if room}
|
||||
in <ChannelLink {url} {room} />
|
||||
{/if}
|
||||
</span>
|
||||
<ThreadActions showActivity {url} {event} />
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
<script lang="ts">
|
||||
import {stopPropagation} from "svelte/legacy"
|
||||
import {noop} from "@welshman/lib"
|
||||
import {
|
||||
MergedThunk,
|
||||
publishThunk,
|
||||
isMergedThunk,
|
||||
thunkIsComplete,
|
||||
getFailedThunkUrls,
|
||||
} from "@welshman/app"
|
||||
import type {Thunk} from "@welshman/app"
|
||||
import type {AbstractThunk} from "@welshman/app"
|
||||
import {retryThunk, thunkIsComplete, getFailedThunkUrls} from "@welshman/app"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
@@ -17,7 +11,7 @@
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
interface Props {
|
||||
thunk: Thunk | MergedThunk
|
||||
thunk: AbstractThunk
|
||||
showToastOnRetry?: boolean
|
||||
class?: string
|
||||
}
|
||||
@@ -25,9 +19,7 @@
|
||||
let {thunk, showToastOnRetry, ...restProps}: Props = $props()
|
||||
|
||||
const retry = () => {
|
||||
thunk = isMergedThunk(thunk)
|
||||
? new MergedThunk(thunk.thunks.map(t => publishThunk(t.options)))
|
||||
: publishThunk(thunk.options)
|
||||
thunk = retryThunk(thunk)
|
||||
|
||||
if (showToastOnRetry) {
|
||||
pushToast({
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import {MergedThunk, thunkIsComplete, getFailedThunkUrls} from "@welshman/app"
|
||||
import type {Thunk} from "@welshman/app"
|
||||
import type {AbstractThunk} from "@welshman/app"
|
||||
import {thunkIsComplete, getFailedThunkUrls} from "@welshman/app"
|
||||
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
||||
import ThunkPending from "@app/components/ThunkPending.svelte"
|
||||
|
||||
interface Props {
|
||||
thunk: Thunk | MergedThunk
|
||||
thunk: AbstractThunk
|
||||
class?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {deriveIsDeleted} from "@welshman/store"
|
||||
import {thunks, repository} from "@welshman/app"
|
||||
import {thunks, mergeThunks, repository} from "@welshman/app"
|
||||
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
||||
|
||||
const {event}: {event: TrustedEvent} = $props()
|
||||
|
||||
const thunk = $derived($thunks[event.id])
|
||||
const deleted = deriveIsDeleted(repository, event)
|
||||
const thunk = $derived(mergeThunks($thunks.filter(t => t.event.id === event.id)))
|
||||
</script>
|
||||
|
||||
{#if $deleted}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {session, deriveZapperForPubkey} from "@welshman/app"
|
||||
import {session, loadZapperForPubkey} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Zap from "@app/components/Zap.svelte"
|
||||
import InfoZapperError from "@app/components/InfoZapperError.svelte"
|
||||
@@ -9,7 +9,7 @@
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
url?: string
|
||||
event: TrustedEvent
|
||||
children: Snippet
|
||||
replaceState?: boolean
|
||||
@@ -18,19 +18,29 @@
|
||||
|
||||
const {url, event, children, replaceState, ...props}: Props = $props()
|
||||
|
||||
const zapper = deriveZapperForPubkey(event.pubkey)
|
||||
const zapperPromise = loadZapperForPubkey(event.pubkey)
|
||||
|
||||
const onClick = () => {
|
||||
if (!$zapper?.allowsNostr) {
|
||||
pushModal(InfoZapperError, {url, pubkey: event.pubkey, eventId: event.id}, {replaceState})
|
||||
} else if ($session?.wallet) {
|
||||
pushModal(Zap, {url, pubkey: event.pubkey, eventId: event.id}, {replaceState})
|
||||
} else {
|
||||
pushModal(WalletConnect, {}, {replaceState})
|
||||
const onClick = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const zapper = await zapperPromise
|
||||
|
||||
if (!zapper?.allowsNostr) {
|
||||
pushModal(InfoZapperError, {url, pubkey: event.pubkey, eventId: event.id}, {replaceState})
|
||||
} else if ($session?.wallet) {
|
||||
pushModal(Zap, {url, pubkey: event.pubkey, eventId: event.id}, {replaceState})
|
||||
} else {
|
||||
pushModal(WalletConnect, {}, {replaceState})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
<Button onclick={onClick} {...props}>
|
||||
<Button onclick={onClick} disabled={loading} {...props}>
|
||||
{@render children?.()}
|
||||
</Button>
|
||||
|
||||
+25
-36
@@ -35,7 +35,8 @@ import {
|
||||
RELAYS,
|
||||
FOLLOWS,
|
||||
REACTION,
|
||||
AUTH_JOIN,
|
||||
RELAY_JOIN,
|
||||
RELAY_LEAVE,
|
||||
ROOMS,
|
||||
COMMENT,
|
||||
ALERT_EMAIL,
|
||||
@@ -90,23 +91,21 @@ import {
|
||||
waitForThunkError,
|
||||
getPubkeyRelays,
|
||||
userBlossomServers,
|
||||
shouldUnwrap,
|
||||
} from "@welshman/app"
|
||||
import {compressFile} from "@src/lib/html"
|
||||
import type {SettingsValues, Alert} from "@app/core/state"
|
||||
import {
|
||||
SETTINGS,
|
||||
PROTECTED,
|
||||
userMembership,
|
||||
INDEXER_RELAYS,
|
||||
NOTIFIER_PUBKEY,
|
||||
NOTIFIER_RELAY,
|
||||
DEFAULT_BLOSSOM_SERVERS,
|
||||
userRoomsByUrl,
|
||||
userSpaceUrls,
|
||||
userSettingsValues,
|
||||
canDecrypt,
|
||||
ensureUnwrapped,
|
||||
userInboxRelays,
|
||||
getMembershipUrls,
|
||||
userGroupSelections,
|
||||
} from "@app/core/state"
|
||||
import {loadAlertStatuses} from "@app/core/requests"
|
||||
import {platform, platformName, getPushInfo} from "@app/util/push"
|
||||
@@ -176,7 +175,7 @@ export const broadcastUserData = async (relays: string[]) => {
|
||||
// List updates
|
||||
|
||||
export const addSpaceMembership = async (url: string) => {
|
||||
const list = get(userMembership) || makeList({kind: ROOMS})
|
||||
const list = get(userGroupSelections) || makeList({kind: ROOMS})
|
||||
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
|
||||
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||
|
||||
@@ -184,7 +183,7 @@ export const addSpaceMembership = async (url: string) => {
|
||||
}
|
||||
|
||||
export const removeSpaceMembership = async (url: string) => {
|
||||
const list = get(userMembership) || makeList({kind: ROOMS})
|
||||
const list = get(userGroupSelections) || makeList({kind: ROOMS})
|
||||
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
|
||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
||||
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||
@@ -193,7 +192,7 @@ export const removeSpaceMembership = async (url: string) => {
|
||||
}
|
||||
|
||||
export const addRoomMembership = async (url: string, room: string) => {
|
||||
const list = get(userMembership) || makeList({kind: ROOMS})
|
||||
const list = get(userGroupSelections) || makeList({kind: ROOMS})
|
||||
const newTags = [
|
||||
["r", url],
|
||||
["group", room, url],
|
||||
@@ -205,7 +204,7 @@ export const addRoomMembership = async (url: string, room: string) => {
|
||||
}
|
||||
|
||||
export const removeRoomMembership = async (url: string, room: string) => {
|
||||
const list = get(userMembership) || makeList({kind: ROOMS})
|
||||
const list = get(userGroupSelections) || makeList({kind: ROOMS})
|
||||
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
|
||||
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
|
||||
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||
@@ -227,12 +226,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
|
||||
|
||||
return publishThunk({
|
||||
event: makeEvent(list.kind, {tags}),
|
||||
relays: [
|
||||
url,
|
||||
...INDEXER_RELAYS,
|
||||
...Router.get().FromUser().getUrls(),
|
||||
...userRoomsByUrl.get().keys(),
|
||||
],
|
||||
relays: [url, ...INDEXER_RELAYS, ...Router.get().FromUser().getUrls(), ...get(userSpaceUrls)],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,11 +243,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
|
||||
|
||||
return publishThunk({
|
||||
event: makeEvent(list.kind, {tags}),
|
||||
relays: [
|
||||
...INDEXER_RELAYS,
|
||||
...Router.get().FromUser().getUrls(),
|
||||
...userRoomsByUrl.get().keys(),
|
||||
],
|
||||
relays: [...INDEXER_RELAYS, ...Router.get().FromUser().getUrls(), ...get(userSpaceUrls)],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -305,7 +295,7 @@ export const checkRelayAccess = async (url: string, claim = "") => {
|
||||
export const checkRelayProfile = async (url: string) => {
|
||||
const relay = await loadRelay(url)
|
||||
|
||||
if (!relay?.profile) {
|
||||
if (!relay) {
|
||||
return "Sorry, we weren't able to find that relay."
|
||||
}
|
||||
}
|
||||
@@ -593,8 +583,8 @@ export const createAlert = async (params: CreateAlertParams): Promise<CreateAler
|
||||
}
|
||||
|
||||
export const createDmAlert = async () => {
|
||||
if (!get(canDecrypt)) {
|
||||
enableGiftWraps()
|
||||
if (!shouldUnwrap.get()) {
|
||||
shouldUnwrap.set(true)
|
||||
}
|
||||
|
||||
return createAlert({
|
||||
@@ -633,11 +623,20 @@ export type JoinRequestParams = {
|
||||
}
|
||||
|
||||
export const makeJoinRequest = (params: JoinRequestParams) =>
|
||||
makeEvent(AUTH_JOIN, {tags: [["claim", params.claim]]})
|
||||
makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]})
|
||||
|
||||
export const publishJoinRequest = (params: JoinRequestParams) =>
|
||||
publishThunk({event: makeJoinRequest(params), relays: [params.url]})
|
||||
|
||||
// Leave request
|
||||
|
||||
export type LeaveRequestParams = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export const publishLeaveRequest = (params: LeaveRequestParams) =>
|
||||
publishThunk({event: makeEvent(RELAY_LEAVE), relays: [params.url]})
|
||||
|
||||
// Lightning
|
||||
|
||||
export const getWebLn = () => (window as any).webln
|
||||
@@ -658,16 +657,6 @@ export const payInvoice = async (invoice: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Gift Wraps
|
||||
|
||||
export const enableGiftWraps = () => {
|
||||
canDecrypt.set(true)
|
||||
|
||||
for (const event of repository.query([{kinds: [WRAP]}])) {
|
||||
ensureUnwrapped(event)
|
||||
}
|
||||
}
|
||||
|
||||
// File upload
|
||||
|
||||
export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http"))
|
||||
@@ -792,7 +781,7 @@ export const updateProfile = async ({
|
||||
}) => {
|
||||
const router = Router.get()
|
||||
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
|
||||
const scenarios = [router.FromRelays(getMembershipUrls(userMembership.get()))]
|
||||
const scenarios = [router.FromRelays(get(userSpaceUrls))]
|
||||
|
||||
if (shouldBroadcast) {
|
||||
scenarios.push(router.FromUser(), router.Index())
|
||||
|
||||
+47
-221
@@ -1,36 +1,26 @@
|
||||
import {get, writable} from "svelte/store"
|
||||
import {
|
||||
partition,
|
||||
chunk,
|
||||
sample,
|
||||
sleep,
|
||||
shuffle,
|
||||
uniq,
|
||||
int,
|
||||
YEAR,
|
||||
DAY,
|
||||
insertAt,
|
||||
sortBy,
|
||||
assoc,
|
||||
now,
|
||||
on,
|
||||
isNotNil,
|
||||
filterVals,
|
||||
fromPairs,
|
||||
} from "@welshman/lib"
|
||||
import {
|
||||
MESSAGE,
|
||||
DELETE,
|
||||
THREAD,
|
||||
EVENT_TIME,
|
||||
AUTH_INVITE,
|
||||
COMMENT,
|
||||
RELAY_INVITE,
|
||||
ALERT_EMAIL,
|
||||
ALERT_WEB,
|
||||
ALERT_IOS,
|
||||
ALERT_ANDROID,
|
||||
ALERT_STATUS,
|
||||
matchFilters,
|
||||
getTagValues,
|
||||
getTagValue,
|
||||
getAddress,
|
||||
isShareableRelayUrl,
|
||||
@@ -39,88 +29,37 @@ import {
|
||||
import type {TrustedEvent, Filter, List} from "@welshman/util"
|
||||
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
|
||||
import {load, request} from "@welshman/net"
|
||||
import type {AppSyncOpts, Thunk} from "@welshman/app"
|
||||
import {
|
||||
repository,
|
||||
pull,
|
||||
hasNegentropy,
|
||||
thunkQueue,
|
||||
makeFeedController,
|
||||
loadRelay,
|
||||
loadMutes,
|
||||
loadFollows,
|
||||
loadProfile,
|
||||
loadBlossomServers,
|
||||
loadRelaySelections,
|
||||
loadInboxRelaySelections,
|
||||
} from "@welshman/app"
|
||||
import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app"
|
||||
import {createScroller} from "@lib/html"
|
||||
import {daysBetween} from "@lib/util"
|
||||
import {
|
||||
NOTIFIER_RELAY,
|
||||
INDEXER_RELAYS,
|
||||
defaultPubkeys,
|
||||
userRoomsByUrl,
|
||||
getUrlsForEvent,
|
||||
loadMembership,
|
||||
loadSettings,
|
||||
} from "@app/core/state"
|
||||
import {NOTIFIER_RELAY, getEventsForUrl} from "@app/core/state"
|
||||
|
||||
// Utils
|
||||
|
||||
export const pullConservatively = ({relays, filters}: AppSyncOpts) => {
|
||||
const $getUrlsForEvent = get(getUrlsForEvent)
|
||||
const [smart, dumb] = partition(hasNegentropy, relays)
|
||||
const promises = [pull({relays: smart, filters})]
|
||||
const allEvents = repository.query(filters, {shouldSort: false})
|
||||
|
||||
// Since pulling from relays without negentropy is expensive, limit how many
|
||||
// duplicates we repeatedly download
|
||||
for (const url of dumb) {
|
||||
const events = allEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||
|
||||
if (events.length > 100) {
|
||||
filters = filters.map(assoc("since", events[10]!.created_at))
|
||||
}
|
||||
|
||||
promises.push(pull({relays: [url], filters}))
|
||||
}
|
||||
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
export const makeFeed = ({
|
||||
relays,
|
||||
feedFilters,
|
||||
subscriptionFilters,
|
||||
url,
|
||||
filters,
|
||||
element,
|
||||
onEvent,
|
||||
onExhausted,
|
||||
initialEvents = [],
|
||||
}: {
|
||||
relays: string[]
|
||||
feedFilters: Filter[]
|
||||
subscriptionFilters: Filter[]
|
||||
url: string
|
||||
filters: Filter[]
|
||||
element: HTMLElement
|
||||
onEvent?: (event: TrustedEvent) => void
|
||||
onExhausted?: () => void
|
||||
initialEvents?: TrustedEvent[]
|
||||
}) => {
|
||||
const seen = new Set<string>()
|
||||
const buffer = writable<TrustedEvent[]>([])
|
||||
const events = writable(initialEvents)
|
||||
const initialEvents = getEventsForUrl(url, filters)
|
||||
const seen = new Set(initialEvents.map(e => e.id))
|
||||
const controller = new AbortController()
|
||||
|
||||
const markEvent = (event: TrustedEvent) => {
|
||||
if (!seen.has(event.id)) {
|
||||
seen.add(event.id)
|
||||
onEvent?.(event)
|
||||
}
|
||||
}
|
||||
const buffer = writable(initialEvents)
|
||||
const events = writable<TrustedEvent[]>([])
|
||||
|
||||
const insertEvent = (event: TrustedEvent) => {
|
||||
let handled = false
|
||||
|
||||
if (seen.has(event.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
events.update($events => {
|
||||
for (let i = 0; i < $events.length; i++) {
|
||||
if ($events[i].id === event.id) return $events
|
||||
@@ -144,47 +83,27 @@ export const makeFeed = ({
|
||||
})
|
||||
}
|
||||
|
||||
markEvent(event)
|
||||
seen.add(event.id)
|
||||
}
|
||||
|
||||
const removeEvents = (ids: string[]) => {
|
||||
buffer.update($buffer => $buffer.filter(e => !ids.includes(e.id)))
|
||||
events.update($events => $events.filter(e => !ids.includes(e.id)))
|
||||
}
|
||||
|
||||
const handleDelete = (e: TrustedEvent) => removeEvents(getTagValues(["e", "a"], e.tags))
|
||||
|
||||
const onThunk = (thunk: Thunk) => {
|
||||
if (matchFilters(feedFilters, thunk.event)) {
|
||||
insertEvent(thunk.event)
|
||||
|
||||
thunk.controller.signal.addEventListener("abort", () => {
|
||||
removeEvents([thunk.event.id])
|
||||
})
|
||||
} else if (thunk.event.kind === DELETE) {
|
||||
handleDelete(thunk.event)
|
||||
const unsubscribe = on(repository, "update", ({added, removed}) => {
|
||||
if (removed.size > 0) {
|
||||
buffer.update($buffer => $buffer.filter(e => !removed.has(e.id)))
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ctrl = makeFeedController({
|
||||
useWindowing: true,
|
||||
feed: makeIntersectionFeed(makeRelayFeed(...relays), feedFromFilters(feedFilters)),
|
||||
onEvent: insertEvent,
|
||||
onExhausted,
|
||||
})
|
||||
|
||||
for (const event of initialEvents) {
|
||||
markEvent(event)
|
||||
}
|
||||
|
||||
request({
|
||||
relays,
|
||||
signal: controller.signal,
|
||||
filters: subscriptionFilters,
|
||||
onEvent: (e: TrustedEvent) => {
|
||||
if (matchFilters(feedFilters, e)) insertEvent(e)
|
||||
if (e.kind === DELETE) handleDelete(e)
|
||||
},
|
||||
feed: makeIntersectionFeed(makeRelayFeed(url), feedFromFilters(filters)),
|
||||
onExhausted,
|
||||
})
|
||||
|
||||
const scroller = createScroller({
|
||||
@@ -194,7 +113,7 @@ export const makeFeed = ({
|
||||
onScroll: async () => {
|
||||
const $buffer = get(buffer)
|
||||
|
||||
events.update($events => sortBy(e => -e.created_at, [...$events, ...$buffer.splice(0, 100)]))
|
||||
events.update($events => [...$events, ...$buffer.splice(0, 30)])
|
||||
|
||||
if ($buffer.length < 100) {
|
||||
ctrl.load(100)
|
||||
@@ -202,8 +121,6 @@ export const makeFeed = ({
|
||||
},
|
||||
})
|
||||
|
||||
const unsubscribe = thunkQueue.subscribe(onThunk)
|
||||
|
||||
return {
|
||||
events,
|
||||
cleanup: () => {
|
||||
@@ -215,22 +132,19 @@ export const makeFeed = ({
|
||||
}
|
||||
|
||||
export const makeCalendarFeed = ({
|
||||
relays,
|
||||
feedFilters,
|
||||
subscriptionFilters,
|
||||
url,
|
||||
filters,
|
||||
element,
|
||||
onExhausted,
|
||||
initialEvents = [],
|
||||
}: {
|
||||
relays: string[]
|
||||
feedFilters: Filter[]
|
||||
subscriptionFilters: Filter[]
|
||||
url: string
|
||||
filters: Filter[]
|
||||
element: HTMLElement
|
||||
onExhausted?: () => void
|
||||
initialEvents?: TrustedEvent[]
|
||||
}) => {
|
||||
const interval = int(5, DAY)
|
||||
const controller = new AbortController()
|
||||
const initialEvents = getEventsForUrl(url, filters)
|
||||
|
||||
let exhaustedScrollers = 0
|
||||
let backwardWindow = [now() - interval, now()]
|
||||
@@ -258,38 +172,26 @@ export const makeCalendarFeed = ({
|
||||
})
|
||||
}
|
||||
|
||||
const removeEvents = (ids: string[]) => {
|
||||
events.update($events => $events.filter(e => !ids.includes(e.id)))
|
||||
}
|
||||
|
||||
const onThunk = (thunk: Thunk) => {
|
||||
if (matchFilters(feedFilters, thunk.event)) {
|
||||
insertEvent(thunk.event)
|
||||
|
||||
thunk.controller.signal.addEventListener("abort", () => {
|
||||
removeEvents([thunk.event.id])
|
||||
})
|
||||
const unsubscribe = on(repository, "update", ({added, removed}) => {
|
||||
if (removed.size > 0) {
|
||||
events.update($events => $events.filter(e => !removed.has(e.id)))
|
||||
}
|
||||
}
|
||||
|
||||
request({
|
||||
relays,
|
||||
signal: controller.signal,
|
||||
filters: subscriptionFilters,
|
||||
onEvent: (e: TrustedEvent) => {
|
||||
if (matchFilters(feedFilters, e)) insertEvent(e)
|
||||
},
|
||||
for (const event of added) {
|
||||
if (matchFilters(filters, event)) {
|
||||
insertEvent(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const loadTimeframe = (since: number, until: number) => {
|
||||
const hashes = daysBetween(since, until).map(String)
|
||||
|
||||
request({
|
||||
relays,
|
||||
signal: controller.signal,
|
||||
relays: [url],
|
||||
autoClose: true,
|
||||
signal: controller.signal,
|
||||
filters: [{kinds: [EVENT_TIME], "#D": hashes}],
|
||||
onEvent: insertEvent,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -332,8 +234,6 @@ export const makeCalendarFeed = ({
|
||||
},
|
||||
})
|
||||
|
||||
const unsubscribe = thunkQueue.subscribe(onThunk)
|
||||
|
||||
return {
|
||||
events,
|
||||
cleanup: () => {
|
||||
@@ -359,80 +259,6 @@ export const loadAlertStatuses = (pubkey: string) =>
|
||||
filters: [{kinds: [ALERT_STATUS], "#p": [pubkey]}],
|
||||
})
|
||||
|
||||
// Application requests
|
||||
|
||||
export const listenForNotifications = () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
for (const [url, allRooms] of userRoomsByUrl.get()) {
|
||||
// Limit how many rooms we load at a time, since we have to send a separate filter
|
||||
// for each one due to relay29 being picky
|
||||
const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
|
||||
|
||||
load({
|
||||
signal: controller.signal,
|
||||
relays: [url],
|
||||
filters: [
|
||||
{kinds: [THREAD], limit: 1},
|
||||
{kinds: [MESSAGE], limit: 1},
|
||||
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
|
||||
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
|
||||
],
|
||||
})
|
||||
|
||||
request({
|
||||
signal: controller.signal,
|
||||
relays: [url],
|
||||
filters: [
|
||||
{kinds: [THREAD], since: now()},
|
||||
{kinds: [MESSAGE], since: now()},
|
||||
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
|
||||
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
export const loadUserData = async (pubkey: string, relays: string[] = []) => {
|
||||
await Promise.race([sleep(3000), loadRelaySelections(pubkey, relays)])
|
||||
|
||||
const promise = Promise.race([
|
||||
sleep(3000),
|
||||
Promise.all([
|
||||
loadInboxRelaySelections(pubkey, relays),
|
||||
loadBlossomServers(pubkey, relays),
|
||||
loadMembership(pubkey, relays),
|
||||
loadSettings(pubkey, relays),
|
||||
loadProfile(pubkey, relays),
|
||||
loadFollows(pubkey, relays),
|
||||
loadMutes(pubkey, relays),
|
||||
loadAlertStatuses(pubkey),
|
||||
loadAlerts(pubkey),
|
||||
]),
|
||||
])
|
||||
|
||||
// Load followed profiles slowly in the background without clogging other stuff up. Only use a single
|
||||
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
|
||||
promise.then(async () => {
|
||||
for (const pubkeys of chunk(50, get(defaultPubkeys))) {
|
||||
const relays = sample(1, INDEXER_RELAYS)
|
||||
|
||||
await sleep(1000)
|
||||
|
||||
for (const pubkey of pubkeys) {
|
||||
loadMembership(pubkey, relays)
|
||||
loadProfile(pubkey, relays)
|
||||
loadFollows(pubkey, relays)
|
||||
loadMutes(pubkey, relays)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
export const discoverRelays = (lists: List[]) =>
|
||||
Promise.all(
|
||||
uniq(lists.flatMap($l => getRelaysFromList($l)))
|
||||
@@ -441,7 +267,7 @@ export const discoverRelays = (lists: List[]) =>
|
||||
)
|
||||
|
||||
export const requestRelayClaim = async (url: string) => {
|
||||
const filters = [{kinds: [AUTH_INVITE], limit: 1}]
|
||||
const filters = [{kinds: [RELAY_INVITE], limit: 1}]
|
||||
const events = await load({filters, relays: [url]})
|
||||
|
||||
if (events.length > 0) {
|
||||
|
||||
+458
-271
@@ -4,15 +4,17 @@ import {get, derived, writable} from "svelte/store"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {
|
||||
on,
|
||||
spec,
|
||||
call,
|
||||
first,
|
||||
assoc,
|
||||
remove,
|
||||
uniqBy,
|
||||
sortBy,
|
||||
sort,
|
||||
prop,
|
||||
uniq,
|
||||
nth,
|
||||
pushToMapKey,
|
||||
nthEq,
|
||||
shuffle,
|
||||
parseJson,
|
||||
fromPairs,
|
||||
@@ -24,59 +26,80 @@ import {
|
||||
tryCatch,
|
||||
} from "@welshman/lib"
|
||||
import type {Socket} from "@welshman/net"
|
||||
import {Pool, load, AuthStateEvent, AuthStatus, SocketEvent, netContext} from "@welshman/net"
|
||||
import {
|
||||
Pool,
|
||||
load,
|
||||
SocketStatus,
|
||||
AuthStateEvent,
|
||||
AuthStatus,
|
||||
SocketEvent,
|
||||
netContext,
|
||||
} from "@welshman/net"
|
||||
import {
|
||||
collection,
|
||||
custom,
|
||||
throttled,
|
||||
deriveEvents,
|
||||
deriveEventsMapped,
|
||||
withGetter,
|
||||
synced,
|
||||
} from "@welshman/store"
|
||||
import {isKindFeed, findFeed} from "@welshman/feeds"
|
||||
import {
|
||||
getIdFilters,
|
||||
WRAP,
|
||||
ALERT_ANDROID,
|
||||
ALERT_EMAIL,
|
||||
ALERT_IOS,
|
||||
ALERT_STATUS,
|
||||
ALERT_WEB,
|
||||
APP_DATA,
|
||||
CLIENT_AUTH,
|
||||
AUTH_JOIN,
|
||||
REACTION,
|
||||
ZAP_REQUEST,
|
||||
ZAP_RESPONSE,
|
||||
DIRECT_MESSAGE,
|
||||
COMMENT,
|
||||
DELETE,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
ROOM_META,
|
||||
DIRECT_MESSAGE,
|
||||
EVENT_TIME,
|
||||
MESSAGE,
|
||||
REACTION,
|
||||
RELAY_ADD_MEMBER,
|
||||
RELAY_JOIN,
|
||||
RELAY_LEAVE,
|
||||
RELAY_MEMBERS,
|
||||
RELAY_REMOVE_MEMBER,
|
||||
REPORT,
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_CREATE_PERMISSION,
|
||||
ROOM_JOIN,
|
||||
ROOM_LEAVE,
|
||||
ROOM_MEMBERS,
|
||||
ROOM_ADMINS,
|
||||
ROOM_META,
|
||||
ROOM_DELETE,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOMS,
|
||||
THREAD,
|
||||
COMMENT,
|
||||
ROOM_JOIN,
|
||||
ROOM_ADD_USER,
|
||||
ROOM_REMOVE_USER,
|
||||
ALERT_EMAIL,
|
||||
ALERT_WEB,
|
||||
ALERT_IOS,
|
||||
ALERT_ANDROID,
|
||||
ALERT_STATUS,
|
||||
APP_DATA,
|
||||
getGroupTags,
|
||||
getRelayTagValues,
|
||||
getPubkeyTagValues,
|
||||
isHashedEvent,
|
||||
displayProfile,
|
||||
readList,
|
||||
getListTags,
|
||||
WRAP,
|
||||
ZAP_GOAL,
|
||||
ZAP_REQUEST,
|
||||
ZAP_RESPONSE,
|
||||
asDecryptedEvent,
|
||||
normalizeRelayUrl,
|
||||
displayProfile,
|
||||
getGroupTags,
|
||||
getIdFilters,
|
||||
getListTags,
|
||||
getPubkeyTagValues,
|
||||
getRelaysFromList,
|
||||
getRelayTagValues,
|
||||
getTag,
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
verifyEvent,
|
||||
isRelayUrl,
|
||||
makeEvent,
|
||||
normalizeRelayUrl,
|
||||
readList,
|
||||
RelayMode,
|
||||
getRelaysFromList,
|
||||
verifyEvent,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
|
||||
import {Nip59, decrypt} from "@welshman/signer"
|
||||
import type {TrustedEvent, RelayProfile, PublishedList, List, Filter} from "@welshman/util"
|
||||
import {decrypt} from "@welshman/signer"
|
||||
import {routerContext, Router} from "@welshman/router"
|
||||
import {
|
||||
pubkey,
|
||||
@@ -85,14 +108,10 @@ import {
|
||||
tracker,
|
||||
makeTrackerStore,
|
||||
makeRepositoryStore,
|
||||
relay,
|
||||
getSession,
|
||||
getSigner,
|
||||
createSearch,
|
||||
userFollows,
|
||||
ensurePlaintext,
|
||||
thunks,
|
||||
flattenThunks,
|
||||
signer,
|
||||
makeOutboxLoader,
|
||||
appContext,
|
||||
@@ -100,9 +119,11 @@ import {
|
||||
publishThunk,
|
||||
userRelaySelections,
|
||||
userInboxRelaySelections,
|
||||
deriveRelay,
|
||||
makeUserData,
|
||||
makeUserLoader,
|
||||
} from "@welshman/app"
|
||||
import type {Thunk, Relay} from "@welshman/app"
|
||||
import {preferencesStorageProvider} from "@src/lib/storage"
|
||||
import type {Thunk} from "@welshman/app"
|
||||
|
||||
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
||||
|
||||
@@ -112,8 +133,6 @@ export const PROTECTED = ["-"]
|
||||
|
||||
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
|
||||
|
||||
export const REACTION_KINDS = ENABLE_ZAPS ? [REACTION, ZAP_RESPONSE] : [REACTION]
|
||||
|
||||
export const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
|
||||
|
||||
export const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY
|
||||
@@ -150,7 +169,7 @@ export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
|
||||
|
||||
export const NIP46_PERMS =
|
||||
"nip44_encrypt,nip44_decrypt," +
|
||||
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST]
|
||||
[CLIENT_AUTH, RELAY_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST]
|
||||
.map(k => `sign_event:${k}`)
|
||||
.join(",")
|
||||
|
||||
@@ -183,53 +202,13 @@ export const entityLink = (entity: string) => `https://coracle.social/${entity}`
|
||||
export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
|
||||
entityLink(nip19.nprofileEncode({pubkey, relays}))
|
||||
|
||||
export const defaultPubkeys = derived(userFollows, $userFollows => {
|
||||
export const bootstrapPubkeys = derived(userFollows, $userFollows => {
|
||||
const appPubkeys = DEFAULT_PUBKEYS.split(",")
|
||||
const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollows)))
|
||||
|
||||
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
|
||||
})
|
||||
|
||||
const failedUnwraps = new Set()
|
||||
|
||||
export const ensureUnwrapped = async (event: TrustedEvent) => {
|
||||
if (event.kind !== WRAP) {
|
||||
return event
|
||||
}
|
||||
|
||||
let rumor = repository.eventsByWrap.get(event.id)
|
||||
|
||||
if (rumor || failedUnwraps.has(event.id)) {
|
||||
return rumor
|
||||
}
|
||||
|
||||
for (const recipient of getPubkeyTagValues(event.tags)) {
|
||||
const session = getSession(recipient)
|
||||
const signer = getSigner(session)
|
||||
|
||||
if (signer) {
|
||||
try {
|
||||
rumor = await Nip59.fromSigner(signer).unwrap(event as SignedEvent)
|
||||
break
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rumor && isHashedEvent(rumor)) {
|
||||
// Copy urls over to the rumor
|
||||
tracker.copy(event.id, rumor.id)
|
||||
|
||||
// Send the rumor via our relay so listeners get updated
|
||||
relay.send("EVENT", rumor)
|
||||
} else {
|
||||
failedUnwraps.add(event.id)
|
||||
}
|
||||
|
||||
return rumor
|
||||
}
|
||||
|
||||
export const trackerStore = makeTrackerStore()
|
||||
|
||||
export const repositoryStore = makeRepositoryStore()
|
||||
@@ -253,46 +232,59 @@ export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thunks]) => {
|
||||
const getThunksByEventId = memoize(() => {
|
||||
const thunksByEventId = new Map<string, Thunk[]>()
|
||||
export const getUrlsForEvent = withGetter(
|
||||
derived([trackerStore, thunks], ([$tracker, $thunks]) => {
|
||||
const getThunksByEventId = memoize(() => {
|
||||
const thunksByEventId = new Map<string, Thunk[]>()
|
||||
|
||||
for (const thunk of flattenThunks(Object.values($thunks))) {
|
||||
pushToMapKey(thunksByEventId, thunk.event.id, thunk)
|
||||
}
|
||||
|
||||
return thunksByEventId
|
||||
})
|
||||
|
||||
return (id: string) => {
|
||||
const urls = Array.from($tracker.getRelays(id))
|
||||
|
||||
for (const thunk of getThunksByEventId().get(id) || []) {
|
||||
for (const url of thunk.options.relays) {
|
||||
urls.push(url)
|
||||
for (const thunk of $thunks) {
|
||||
pushToMapKey(thunksByEventId, thunk.event.id, thunk)
|
||||
}
|
||||
}
|
||||
|
||||
return uniq(urls)
|
||||
}
|
||||
})
|
||||
return thunksByEventId
|
||||
})
|
||||
|
||||
return (id: string) => {
|
||||
const urls = Array.from($tracker.getRelays(id))
|
||||
|
||||
for (const thunk of getThunksByEventId().get(id) || []) {
|
||||
for (const url of thunk.options.relays) {
|
||||
urls.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
return uniq(urls)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
export const getEventsForUrl = (url: string, filters: Filter[]) => {
|
||||
const $getUrlsForEvent = get(getUrlsForEvent)
|
||||
const $events = repository.query(filters)
|
||||
const ids = uniq([
|
||||
...tracker.getIds(url),
|
||||
...get(thunks)
|
||||
.filter(t => t.options.relays.includes(url))
|
||||
.map(t => t.event.id),
|
||||
])
|
||||
|
||||
return sortBy(
|
||||
e => -e.created_at,
|
||||
$events.filter(e => $getUrlsForEvent(e.id).includes(url)),
|
||||
)
|
||||
return repository.query(filters.map(assoc("ids", ids)))
|
||||
}
|
||||
|
||||
export const deriveEventsForUrl = (url: string, filters: Filter[]) =>
|
||||
derived([deriveEvents(repository, {filters}), getUrlsForEvent], ([$events, $getUrlsForEvent]) =>
|
||||
sortBy(
|
||||
e => -e.created_at,
|
||||
$events.filter(e => $getUrlsForEvent(e.id).includes(url)),
|
||||
),
|
||||
derived([trackerStore, thunks], ([$tracker, $thunks]) => {
|
||||
const ids = uniq([
|
||||
...$tracker.getIds(url),
|
||||
...$thunks.filter(t => t.options.relays.includes(url)).map(t => t.event.id),
|
||||
])
|
||||
|
||||
return repository.query(filters.map(assoc("ids", ids)))
|
||||
})
|
||||
|
||||
export const deriveSignedEventsForUrl = (url: string, filters: Filter[]) =>
|
||||
derived(
|
||||
[deriveEventsForUrl(url, filters), deriveRelay(url)],
|
||||
([$events, $relay]) => $events,
|
||||
// Disable this check for now since khatru doesn't support self
|
||||
// $relay?.self ? $events.filter(spec({pubkey: $relay.self})) : [],
|
||||
)
|
||||
|
||||
// Context
|
||||
@@ -304,14 +296,37 @@ routerContext.getIndexerRelays = always(INDEXER_RELAYS)
|
||||
netContext.isEventValid = (event: TrustedEvent, url: string) =>
|
||||
getSetting<string[]>("trusted_relays").includes(url) || verifyEvent(event)
|
||||
|
||||
// Settings
|
||||
// Filters
|
||||
|
||||
export const canDecrypt = synced({
|
||||
key: "canDecrypt",
|
||||
defaultValue: false,
|
||||
storage: preferencesStorageProvider,
|
||||
export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
|
||||
kinds: [COMMENT],
|
||||
"#K": kinds.map(String),
|
||||
...extra,
|
||||
})
|
||||
|
||||
export const REACTION_KINDS = [REPORT, DELETE, REACTION]
|
||||
|
||||
if (ENABLE_ZAPS) {
|
||||
REACTION_KINDS.push(ZAP_RESPONSE)
|
||||
}
|
||||
|
||||
export const MESSAGE_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, MESSAGE]
|
||||
|
||||
export const MESSAGE_FILTER = {kinds: MESSAGE_KINDS}
|
||||
|
||||
export const COMMENT_FILTER = makeCommentFilter(MESSAGE_KINDS)
|
||||
|
||||
export const MEMBERSHIP_KINDS = [
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
RELAY_ADD_MEMBER,
|
||||
RELAY_REMOVE_MEMBER,
|
||||
]
|
||||
|
||||
export const MEMBERSHIP_FILTER = {kinds: MEMBERSHIP_KINDS}
|
||||
|
||||
// Settings
|
||||
|
||||
export const SETTINGS = "flotilla/settings"
|
||||
|
||||
export type SettingsValues = {
|
||||
@@ -363,9 +378,22 @@ export const {
|
||||
load: makeOutboxLoader(APP_DATA, {"#d": [SETTINGS]}),
|
||||
})
|
||||
|
||||
export const userSettings = makeUserData({
|
||||
mapStore: settingsByPubkey,
|
||||
loadItem: loadSettings,
|
||||
})
|
||||
|
||||
export const loadUserSettings = makeUserLoader(loadSettings)
|
||||
|
||||
export const userSettingsValues = withGetter(
|
||||
derived(userSettings, $s => $s?.values || defaultSettings),
|
||||
)
|
||||
|
||||
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingsValues.get()[key] as T
|
||||
|
||||
// Relays sending events with empty signatures that the user has to choose to trust
|
||||
|
||||
export const relaysPendingTrust = writable<string[]>([])
|
||||
export const relaysPendingTrust = withGetter(writable<string[]>([]))
|
||||
|
||||
// Relays that mostly send restricted responses to requests and events
|
||||
|
||||
@@ -443,64 +471,6 @@ export const alertStatuses = withGetter(
|
||||
export const deriveAlertStatus = (address: string) =>
|
||||
derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address))
|
||||
|
||||
// Membership
|
||||
|
||||
export const hasMembershipUrl = (list: List | undefined, url: string) =>
|
||||
getListTags(list).some(t => {
|
||||
if (t[0] === "r") return t[1] === url
|
||||
if (t[0] === "group") return t[2] === url
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
export const getMembershipUrls = (list?: List) => {
|
||||
const tags = getListTags(list)
|
||||
|
||||
return sort(
|
||||
uniq([...getRelayTagValues(tags), ...getGroupTags(tags).map(nth(2))]).map(url =>
|
||||
normalizeRelayUrl(url),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const getMembershipRooms = (list?: List) =>
|
||||
getGroupTags(getListTags(list)).map(([_, room, url, name = ""]) => ({url, room, name}))
|
||||
|
||||
export const getMembershipRoomsByUrl = (url: string, list?: List) =>
|
||||
sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1)))
|
||||
|
||||
export const memberships = deriveEventsMapped<PublishedList>(repository, {
|
||||
filters: [{kinds: [ROOMS]}],
|
||||
itemToEvent: item => item.event,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
})
|
||||
|
||||
export const {
|
||||
indexStore: membershipsByPubkey,
|
||||
deriveItem: deriveMembership,
|
||||
loadItem: loadMembership,
|
||||
} = collection({
|
||||
name: "memberships",
|
||||
store: memberships,
|
||||
getKey: list => list.event.pubkey,
|
||||
load: makeOutboxLoader(ROOMS),
|
||||
})
|
||||
|
||||
export const membersByUrl = derived(
|
||||
[defaultPubkeys, membershipsByPubkey],
|
||||
([$defaultPubkeys, $membershipsByPubkey]) => {
|
||||
const $membersByUrl = new Map<string, Set<string>>()
|
||||
|
||||
for (const pubkey of $defaultPubkeys) {
|
||||
for (const url of getMembershipUrls($membershipsByPubkey.get(pubkey))) {
|
||||
addToMapKey($membersByUrl, url, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
return $membersByUrl
|
||||
},
|
||||
)
|
||||
|
||||
// Chats
|
||||
|
||||
export const chatMessages = deriveEvents(repository, {
|
||||
@@ -525,11 +495,6 @@ export const chats = derived(
|
||||
const messagesByChatId = new Map<string, TrustedEvent[]>()
|
||||
|
||||
for (const message of $messages) {
|
||||
// Filter out messages we sent but aren't addressed to the user
|
||||
if (!getPubkeyTagValues(message.wrap?.tags || []).includes($pubkey!)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const chatId = makeChatId(getPubkeyTagValues(message.tags).concat(message.pubkey))
|
||||
|
||||
pushToMapKey(messagesByChatId, chatId, message)
|
||||
@@ -545,7 +510,7 @@ export const chats = derived(
|
||||
c => -c.last_activity,
|
||||
Array.from(messagesByChatId.entries()).map(([id, events]): Chat => {
|
||||
const pubkeys = remove($pubkey!, splitChatId(id))
|
||||
const messages = sortBy(e => -e.created_at, events)
|
||||
const messages = sortBy(e => -e.created_at, uniqBy(prop("id"), events))
|
||||
const last_activity = messages[0].created_at
|
||||
const search_text =
|
||||
pubkeys.length === 0
|
||||
@@ -576,12 +541,10 @@ export const chatSearch = derived(chats, $chats =>
|
||||
}),
|
||||
)
|
||||
|
||||
// Messages
|
||||
// Channels
|
||||
|
||||
export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]})
|
||||
|
||||
// Channels
|
||||
|
||||
export type Channel = {
|
||||
id: string
|
||||
url: string
|
||||
@@ -598,40 +561,46 @@ export const makeChannelId = (url: string, room: string) => `${url}'${room}`
|
||||
|
||||
export const splitChannelId = (id: string) => id.split("'")
|
||||
|
||||
export const hasNip29 = (relay?: Relay) =>
|
||||
relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
|
||||
|
||||
export const channelEvents = deriveEvents(repository, {filters: [{kinds: [ROOM_META]}]})
|
||||
export const hasNip29 = (relay?: RelayProfile) =>
|
||||
relay?.supported_nips?.map?.(String)?.includes?.("29")
|
||||
|
||||
export const channels = derived(
|
||||
[channelEvents, getUrlsForEvent],
|
||||
([$channelEvents, $getUrlsForEvent]) => {
|
||||
const $channels: Channel[] = []
|
||||
[deriveEvents(repository, {filters: [{kinds: [ROOM_META, ROOM_DELETE]}]}), getUrlsForEvent],
|
||||
([$events, $getUrlsForEvent]) => {
|
||||
const result = new Map<string, Channel>()
|
||||
|
||||
for (const event of $channelEvents) {
|
||||
const meta = fromPairs(event.tags)
|
||||
const room = meta.d
|
||||
for (const event of sortBy(e => e.created_at, $events)) {
|
||||
for (const url of $getUrlsForEvent(event.id)) {
|
||||
if (event.kind === ROOM_META) {
|
||||
const meta = fromPairs(event.tags)
|
||||
const room = meta.d
|
||||
|
||||
if (room) {
|
||||
for (const url of $getUrlsForEvent(event.id)) {
|
||||
const id = makeChannelId(url, room)
|
||||
if (room) {
|
||||
const id = makeChannelId(url, room)
|
||||
|
||||
$channels.push({
|
||||
id,
|
||||
url,
|
||||
room,
|
||||
event,
|
||||
name: meta.name || room,
|
||||
closed: Boolean(getTag("closed", event.tags)),
|
||||
private: Boolean(getTag("private", event.tags)),
|
||||
picture: meta.picture,
|
||||
about: meta.about,
|
||||
})
|
||||
result.set(id, {
|
||||
id,
|
||||
url,
|
||||
room,
|
||||
event,
|
||||
name: meta.name || room,
|
||||
closed: Boolean(getTag("closed", event.tags)),
|
||||
private: Boolean(getTag("private", event.tags)),
|
||||
picture: meta.picture,
|
||||
about: meta.about,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === ROOM_DELETE) {
|
||||
for (const room of getTagValues("h", event.tags)) {
|
||||
result.delete(makeChannelId(url, room))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uniqBy(c => c.id, $channels)
|
||||
return Array.from(result.values())
|
||||
},
|
||||
)
|
||||
|
||||
@@ -665,101 +634,271 @@ export const displayChannel = (url: string, room: string) =>
|
||||
export const roomComparator = (url: string) => (room: string) =>
|
||||
displayChannel(url, room).toLowerCase()
|
||||
|
||||
// User stuff
|
||||
// User space/room selections
|
||||
|
||||
export const userSettings = withGetter(
|
||||
derived([pubkey, settingsByPubkey], ([$pubkey, $settingsByPubkey]) => {
|
||||
if (!$pubkey) return undefined
|
||||
export const groupSelections = deriveEventsMapped<PublishedList>(repository, {
|
||||
filters: [{kinds: [ROOMS]}],
|
||||
itemToEvent: item => item.event,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
})
|
||||
|
||||
loadSettings($pubkey)
|
||||
export const {
|
||||
indexStore: groupSelectionsByPubkey,
|
||||
deriveItem: deriveGroupSelections,
|
||||
loadItem: loadGroupSelections,
|
||||
} = collection({
|
||||
name: "groupSelections",
|
||||
store: groupSelections,
|
||||
getKey: list => list.event.pubkey,
|
||||
load: makeOutboxLoader(ROOMS),
|
||||
})
|
||||
|
||||
return $settingsByPubkey.get($pubkey)
|
||||
}),
|
||||
)
|
||||
export const groupSelectionsPubkeysByUrl = derived(groupSelections, $groupSelections => {
|
||||
const result = new Map<string, Set<string>>()
|
||||
|
||||
export const userSettingsValues = withGetter(
|
||||
derived(userSettings, $s => $s?.values || defaultSettings),
|
||||
)
|
||||
|
||||
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingsValues.get()[key] as T
|
||||
|
||||
export const userMembership = withGetter(
|
||||
derived([pubkey, membershipsByPubkey], ([$pubkey, $membershipsByPubkey]) => {
|
||||
if (!$pubkey) return undefined
|
||||
|
||||
loadMembership($pubkey)
|
||||
|
||||
return $membershipsByPubkey.get($pubkey)
|
||||
}),
|
||||
)
|
||||
|
||||
export const userRoomsByUrl = withGetter(
|
||||
derived([userMembership, channelsById], ([$userMembership, $channelsById]) => {
|
||||
const tags = getListTags($userMembership)
|
||||
const $userRoomsByUrl = new Map<string, Set<string>>()
|
||||
for (const list of $groupSelections) {
|
||||
const tags = getListTags(list)
|
||||
|
||||
for (const url of getRelayTagValues(tags)) {
|
||||
$userRoomsByUrl.set(normalizeRelayUrl(url), new Set())
|
||||
addToMapKey(result, url, list.event.pubkey)
|
||||
}
|
||||
|
||||
for (const [_, room, url] of getGroupTags(tags)) {
|
||||
for (const tag of getGroupTags(tags)) {
|
||||
const url = tag[2] || ""
|
||||
|
||||
if (isRelayUrl(url)) {
|
||||
addToMapKey(result, url, list.event.pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
export const getSpaceUrlsFromGroupSelections = ($groupSelections: List | undefined) => {
|
||||
const tags = getListTags($groupSelections)
|
||||
const urls = getRelayTagValues(tags)
|
||||
|
||||
for (const tag of getGroupTags(tags)) {
|
||||
const url = tag[2] || ""
|
||||
|
||||
if (isRelayUrl(url)) {
|
||||
urls.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
return uniq(urls.map(normalizeRelayUrl))
|
||||
}
|
||||
|
||||
export const getSpaceRoomsFromGroupSelections = (
|
||||
url: string,
|
||||
$groupSelections: List | undefined,
|
||||
) => {
|
||||
const rooms: string[] = []
|
||||
|
||||
for (const [_, room, relay] of getGroupTags(getListTags($groupSelections))) {
|
||||
if (url === relay) {
|
||||
rooms.push(room)
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(roomComparator(url), rooms)
|
||||
}
|
||||
|
||||
export const userGroupSelections = makeUserData({
|
||||
mapStore: groupSelectionsByPubkey,
|
||||
loadItem: loadGroupSelections,
|
||||
})
|
||||
|
||||
export const loadUserGroupSelections = makeUserLoader(loadGroupSelections)
|
||||
|
||||
export const userSpaceUrls = derived(userGroupSelections, getSpaceUrlsFromGroupSelections)
|
||||
|
||||
export const deriveUserRooms = (url: string) =>
|
||||
derived([userGroupSelections, channelsById], ([$userGroupSelections, $channelsById]) => {
|
||||
const rooms: string[] = []
|
||||
|
||||
for (const room of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) {
|
||||
if ($channelsById.has(makeChannelId(url, room))) {
|
||||
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
|
||||
rooms.push(room)
|
||||
}
|
||||
}
|
||||
|
||||
return $userRoomsByUrl
|
||||
}),
|
||||
)
|
||||
|
||||
export const deriveUserRooms = (url: string) =>
|
||||
derived(userRoomsByUrl, $userRoomsByUrl =>
|
||||
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || []))),
|
||||
)
|
||||
return sortBy(roomComparator(url), rooms)
|
||||
})
|
||||
|
||||
export const deriveOtherRooms = (url: string) =>
|
||||
derived([deriveUserRooms(url), channelsByUrl], ([$userRooms, $channelsByUrl]) =>
|
||||
sortBy(
|
||||
roomComparator(url),
|
||||
($channelsByUrl.get(url) || []).filter(c => !$userRooms.includes(c.room)).map(c => c.room),
|
||||
),
|
||||
derived([deriveUserRooms(url), channelsByUrl], ([$userRooms, $channelsByUrl]) => {
|
||||
const rooms: string[] = []
|
||||
|
||||
for (const {room} of $channelsByUrl.get(url) || []) {
|
||||
if (!$userRooms.includes(room)) {
|
||||
rooms.push(room)
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(roomComparator(url), rooms)
|
||||
})
|
||||
|
||||
// Space/room memberships
|
||||
|
||||
export const deriveSpaceMembers = (url: string) =>
|
||||
derived(
|
||||
deriveSignedEventsForUrl(url, [
|
||||
{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]},
|
||||
]),
|
||||
$events => {
|
||||
const membersEvent = $events.find(spec({kind: RELAY_MEMBERS}))
|
||||
|
||||
if (membersEvent) {
|
||||
return getTagValues("member", membersEvent.tags)
|
||||
}
|
||||
|
||||
const members = new Set()
|
||||
|
||||
for (const event of sortBy(e => e.created_at, $events)) {
|
||||
const pubkeys = getPubkeyTagValues(event.tags)
|
||||
|
||||
if (event.kind === RELAY_ADD_MEMBER) {
|
||||
for (const pubkey of pubkeys) {
|
||||
members.add(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === RELAY_REMOVE_MEMBER) {
|
||||
for (const pubkey of pubkeys) {
|
||||
members.delete(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(members)
|
||||
},
|
||||
)
|
||||
|
||||
export const deriveRoomMembers = (url: string, room: string) =>
|
||||
derived(
|
||||
deriveEventsForUrl(url, [
|
||||
{kinds: [ROOM_MEMBERS], "#d": [room]},
|
||||
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [room]},
|
||||
]),
|
||||
$events => {
|
||||
const membersEvent = $events.find(spec({kind: ROOM_MEMBERS}))
|
||||
|
||||
if (membersEvent) {
|
||||
return getPubkeyTagValues(membersEvent.tags)
|
||||
}
|
||||
|
||||
const members = new Set()
|
||||
|
||||
for (const event of sortBy(e => -e.created_at, $events)) {
|
||||
const pubkeys = getPubkeyTagValues(event.tags)
|
||||
|
||||
if (event.kind === ROOM_ADD_MEMBER) {
|
||||
for (const pubkey of pubkeys) {
|
||||
members.add(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind === ROOM_REMOVE_MEMBER) {
|
||||
for (const pubkey of pubkeys) {
|
||||
members.delete(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(members)
|
||||
},
|
||||
)
|
||||
|
||||
export const deriveRoomAdmins = (url: string, room: string) =>
|
||||
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ADMINS], "#d": [room]}]), $events => {
|
||||
const adminsEvent = first($events)
|
||||
|
||||
if (adminsEvent) {
|
||||
return getPubkeyTagValues(adminsEvent.tags)
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
// User membership status
|
||||
|
||||
export enum MembershipStatus {
|
||||
Initial,
|
||||
Pending,
|
||||
Granted,
|
||||
}
|
||||
|
||||
export const deriveUserMembershipStatus = (url: string, room: string) =>
|
||||
export const deriveUserSpaceMembershipStatus = (url: string) =>
|
||||
derived(
|
||||
[
|
||||
pubkey,
|
||||
deriveEventsForUrl(url, [
|
||||
{kinds: [ROOM_JOIN, ROOM_ADD_USER, ROOM_REMOVE_USER], "#h": [room]},
|
||||
]),
|
||||
deriveSpaceMembers(url),
|
||||
deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]),
|
||||
],
|
||||
([$pubkey, $events]) => {
|
||||
let status = MembershipStatus.Initial
|
||||
([$pubkey, $members, $events]) => {
|
||||
const isMember = $members.includes($pubkey)
|
||||
|
||||
for (const event of $events) {
|
||||
if (event.kind === ROOM_JOIN && event.pubkey === $pubkey) {
|
||||
status = MembershipStatus.Pending
|
||||
if (event.pubkey !== $pubkey) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.kind === ROOM_REMOVE_USER && getTagValues("p", event.tags).includes($pubkey!)) {
|
||||
break
|
||||
if (event.kind === RELAY_JOIN) {
|
||||
return isMember ? MembershipStatus.Granted : MembershipStatus.Pending
|
||||
}
|
||||
|
||||
if (event.kind === ROOM_ADD_USER && getTagValues("p", event.tags).includes($pubkey!)) {
|
||||
return MembershipStatus.Granted
|
||||
if (event.kind === RELAY_LEAVE) {
|
||||
return MembershipStatus.Initial
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
|
||||
},
|
||||
)
|
||||
|
||||
export const deriveUserRoomMembershipStatus = (url: string, room: string) =>
|
||||
derived(
|
||||
[
|
||||
pubkey,
|
||||
deriveRoomMembers(url, room),
|
||||
deriveEventsForUrl(url, [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [room]}]),
|
||||
],
|
||||
([$pubkey, $members, $events]) => {
|
||||
const isMember = $members.includes($pubkey)
|
||||
|
||||
for (const event of $events) {
|
||||
if (event.pubkey !== $pubkey) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.kind === ROOM_JOIN) {
|
||||
return isMember ? MembershipStatus.Granted : MembershipStatus.Pending
|
||||
}
|
||||
|
||||
if (event.kind === ROOM_LEAVE) {
|
||||
return MembershipStatus.Initial
|
||||
}
|
||||
}
|
||||
|
||||
return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
|
||||
},
|
||||
)
|
||||
|
||||
export const deriveUserCanCreateRoom = (url: string) =>
|
||||
derived(
|
||||
[pubkey, deriveEventsForUrl(url, [{kinds: [ROOM_CREATE_PERMISSION]}])],
|
||||
([$pubkey, $events]) => {
|
||||
const event = first($events)
|
||||
|
||||
return event ? getPubkeyTagValues(event.tags).includes($pubkey!) : true
|
||||
},
|
||||
)
|
||||
|
||||
export const deriveUserIsRoomAdmin = (url: string, room: string) =>
|
||||
derived([pubkey, deriveRoomAdmins(url, room)], ([$pubkey, $admins]) => $admins.includes($pubkey!))
|
||||
|
||||
// Other utils
|
||||
|
||||
export const encodeRelay = (url: string) =>
|
||||
@@ -793,6 +932,54 @@ export const deriveSocket = (url: string) =>
|
||||
return () => subs.forEach(call)
|
||||
})
|
||||
|
||||
export const deriveSocketStatus = (url: string) =>
|
||||
throttled(
|
||||
800,
|
||||
derived([deriveSocket(url), relaysMostlyRestricted], ([$socket, $relaysMostlyRestricted]) => {
|
||||
if ($socket.status === SocketStatus.Opening) {
|
||||
return {theme: "warning", title: "Connecting"}
|
||||
}
|
||||
|
||||
if ($socket.status === SocketStatus.Closing) {
|
||||
return {theme: "gray-500", title: "Not Connected"}
|
||||
}
|
||||
|
||||
if ($socket.status === SocketStatus.Closed) {
|
||||
return {theme: "gray-500", title: "Not Connected"}
|
||||
}
|
||||
|
||||
if ($socket.status === SocketStatus.Error) {
|
||||
return {theme: "error", title: "Failed to Connect"}
|
||||
}
|
||||
|
||||
if ($socket.auth.status === AuthStatus.Requested) {
|
||||
return {theme: "warning", title: "Authenticating"}
|
||||
}
|
||||
|
||||
if ($socket.auth.status === AuthStatus.PendingSignature) {
|
||||
return {theme: "warning", title: "Authenticating"}
|
||||
}
|
||||
|
||||
if ($socket.auth.status === AuthStatus.DeniedSignature) {
|
||||
return {theme: "error", title: "Failed to Authenticate"}
|
||||
}
|
||||
|
||||
if ($socket.auth.status === AuthStatus.PendingResponse) {
|
||||
return {theme: "warning", title: "Authenticating"}
|
||||
}
|
||||
|
||||
if ($socket.auth.status === AuthStatus.Forbidden) {
|
||||
return {theme: "error", title: "Access Denied"}
|
||||
}
|
||||
|
||||
if ($relaysMostlyRestricted[url]) {
|
||||
return {theme: "error", title: "Access Denied"}
|
||||
}
|
||||
|
||||
return {theme: "success", title: "Connected"}
|
||||
}),
|
||||
)
|
||||
|
||||
export const deriveTimeout = (timeout: number) => {
|
||||
const store = writable<boolean>(false)
|
||||
|
||||
@@ -811,7 +998,7 @@ export const deriveRelayAuthError = (url: string, claim = "") => {
|
||||
|
||||
// Attempt to join the relay
|
||||
const thunk = publishThunk({
|
||||
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
|
||||
event: makeEvent(RELAY_JOIN, {tags: [["claim", claim]]}),
|
||||
relays: [url],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import {page} from "$app/stores"
|
||||
import type {Unsubscriber} from "svelte/store"
|
||||
import {derived, get} from "svelte/store"
|
||||
import {
|
||||
partition,
|
||||
call,
|
||||
sortBy,
|
||||
assoc,
|
||||
chunk,
|
||||
sleep,
|
||||
now,
|
||||
identity,
|
||||
WEEK,
|
||||
MONTH,
|
||||
ago,
|
||||
} from "@welshman/lib"
|
||||
import {
|
||||
getListTags,
|
||||
getRelayTagValues,
|
||||
WRAP,
|
||||
ROOM_META,
|
||||
ROOM_DELETE,
|
||||
ROOM_ADMINS,
|
||||
ROOM_MEMBERS,
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOM_CREATE_PERMISSION,
|
||||
RELAY_MEMBERS,
|
||||
isSignedEvent,
|
||||
} from "@welshman/util"
|
||||
import type {Filter, TrustedEvent} from "@welshman/util"
|
||||
import {request, load, pull} from "@welshman/net"
|
||||
import {
|
||||
pubkey,
|
||||
loadRelay,
|
||||
userFollows,
|
||||
userRelaySelections,
|
||||
userInboxRelaySelections,
|
||||
loadRelaySelections,
|
||||
loadInboxRelaySelections,
|
||||
loadBlossomServers,
|
||||
loadFollows,
|
||||
loadMutes,
|
||||
loadProfile,
|
||||
repository,
|
||||
shouldUnwrap,
|
||||
hasNegentropy,
|
||||
} from "@welshman/app"
|
||||
import {
|
||||
MESSAGE_FILTER,
|
||||
COMMENT_FILTER,
|
||||
MEMBERSHIP_FILTER,
|
||||
INDEXER_RELAYS,
|
||||
REACTION_KINDS,
|
||||
loadSettings,
|
||||
loadGroupSelections,
|
||||
userSpaceUrls,
|
||||
bootstrapPubkeys,
|
||||
decodeRelay,
|
||||
getUrlsForEvent,
|
||||
} from "@app/core/state"
|
||||
import {loadAlerts, loadAlertStatuses} from "@app/core/requests"
|
||||
import {hasBlossomSupport} from "@app/core/commands"
|
||||
|
||||
// Utils
|
||||
|
||||
type PullOpts = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
const pullConservatively = ({relays, filters, signal}: PullOpts) => {
|
||||
const $getUrlsForEvent = get(getUrlsForEvent)
|
||||
const [smart, dumb] = partition(hasNegentropy, relays)
|
||||
const events = repository.query(filters, {shouldSort: false}).filter(isSignedEvent)
|
||||
const promises: Promise<TrustedEvent[]>[] = [pull({relays: smart, filters, signal, events})]
|
||||
|
||||
// Since pulling from relays without negentropy is expensive, limit how many
|
||||
// duplicates we repeatedly download
|
||||
for (const url of dumb) {
|
||||
const urlEvents = events.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||
|
||||
if (urlEvents.length >= 100) {
|
||||
filters = filters.map(assoc("since", sortBy(e => -e.created_at, urlEvents)[10]!.created_at))
|
||||
}
|
||||
|
||||
promises.push(load({relays: [url], filters, signal}))
|
||||
}
|
||||
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
// Relays
|
||||
|
||||
const syncRelays = () => {
|
||||
for (const url of INDEXER_RELAYS) {
|
||||
loadRelay(url)
|
||||
}
|
||||
|
||||
const unsubscribePage = page.subscribe($page => {
|
||||
if ($page.params.relay) {
|
||||
const url = decodeRelay($page.params.relay)
|
||||
|
||||
loadRelay(url)
|
||||
hasBlossomSupport(url)
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => {
|
||||
for (const url of urls) {
|
||||
loadRelay(url)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribePage()
|
||||
unsubscribeSpaceUrls()
|
||||
}
|
||||
}
|
||||
|
||||
// User data
|
||||
|
||||
const syncUserData = () => {
|
||||
const unsubscribePubkey = pubkey.subscribe($pubkey => {
|
||||
if ($pubkey) {
|
||||
loadRelaySelections($pubkey)
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribeSelections = userRelaySelections.subscribe($l => {
|
||||
const $pubkey = pubkey.get()
|
||||
|
||||
if ($pubkey) {
|
||||
loadAlerts($pubkey)
|
||||
loadAlertStatuses($pubkey)
|
||||
loadBlossomServers($pubkey)
|
||||
loadFollows($pubkey)
|
||||
loadGroupSelections($pubkey)
|
||||
loadMutes($pubkey)
|
||||
loadProfile($pubkey)
|
||||
loadSettings($pubkey)
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribeFollows = userFollows.subscribe(async $l => {
|
||||
for (const pubkeys of chunk(10, get(bootstrapPubkeys))) {
|
||||
// This isn't urgent, avoid clogging other stuff up
|
||||
await sleep(1000)
|
||||
|
||||
for (const pk of pubkeys) {
|
||||
loadRelaySelections(pk).then(() => {
|
||||
loadGroupSelections(pk)
|
||||
loadProfile(pk)
|
||||
loadFollows(pk)
|
||||
loadMutes(pk)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribePubkey()
|
||||
unsubscribeSelections()
|
||||
unsubscribeFollows()
|
||||
}
|
||||
}
|
||||
|
||||
// Memberships
|
||||
|
||||
const syncMembership = (url: string) => {
|
||||
const controller = new AbortController()
|
||||
const relayFilter = {kinds: [RELAY_MEMBERS, ROOM_CREATE_PERMISSION]}
|
||||
const roomsFilter = {kinds: [ROOM_ADMINS, ROOM_MEMBERS, ROOM_META, ROOM_DELETE]}
|
||||
|
||||
// Load group metadata and member lists
|
||||
pullConservatively({
|
||||
relays: [url],
|
||||
signal: controller.signal,
|
||||
filters: [relayFilter, roomsFilter],
|
||||
})
|
||||
|
||||
// Load historical data from up to a month ago for quick page loading
|
||||
pullConservatively({
|
||||
relays: [url],
|
||||
signal: controller.signal,
|
||||
filters: [MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map(assoc("since", ago(MONTH))),
|
||||
})
|
||||
|
||||
// Listen for new events
|
||||
request({
|
||||
relays: [url],
|
||||
signal: controller.signal,
|
||||
filters: [relayFilter, roomsFilter, MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map(
|
||||
assoc("since", now()),
|
||||
),
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
const syncMemberships = () => {
|
||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
||||
|
||||
const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => {
|
||||
// stop syncing removed spaces
|
||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
||||
if (!urls.includes(url)) {
|
||||
unsubscribersByUrl.delete(url)
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// Start syncing newly added spaces
|
||||
for (const url of urls) {
|
||||
if (!unsubscribersByUrl.has(url)) {
|
||||
unsubscribersByUrl.set(url, syncMembership(url))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
Array.from(unsubscribersByUrl.values()).forEach(call)
|
||||
unsubscribeSpaceUrls()
|
||||
}
|
||||
}
|
||||
|
||||
// Sync extra stuff for the current space
|
||||
|
||||
const syncSpace = (url: string) => {
|
||||
const $pubkey = pubkey.get()
|
||||
const controller = new AbortController()
|
||||
|
||||
// Load all membership changes for the current user
|
||||
if ($pubkey) {
|
||||
pullConservatively({
|
||||
relays: [url],
|
||||
signal: controller.signal,
|
||||
filters: [
|
||||
{
|
||||
kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
|
||||
"#p": [$pubkey],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Listen actively for all current membership changes, reports, reactions, zaps, etc
|
||||
request({
|
||||
relays: [url],
|
||||
signal: controller.signal,
|
||||
filters: [
|
||||
{
|
||||
kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, ...REACTION_KINDS],
|
||||
since: now(),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
const syncCurrentSpace = () => {
|
||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
||||
|
||||
// Sync the space the user is currently visiting
|
||||
const unsubscribePage = page.subscribe($page => {
|
||||
if ($page.params.relay) {
|
||||
const url = decodeRelay($page.params.relay)
|
||||
|
||||
if (!unsubscribersByUrl.has(url)) {
|
||||
unsubscribersByUrl.set(url, syncSpace(url))
|
||||
}
|
||||
|
||||
for (const [oldUrl, unsubscribe] of unsubscribersByUrl.entries()) {
|
||||
if (url !== oldUrl) {
|
||||
unsubscribersByUrl.delete(oldUrl)
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Array.from(unsubscribersByUrl.values()).forEach(call)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
Array.from(unsubscribersByUrl.values()).forEach(call)
|
||||
unsubscribePage()
|
||||
}
|
||||
}
|
||||
|
||||
// DMs
|
||||
|
||||
const syncDMRelay = (url: string, pubkey: string) => {
|
||||
const controller = new AbortController()
|
||||
|
||||
// Load historical data
|
||||
pullConservatively({
|
||||
relays: [url],
|
||||
signal: controller.signal,
|
||||
filters: [{kinds: [WRAP], "#p": [pubkey], until: ago(WEEK, 2)}],
|
||||
})
|
||||
|
||||
// Load new events
|
||||
request({
|
||||
relays: [url],
|
||||
signal: controller.signal,
|
||||
filters: [{kinds: [WRAP], "#p": [pubkey], since: ago(WEEK, 2)}],
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
const syncDMs = () => {
|
||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
||||
|
||||
let currentPubkey: string | undefined
|
||||
|
||||
const unsubscribeAll = () => {
|
||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
||||
unsubscribersByUrl.delete(url)
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
const subscribeAll = (pubkey: string, urls: string[]) => {
|
||||
// Start syncing newly added relays
|
||||
for (const url of urls) {
|
||||
if (!unsubscribersByUrl.has(url)) {
|
||||
unsubscribersByUrl.set(url, syncDMRelay(url, pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
// Stop syncing removed spaces
|
||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
||||
if (!urls.includes(url)) {
|
||||
unsubscribersByUrl.delete(url)
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When pubkey changes, re-sync
|
||||
const unsubscribePubkey = derived([pubkey, shouldUnwrap], identity).subscribe(
|
||||
([$pubkey, $shouldUnwrap]) => {
|
||||
if ($pubkey !== currentPubkey) {
|
||||
unsubscribeAll()
|
||||
}
|
||||
|
||||
// If we have a pubkey, refresh our user's relay selections then sync our subscriptions
|
||||
if ($pubkey && $shouldUnwrap) {
|
||||
loadRelaySelections($pubkey)
|
||||
.then(() => loadInboxRelaySelections($pubkey))
|
||||
.then($l => subscribeAll($pubkey, getRelayTagValues(getListTags($l))))
|
||||
}
|
||||
|
||||
currentPubkey = $pubkey
|
||||
},
|
||||
)
|
||||
|
||||
// When user inbox relays change, update synchronization
|
||||
const unsubscribeSelections = userInboxRelaySelections.subscribe($userInboxRelaySelections => {
|
||||
const $pubkey = pubkey.get()
|
||||
const $shouldUnwrap = shouldUnwrap.get()
|
||||
|
||||
if ($pubkey && $shouldUnwrap) {
|
||||
subscribeAll($pubkey, getRelayTagValues(getListTags($userInboxRelaySelections)))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribeAll()
|
||||
unsubscribePubkey()
|
||||
unsubscribeSelections()
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all synchronization functions
|
||||
|
||||
export const syncApplicationData = () => {
|
||||
const unsubscribers = [
|
||||
syncRelays(),
|
||||
syncUserData(),
|
||||
syncMemberships(),
|
||||
syncCurrentSpace(),
|
||||
syncDMs(),
|
||||
]
|
||||
|
||||
return () => unsubscribers.forEach(call)
|
||||
}
|
||||
+51
-2
@@ -1,13 +1,24 @@
|
||||
import {mount} from "svelte"
|
||||
import type {Writable} from "svelte/store"
|
||||
import {get} from "svelte/store"
|
||||
import {get, derived} from "svelte/store"
|
||||
import {Router} from "@welshman/router"
|
||||
import {profileSearch} from "@welshman/app"
|
||||
import {dec, inc} from "@welshman/lib"
|
||||
import {throttled} from "@welshman/store"
|
||||
import type {PublishedProfile} from "@welshman/util"
|
||||
import {
|
||||
createSearch,
|
||||
profiles,
|
||||
searchProfiles,
|
||||
handlesByNip05,
|
||||
maxWot,
|
||||
wotGraph,
|
||||
} from "@welshman/app"
|
||||
import type {FileAttributes} from "@welshman/editor"
|
||||
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor"
|
||||
import {makeMentionNodeView} from "@app/editor/MentionNodeView"
|
||||
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
|
||||
import {uploadFile} from "@app/core/commands"
|
||||
import {deriveSpaceMembers} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
export const makeEditor = async ({
|
||||
@@ -33,6 +44,43 @@ export const makeEditor = async ({
|
||||
uploading?: Writable<boolean>
|
||||
wordCount?: Writable<number>
|
||||
}) => {
|
||||
const profileSearch = derived(
|
||||
[
|
||||
throttled(800, profiles),
|
||||
throttled(800, handlesByNip05),
|
||||
throttled(800, deriveSpaceMembers(url || "")),
|
||||
],
|
||||
([$profiles, $handlesByNip05, $spaceMembers]) => {
|
||||
// Remove invalid nip05's from profiles
|
||||
const options = $profiles.map(p => {
|
||||
const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey
|
||||
|
||||
return isNip05Valid ? p : {...p, nip05: ""}
|
||||
})
|
||||
|
||||
return createSearch(options, {
|
||||
onSearch: searchProfiles,
|
||||
getValue: (profile: PublishedProfile) => profile.event.pubkey,
|
||||
sortFn: ({score = 1, item}) => {
|
||||
const wotScore = wotGraph.get().get(item.event.pubkey) || 0
|
||||
const membershipScale = $spaceMembers.includes(item.event.pubkey) ? 2 : 1
|
||||
|
||||
return dec(score) * inc(wotScore / maxWot.get()) * membershipScale
|
||||
},
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
"nip05",
|
||||
{name: "name", weight: 0.8},
|
||||
{name: "display_name", weight: 0.5},
|
||||
{name: "about", weight: 0.3},
|
||||
],
|
||||
threshold: 0.3,
|
||||
shouldSort: false,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
return new Editor({
|
||||
content,
|
||||
autofocus,
|
||||
@@ -73,6 +121,7 @@ export const makeEditor = async ({
|
||||
editor: (this as any).editor,
|
||||
search: (term: string) => get(profileSearch).searchValues(term),
|
||||
getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||
updateSignal: profileSearch,
|
||||
createSuggestion: (value: string) => {
|
||||
const target = document.createElement("div")
|
||||
|
||||
|
||||
@@ -11,10 +11,9 @@ w.plausible =
|
||||
;(w.plausible.q = w.plausible.q || []).push(arguments)
|
||||
}
|
||||
|
||||
export const setupAnalytics = () => {
|
||||
export const setupAnalytics = () =>
|
||||
page.subscribe($page => {
|
||||
if ($page.route && getSetting("report_usage")) {
|
||||
w.plausible("pageview", {u: $page.route.id})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import {page} from "$app/stores"
|
||||
|
||||
export const lastPageBySpaceUrl = new Map<string, string>()
|
||||
|
||||
export const setupHistory = () =>
|
||||
page.subscribe($page => {
|
||||
if ($page.params.relay) {
|
||||
lastPageBySpaceUrl.set($page.params.relay, $page.url.pathname)
|
||||
}
|
||||
})
|
||||
@@ -3,10 +3,11 @@ import {synced, throttled} from "@welshman/store"
|
||||
import {pubkey, relaysByUrl} from "@welshman/app"
|
||||
import {prop, spec, identity, now, groupBy} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
|
||||
import {ZAP_GOAL, EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
|
||||
import {
|
||||
makeSpacePath,
|
||||
makeChatPath,
|
||||
makeGoalPath,
|
||||
makeThreadPath,
|
||||
makeCalendarPath,
|
||||
makeSpaceChatPath,
|
||||
@@ -16,9 +17,11 @@ import {
|
||||
chats,
|
||||
hasNip29,
|
||||
getUrlsForEvent,
|
||||
userRoomsByUrl,
|
||||
repositoryStore,
|
||||
userSettingsValues,
|
||||
userGroupSelections,
|
||||
getSpaceUrlsFromGroupSelections,
|
||||
getSpaceRoomsFromGroupSelections,
|
||||
} from "@app/core/state"
|
||||
import {preferencesStorageProvider} from "@src/lib/storage"
|
||||
import {Badge} from "@capawesome/capacitor-badge"
|
||||
@@ -41,11 +44,19 @@ export const notifications = derived(
|
||||
throttled(
|
||||
1000,
|
||||
derived(
|
||||
[pubkey, checked, chats, userRoomsByUrl, repositoryStore, getUrlsForEvent, relaysByUrl],
|
||||
[pubkey, checked, chats, userGroupSelections, repositoryStore, getUrlsForEvent, relaysByUrl],
|
||||
identity,
|
||||
),
|
||||
),
|
||||
([$pubkey, $checked, $chats, $userRoomsByUrl, $repository, $getUrlsForEvent, $relaysByUrl]) => {
|
||||
([
|
||||
$pubkey,
|
||||
$checked,
|
||||
$chats,
|
||||
$userGroupSelections,
|
||||
$repository,
|
||||
$getUrlsForEvent,
|
||||
$relaysByUrl,
|
||||
]) => {
|
||||
const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => {
|
||||
if (!latestEvent || latestEvent.pubkey === $pubkey) {
|
||||
return false
|
||||
@@ -76,45 +87,63 @@ export const notifications = derived(
|
||||
}
|
||||
}
|
||||
|
||||
const allThreadEvents = $repository.query([
|
||||
{kinds: [THREAD]},
|
||||
{kinds: [COMMENT], "#K": [String(THREAD)]},
|
||||
])
|
||||
const allGoalComments = $repository.query([{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]}])
|
||||
|
||||
const allCalendarEvents = $repository.query([
|
||||
{kinds: [EVENT_TIME]},
|
||||
{kinds: [COMMENT], "#K": [String(EVENT_TIME)]},
|
||||
])
|
||||
const allThreadComments = $repository.query([{kinds: [COMMENT], "#K": [String(THREAD)]}])
|
||||
|
||||
const allMessageEvents = $repository.query([{kinds: [MESSAGE]}])
|
||||
const allCalendarComments = $repository.query([{kinds: [COMMENT], "#K": [String(EVENT_TIME)]}])
|
||||
|
||||
for (const [url, rooms] of $userRoomsByUrl.entries()) {
|
||||
const allMessages = $repository.query([{kinds: [MESSAGE, THREAD, ZAP_GOAL, EVENT_TIME]}])
|
||||
|
||||
for (const url of getSpaceUrlsFromGroupSelections($userGroupSelections)) {
|
||||
const spacePath = makeSpacePath(url)
|
||||
const spacePathMobile = spacePath + ":mobile"
|
||||
const goalPath = makeGoalPath(url)
|
||||
const threadPath = makeThreadPath(url)
|
||||
const calendarPath = makeCalendarPath(url)
|
||||
const messagesPath = makeSpaceChatPath(url)
|
||||
const threadEvents = allThreadEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||
const calendarEvents = allCalendarEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||
const messagesEvents = allMessageEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||
const goalComments = allGoalComments.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||
const threadComments = allThreadComments.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||
const calendarComments = allCalendarComments.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||
const messages = allMessages.filter(e => $getUrlsForEvent(e.id).includes(url))
|
||||
|
||||
if (hasNotification(threadPath, threadEvents[0])) {
|
||||
paths.add(spacePath)
|
||||
paths.add(threadPath)
|
||||
}
|
||||
const commentsByGoalId = groupBy(
|
||||
e => getTagValue("E", e.tags),
|
||||
goalComments.filter(spec({kind: COMMENT})),
|
||||
)
|
||||
|
||||
if (hasNotification(calendarPath, calendarEvents[0])) {
|
||||
paths.add(spacePath)
|
||||
paths.add(calendarPath)
|
||||
for (const [goalId, [comment]] of commentsByGoalId.entries()) {
|
||||
const goalItemPath = makeGoalPath(url, goalId)
|
||||
|
||||
if (hasNotification(spacePathMobile, comment)) {
|
||||
paths.add(spacePathMobile)
|
||||
}
|
||||
|
||||
if (hasNotification(goalPath, comment)) {
|
||||
paths.add(goalPath)
|
||||
}
|
||||
|
||||
if (hasNotification(goalItemPath, comment)) {
|
||||
paths.add(goalItemPath)
|
||||
}
|
||||
}
|
||||
|
||||
const commentsByThreadId = groupBy(
|
||||
e => getTagValue("E", e.tags),
|
||||
threadEvents.filter(spec({kind: COMMENT})),
|
||||
threadComments.filter(spec({kind: COMMENT})),
|
||||
)
|
||||
|
||||
for (const [threadId, [comment]] of commentsByThreadId.entries()) {
|
||||
const threadItemPath = makeThreadPath(url, threadId)
|
||||
|
||||
if (hasNotification(spacePathMobile, comment)) {
|
||||
paths.add(spacePathMobile)
|
||||
}
|
||||
|
||||
if (hasNotification(threadPath, comment)) {
|
||||
paths.add(threadPath)
|
||||
}
|
||||
|
||||
if (hasNotification(threadItemPath, comment)) {
|
||||
paths.add(threadItemPath)
|
||||
}
|
||||
@@ -122,33 +151,41 @@ export const notifications = derived(
|
||||
|
||||
const commentsByEventId = groupBy(
|
||||
e => getTagValue("E", e.tags),
|
||||
calendarEvents.filter(spec({kind: COMMENT})),
|
||||
calendarComments.filter(spec({kind: COMMENT})),
|
||||
)
|
||||
|
||||
for (const [eventId, [comment]] of commentsByEventId.entries()) {
|
||||
const calendarEventPath = makeCalendarPath(url, eventId)
|
||||
const calendarItemPath = makeCalendarPath(url, eventId)
|
||||
|
||||
if (hasNotification(calendarEventPath, comment)) {
|
||||
paths.add(calendarEventPath)
|
||||
if (hasNotification(spacePathMobile, comment)) {
|
||||
paths.add(spacePathMobile)
|
||||
}
|
||||
|
||||
if (hasNotification(calendarPath, comment)) {
|
||||
paths.add(calendarPath)
|
||||
}
|
||||
|
||||
if (hasNotification(calendarItemPath, comment)) {
|
||||
paths.add(calendarItemPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNip29($relaysByUrl.get(url))) {
|
||||
for (const room of rooms) {
|
||||
for (const room of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) {
|
||||
const roomPath = makeRoomPath(url, room)
|
||||
const latestEvent = allMessageEvents.find(
|
||||
e =>
|
||||
$getUrlsForEvent(e.id).includes(url) &&
|
||||
e.tags.find(t => t[0] === "h" && t[1] === room),
|
||||
const latestEvent = allMessages.find(
|
||||
e => $getUrlsForEvent(e.id).includes(url) && e.tags.find(spec(["h", room])),
|
||||
)
|
||||
|
||||
if (hasNotification(roomPath, latestEvent)) {
|
||||
paths.add(spacePathMobile)
|
||||
paths.add(spacePath)
|
||||
paths.add(roomPath)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (hasNotification(messagesPath, messagesEvents[0])) {
|
||||
if (hasNotification(messagesPath, messages[0])) {
|
||||
paths.add(spacePathMobile)
|
||||
paths.add(spacePath)
|
||||
paths.add(messagesPath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import {on, call, dissoc, assoc, uniq} from "@welshman/lib"
|
||||
import type {StampedEvent} from "@welshman/util"
|
||||
import type {Socket, RelayMessage, ClientMessage} from "@welshman/net"
|
||||
import {
|
||||
makeSocketPolicyAuth,
|
||||
SocketEvent,
|
||||
isRelayEvent,
|
||||
isRelayOk,
|
||||
isRelayClosed,
|
||||
isClientReq,
|
||||
isClientEvent,
|
||||
isClientClose,
|
||||
} from "@welshman/net"
|
||||
import {signer} from "@welshman/app"
|
||||
import {
|
||||
userSettingsValues,
|
||||
getSetting,
|
||||
relaysPendingTrust,
|
||||
relaysMostlyRestricted,
|
||||
} from "@app/core/state"
|
||||
|
||||
export const authPolicy = makeSocketPolicyAuth({
|
||||
sign: (event: StampedEvent) => signer.get()?.sign(event),
|
||||
shouldAuth: (socket: Socket) => true,
|
||||
})
|
||||
|
||||
export const trustPolicy = (socket: Socket) => {
|
||||
const buffer: RelayMessage[] = []
|
||||
|
||||
const unsubscribers = [
|
||||
// When the socket goes from untrusted to trusted, receive all buffered messages
|
||||
userSettingsValues.subscribe($settings => {
|
||||
if ($settings.trusted_relays.includes(socket.url)) {
|
||||
for (const message of buffer.splice(0)) {
|
||||
socket._recvQueue.push(message)
|
||||
}
|
||||
}
|
||||
}),
|
||||
// When we get an event with no signature from an untrusted relay, remove it from
|
||||
// the receive queue. If trust status is undefined, buffer it for later.
|
||||
on(socket, SocketEvent.Receiving, (message: RelayMessage) => {
|
||||
if (isRelayEvent(message) && !message[2]?.sig) {
|
||||
const isTrusted = getSetting<string[]>("trusted_relays").includes(socket.url)
|
||||
|
||||
if (!isTrusted) {
|
||||
buffer.push(message)
|
||||
socket._recvQueue.remove(message)
|
||||
relaysPendingTrust.update($r => uniq([...$r, socket.url]))
|
||||
}
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach(call)
|
||||
}
|
||||
}
|
||||
|
||||
export const mostlyRestrictedPolicy = (socket: Socket) => {
|
||||
let total = 0
|
||||
let restricted = 0
|
||||
let error = ""
|
||||
|
||||
const pending = new Set<string>()
|
||||
|
||||
const updateStatus = () =>
|
||||
relaysMostlyRestricted.update(
|
||||
restricted > total / 2 ? assoc(socket.url, error) : dissoc(socket.url),
|
||||
)
|
||||
|
||||
const unsubscribers = [
|
||||
on(socket, SocketEvent.Receive, (message: RelayMessage) => {
|
||||
if (isRelayOk(message)) {
|
||||
const [_, id, ok, details = ""] = message
|
||||
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id)
|
||||
|
||||
if (!ok && details.startsWith("restricted: ")) {
|
||||
restricted++
|
||||
error = details
|
||||
updateStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isRelayClosed(message)) {
|
||||
const [_, id, details = ""] = message
|
||||
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id)
|
||||
|
||||
if (details.startsWith("restricted: ")) {
|
||||
restricted++
|
||||
error = details
|
||||
updateStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
on(socket, SocketEvent.Send, (message: ClientMessage) => {
|
||||
if (isClientReq(message)) {
|
||||
total++
|
||||
pending.add(message[1])
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
if (isClientEvent(message)) {
|
||||
total++
|
||||
pending.add(message[1].id)
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
if (isClientClose(message)) {
|
||||
pending.delete(message[1])
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach(call)
|
||||
}
|
||||
}
|
||||
+32
-16
@@ -1,9 +1,10 @@
|
||||
import type {Page} from "@sveltejs/kit"
|
||||
import {get} from "svelte/store"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {goto} from "$app/navigation"
|
||||
import {nthEq, sleep} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {tracker} from "@welshman/app"
|
||||
import {tracker, relaysByUrl} from "@welshman/app"
|
||||
import {scrollToEvent} from "@lib/html"
|
||||
import {identity} from "@welshman/lib"
|
||||
import {
|
||||
@@ -17,12 +18,12 @@ import {
|
||||
getPubkeyTagValues,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
ensureUnwrapped,
|
||||
makeChatId,
|
||||
entityLink,
|
||||
decodeRelay,
|
||||
encodeRelay,
|
||||
userRoomsByUrl,
|
||||
userSpaceUrls,
|
||||
hasNip29,
|
||||
ROOM,
|
||||
} from "@app/core/state"
|
||||
|
||||
@@ -36,6 +37,14 @@ export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) =>
|
||||
.filter(identity)
|
||||
.map(s => encodeURIComponent(s as string))
|
||||
.join("/")
|
||||
} else {
|
||||
const relay = relaysByUrl.get().get(url)
|
||||
|
||||
if (hasNip29(relay)) {
|
||||
path += "/recent"
|
||||
} else {
|
||||
path += "/chat"
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
@@ -58,7 +67,7 @@ export const makeCalendarPath = (url: string, eventId?: string) =>
|
||||
export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
|
||||
|
||||
export const getPrimaryNavItemIndex = ($page: Page) => {
|
||||
const urls = Array.from(userRoomsByUrl.get().keys())
|
||||
const urls = get(userSpaceUrls)
|
||||
|
||||
switch (getPrimaryNavItem($page)) {
|
||||
case "discover":
|
||||
@@ -76,20 +85,16 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
|
||||
}
|
||||
|
||||
export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
|
||||
const unwrapped = await ensureUnwrapped(event)
|
||||
const urls = Array.from(tracker.getRelays(event.id))
|
||||
const path = await getEventPath(event, urls)
|
||||
|
||||
if (unwrapped) {
|
||||
const urls = Array.from(tracker.getRelays(unwrapped.id))
|
||||
const path = await getEventPath(unwrapped, urls)
|
||||
if (path.includes("://")) {
|
||||
window.open(path)
|
||||
} else {
|
||||
goto(path, options)
|
||||
|
||||
if (path.includes("://")) {
|
||||
window.open(path)
|
||||
} else {
|
||||
goto(path, options)
|
||||
|
||||
await sleep(300)
|
||||
await scrollToEvent(unwrapped.id)
|
||||
}
|
||||
await sleep(300)
|
||||
await scrollToEvent(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,3 +148,14 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
|
||||
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
|
||||
}
|
||||
|
||||
export const getChannelItemPath = (url: string, event: TrustedEvent) => {
|
||||
switch (event.kind) {
|
||||
case THREAD:
|
||||
return makeThreadPath(url, event.id)
|
||||
case ZAP_GOAL:
|
||||
return makeGoalPath(url, event.id)
|
||||
case EVENT_TIME:
|
||||
return makeCalendarPath(url, event.id)
|
||||
}
|
||||
}
|
||||
|
||||
+110
-134
@@ -1,58 +1,56 @@
|
||||
import {
|
||||
always,
|
||||
on,
|
||||
hash,
|
||||
last,
|
||||
groupBy,
|
||||
throttle,
|
||||
fromPairs,
|
||||
batch,
|
||||
sortBy,
|
||||
concat,
|
||||
} from "@welshman/lib"
|
||||
import {prop, call, on, throttle, fromPairs, batch} from "@welshman/lib"
|
||||
import {throttled, freshness} from "@welshman/store"
|
||||
import {
|
||||
PROFILE,
|
||||
FOLLOWS,
|
||||
MUTES,
|
||||
RELAYS,
|
||||
BLOSSOM_SERVERS,
|
||||
INBOX_RELAYS,
|
||||
ROOMS,
|
||||
APP_DATA,
|
||||
ALERT_STATUS,
|
||||
ALERT_EMAIL,
|
||||
ALERT_WEB,
|
||||
ALERT_IOS,
|
||||
ALERT_ANDROID,
|
||||
EVENT_TIME,
|
||||
THREAD,
|
||||
MESSAGE,
|
||||
DIRECT_MESSAGE,
|
||||
ALERT_EMAIL,
|
||||
ALERT_IOS,
|
||||
ALERT_STATUS,
|
||||
ALERT_WEB,
|
||||
APP_DATA,
|
||||
BLOSSOM_SERVERS,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
DIRECT_MESSAGE,
|
||||
EVENT_TIME,
|
||||
FOLLOWS,
|
||||
INBOX_RELAYS,
|
||||
MESSAGE,
|
||||
MUTES,
|
||||
PROFILE,
|
||||
RELAY_ADD_MEMBER,
|
||||
RELAY_JOIN,
|
||||
RELAY_LEAVE,
|
||||
RELAY_MEMBERS,
|
||||
RELAY_REMOVE_MEMBER,
|
||||
RELAYS,
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_CREATE_PERMISSION,
|
||||
ROOM_MEMBERS,
|
||||
ROOM_META,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOMS,
|
||||
THREAD,
|
||||
ZAP_GOAL,
|
||||
verifiedSymbol,
|
||||
} from "@welshman/util"
|
||||
import type {Zapper, TrustedEvent} from "@welshman/util"
|
||||
import type {RepositoryUpdate} from "@welshman/relay"
|
||||
import type {Handle, Relay} from "@welshman/app"
|
||||
import type {Zapper, TrustedEvent, RelayProfile} from "@welshman/util"
|
||||
import type {RepositoryUpdate, WrapItem} from "@welshman/net"
|
||||
import type {Handle, RelayStats} from "@welshman/app"
|
||||
import {
|
||||
plaintext,
|
||||
tracker,
|
||||
relays,
|
||||
relayStats,
|
||||
repository,
|
||||
handles,
|
||||
zappers,
|
||||
onZapper,
|
||||
onHandle,
|
||||
wrapManager,
|
||||
} from "@welshman/app"
|
||||
import {Collection} from "@lib/storage"
|
||||
|
||||
const syncEvents = async () => {
|
||||
const collection = new Collection<TrustedEvent>({
|
||||
table: "events",
|
||||
shards: Array.from("0123456789abcdef"),
|
||||
getShard: (event: TrustedEvent) => last(event.id),
|
||||
})
|
||||
const collection = new Collection<TrustedEvent>({table: "events", getId: prop("id")})
|
||||
|
||||
const initialEvents = await collection.get()
|
||||
|
||||
@@ -63,89 +61,57 @@ const syncEvents = async () => {
|
||||
|
||||
repository.load(initialEvents)
|
||||
|
||||
const metaKinds = [
|
||||
PROFILE,
|
||||
FOLLOWS,
|
||||
MUTES,
|
||||
RELAYS,
|
||||
BLOSSOM_SERVERS,
|
||||
INBOX_RELAYS,
|
||||
APP_DATA,
|
||||
ROOMS,
|
||||
]
|
||||
const alertKinds = [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]
|
||||
const spaceKinds = [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE]
|
||||
const roomKinds = [
|
||||
ROOM_META,
|
||||
ROOM_MEMBERS,
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOM_CREATE_PERMISSION,
|
||||
]
|
||||
const contentKinds = [EVENT_TIME, THREAD, MESSAGE, ZAP_GOAL, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]
|
||||
|
||||
const rankEvent = (event: TrustedEvent) => {
|
||||
switch (event.kind) {
|
||||
case PROFILE:
|
||||
return 1
|
||||
case FOLLOWS:
|
||||
return 1
|
||||
case MUTES:
|
||||
return 1
|
||||
case RELAYS:
|
||||
return 1
|
||||
case BLOSSOM_SERVERS:
|
||||
return 1
|
||||
case INBOX_RELAYS:
|
||||
return 1
|
||||
case ROOMS:
|
||||
return 1
|
||||
case APP_DATA:
|
||||
return 1
|
||||
case ALERT_STATUS:
|
||||
return 1
|
||||
case ALERT_EMAIL:
|
||||
return 1
|
||||
case ALERT_WEB:
|
||||
return 1
|
||||
case ALERT_IOS:
|
||||
return 1
|
||||
case ALERT_ANDROID:
|
||||
return 1
|
||||
case EVENT_TIME:
|
||||
return 0.9
|
||||
case THREAD:
|
||||
return 0.9
|
||||
case MESSAGE:
|
||||
return 0.9
|
||||
case DIRECT_MESSAGE:
|
||||
return 0.9
|
||||
case DIRECT_MESSAGE_FILE:
|
||||
return 0.9
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
if (metaKinds.includes(event.kind)) return 9
|
||||
if (alertKinds.includes(event.kind)) return 8
|
||||
if (spaceKinds.includes(event.kind)) return 7
|
||||
if (roomKinds.includes(event.kind)) return 6
|
||||
if (contentKinds.includes(event.kind)) return 5
|
||||
return 0
|
||||
}
|
||||
|
||||
return on(
|
||||
repository,
|
||||
"update",
|
||||
batch(3000, async (updates: RepositoryUpdate[]) => {
|
||||
let added: TrustedEvent[] = []
|
||||
const removed = new Set<string>()
|
||||
const add: TrustedEvent[] = []
|
||||
const remove = new Set<string>()
|
||||
|
||||
for (const update of updates) {
|
||||
for (const event of update.added) {
|
||||
if (rankEvent(event) > 0) {
|
||||
added.push(event)
|
||||
removed.delete(event.id)
|
||||
add.push(event)
|
||||
remove.delete(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of update.removed) {
|
||||
removed.add(id)
|
||||
remove.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.size > 0) {
|
||||
added = added.filter(e => !removed.has(e.id))
|
||||
|
||||
const removedByShard = groupBy(id => last(id), removed)
|
||||
const addedByShard = groupBy(e => last(e.id), added)
|
||||
const shards = new Set([...removedByShard.keys(), ...addedByShard.keys()])
|
||||
|
||||
for (const shard of shards) {
|
||||
const removedInShard = removedByShard.get(shard)
|
||||
const addedInShard = addedByShard.get(shard) || []
|
||||
const current = await collection.getShard(shard)
|
||||
const filtered = current.filter(e => !removedInShard?.includes(e.id))
|
||||
const sorted = sortBy(e => -rankEvent(e), concat(filtered, addedInShard))
|
||||
const pruned = sorted.slice(0, 10_000)
|
||||
|
||||
await collection.setShard(shard, pruned)
|
||||
}
|
||||
} else if (added.length > 0) {
|
||||
await collection.add(added)
|
||||
}
|
||||
await collection.update({add, remove})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -155,8 +121,7 @@ type TrackerItem = [string, string[]]
|
||||
const syncTracker = async () => {
|
||||
const collection = new Collection<TrackerItem>({
|
||||
table: "tracker",
|
||||
shards: Array.from("0123456789abcdef"),
|
||||
getShard: (item: TrackerItem) => last(item[0]),
|
||||
getId: (item: TrackerItem) => item[0],
|
||||
})
|
||||
|
||||
const relaysById = new Map<string, Set<string>>()
|
||||
@@ -167,20 +132,14 @@ const syncTracker = async () => {
|
||||
|
||||
tracker.load(relaysById)
|
||||
|
||||
let p = Promise.resolve()
|
||||
|
||||
const updateOne = batch(3000, (ids: string[]) => {
|
||||
p = p.then(() => {
|
||||
collection.add(ids.map(id => [id, Array.from(tracker.getRelays(id))]))
|
||||
})
|
||||
collection.add(ids.map(id => [id, Array.from(tracker.getRelays(id))]))
|
||||
})
|
||||
|
||||
const updateAll = throttle(3000, () => {
|
||||
p = p.then(() => {
|
||||
collection.set(
|
||||
Array.from(tracker.relaysById.entries()).map(([id, relays]) => [id, Array.from(relays)]),
|
||||
)
|
||||
})
|
||||
collection.set(
|
||||
Array.from(tracker.relaysById.entries()).map(([id, relays]) => [id, Array.from(relays)]),
|
||||
)
|
||||
})
|
||||
|
||||
tracker.on("add", updateOne)
|
||||
@@ -197,23 +156,23 @@ const syncTracker = async () => {
|
||||
}
|
||||
|
||||
const syncRelays = async () => {
|
||||
const collection = new Collection<Relay>({
|
||||
table: "relays",
|
||||
shards: Array.from("0123456789"),
|
||||
getShard: (item: Relay) => last(hash(item.url)),
|
||||
})
|
||||
const collection = new Collection<RelayProfile>({table: "relays", getId: prop("url")})
|
||||
|
||||
relays.set(await collection.get())
|
||||
|
||||
return throttled(3000, relays).subscribe(collection.set)
|
||||
}
|
||||
|
||||
const syncRelayStats = async () => {
|
||||
const collection = new Collection<RelayStats>({table: "relayStats", getId: prop("url")})
|
||||
|
||||
relayStats.set(await collection.get())
|
||||
|
||||
return throttled(3000, relayStats).subscribe(collection.set)
|
||||
}
|
||||
|
||||
const syncHandles = async () => {
|
||||
const collection = new Collection<Handle>({
|
||||
table: "handles",
|
||||
shards: Array.from("0123456789"),
|
||||
getShard: (item: Handle) => last(hash(item.nip05)),
|
||||
})
|
||||
const collection = new Collection<Handle>({table: "handles", getId: prop("nip05")})
|
||||
|
||||
handles.set(await collection.get())
|
||||
|
||||
@@ -221,11 +180,7 @@ const syncHandles = async () => {
|
||||
}
|
||||
|
||||
const syncZappers = async () => {
|
||||
const collection = new Collection<Zapper>({
|
||||
table: "zappers",
|
||||
shards: Array.from("0123456789"),
|
||||
getShard: (item: Zapper) => last(hash(item.lnurl)),
|
||||
})
|
||||
const collection = new Collection<Zapper>({table: "zappers", getId: prop("lnurl")})
|
||||
|
||||
zappers.set(await collection.get())
|
||||
|
||||
@@ -237,8 +192,7 @@ type FreshnessItem = [string, number]
|
||||
const syncFreshness = async () => {
|
||||
const collection = new Collection<FreshnessItem>({
|
||||
table: "freshness",
|
||||
shards: ["0"],
|
||||
getShard: always("0"),
|
||||
getId: (item: FreshnessItem) => item[0],
|
||||
})
|
||||
|
||||
freshness.set(fromPairs(await collection.get()))
|
||||
@@ -253,8 +207,7 @@ type PlaintextItem = [string, string]
|
||||
const syncPlaintext = async () => {
|
||||
const collection = new Collection<PlaintextItem>({
|
||||
table: "plaintext",
|
||||
shards: ["0"],
|
||||
getShard: always("0"),
|
||||
getId: (item: PlaintextItem) => item[0],
|
||||
})
|
||||
|
||||
plaintext.set(fromPairs(await collection.get()))
|
||||
@@ -264,13 +217,36 @@ const syncPlaintext = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const syncDataStores = () =>
|
||||
Promise.all([
|
||||
const syncWrapManager = async () => {
|
||||
const collection = new Collection<WrapItem>({table: "wraps", getId: prop("id")})
|
||||
|
||||
wrapManager.load(await collection.get())
|
||||
|
||||
const addOne = batch(3000, (wrapItems: WrapItem[]) => collection.add(wrapItems))
|
||||
|
||||
const updateAll = throttle(3000, () => collection.set(wrapManager.dump()))
|
||||
|
||||
wrapManager.on("add", addOne)
|
||||
wrapManager.on("remove", updateAll)
|
||||
|
||||
return () => {
|
||||
wrapManager.off("add", addOne)
|
||||
wrapManager.off("remove", updateAll)
|
||||
}
|
||||
}
|
||||
|
||||
export const syncDataStores = async () => {
|
||||
const unsubscribers = await Promise.all([
|
||||
syncEvents(),
|
||||
syncTracker(),
|
||||
syncRelays(),
|
||||
syncRelayStats(),
|
||||
syncHandles(),
|
||||
syncZappers(),
|
||||
syncFreshness(),
|
||||
syncPlaintext(),
|
||||
syncWrapManager(),
|
||||
])
|
||||
|
||||
return () => unsubscribers.forEach(call)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user