Compare commits

...

46 Commits

Author SHA1 Message Date
Jon Staab 7bae956ffa Release 1.6.2 2025-12-08 09:22:49 -08:00
Jon Staab a2f59a5b1b Fix some modal bugs 2025-12-08 09:19:41 -08:00
Jon Staab df56af9b0e Bump version 2025-12-05 09:51:15 -08:00
Jon Staab 83f7f9584f Fix duplicate rooms 2025-12-04 17:06:50 -08:00
Jon Staab a2d440e54f Fix dialog z index 2025-12-04 16:01:39 -08:00
Jon Staab 4132e8449b Fix recent missing events in feeds 2025-12-04 15:56:05 -08:00
Jon Staab ee444416e4 Fall back to file name as hash for images 2025-12-04 14:37:59 -08:00
Jon Staab 10c12c3c48 Improve time based chat partitioning 2025-12-04 14:29:12 -08:00
Jon Staab db3775ae99 Fix timezone parsing in AlertAdd 2025-12-04 11:20:54 -08:00
Jon Staab 393acce884 Fix removing non-normalized urls 2025-12-02 17:27:14 -08:00
Jon Staab 68fe663730 Fix chat content bottom offset when keyboard is open 2025-12-02 17:20:10 -08:00
Jon Staab f65a4b0db0 Handle relay urls in content and link within the app 2025-12-02 17:09:56 -08:00
Jon Staab cdfb502e6e Fix skinny profile circles 2025-12-02 13:49:04 -08:00
Jon Staab 1a2c83e49b Bump version 2025-12-02 13:38:03 -08:00
Jon Staab e6c7a675a9 Bump welshman 2025-12-02 13:24:43 -08:00
Jon Staab 69c04f29f4 Tweak zap button 2025-12-02 09:31:38 -08:00
Jon Staab 04c6f9b4fe Add date to chats 2025-12-01 11:09:26 -08:00
Jon Staab 86ec12a9db Tweak some mobile menu components 2025-12-01 11:04:11 -08:00
Jon Staab 72b3111c64 Refine sync 2025-12-01 10:56:37 -08:00
Jon Staab 6709c91779 Fix discover social proof 2025-12-01 10:26:43 -08:00
Jon Staab bb6e7495f5 Add editor props from nostr-editor 2025-12-01 08:45:35 -08:00
Jon Staab df17929681 Fix new messages indicator 2025-11-25 17:06:21 -08:00
Jon Staab e083719ceb Hide nav when keyboard is open 2025-11-25 15:43:21 -08:00
Jon Staab bfdc69f18c Fix chats 2025-11-25 15:05:45 -08:00
Jon Staab e7ae20afb7 Fix content type nav items 2025-11-25 14:13:28 -08:00
Jon Staab 229d92055f Debounce search 2025-11-25 11:55:32 -08:00
Jon Staab 64c77cfd13 Migrate to new welshman stores 2025-11-21 12:40:59 -08:00
Jon Staab 3a63894562 Switch wording to messaging from inbox 2025-11-20 15:12:16 -08:00
Jon Staab 1d272f8b37 Tweak nav icon size 2025-11-14 15:02:23 -08:00
Jon Staab bac433b640 Re-work storage adapter a bit 2025-11-14 14:59:27 -08:00
Jon Staab 62f573eac0 Merge report detail components 2025-11-14 11:36:51 -08:00
Jon Staab b3ea62c53c Remove landlubber link 2025-11-13 17:01:23 -08:00
Jon Staab b0731503a8 Fix indexeddb deletes 2025-11-13 16:39:44 -08:00
Jon Staab 2421c02c24 Add room membership management 2025-11-13 15:25:18 -08:00
Jon Staab 25e868118d Slight optimization 2025-11-13 14:40:02 -08:00
Jon Staab 2880044e0e Add event admin deletion 2025-11-13 14:25:59 -08:00
Jon Staab 5300404b46 Add option to ban users from profile detail dialog 2025-11-13 13:44:52 -08:00
Jon Staab d949d58076 Add space membership management 2025-11-13 13:25:34 -08:00
Jon Staab 997b223e95 Rename space menu components 2025-11-13 10:36:28 -08:00
Jon Staab ba52a97e26 Tweak relay icon size in nav 2025-11-13 10:34:02 -08:00
Jon Staab cc4c7b5fe9 Fix image modal, only show + room if the user is allowed 2025-11-13 10:32:37 -08:00
Jon Staab 8e2ebd11fc remove some alts 2025-11-13 08:59:32 -08:00
Jon Staab 9cae4da9f4 Add lightning invoice payments 2025-11-12 16:24:58 -08:00
Jon Staab c05d7e99e2 remove old icon picker 2025-11-12 14:56:14 -08:00
Jon Staab 2390599e8f Fix relay updating and relay icons 2025-11-11 17:48:24 -08:00
Jon Staab 1a4d45fa9c Upload svgs for room icon 2025-11-11 17:34:15 -08:00
101 changed files with 2772 additions and 2802 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog # Changelog
# 1.6.2
* Fix modal scrolling and style
# 1.6.1
* Fix skinny profile images
* Custom handler for relay urls
* Improve time based chat partitioning
* Improve authenticated image access interop
* Fix image detail dialog
* Fix zapper loading
* Fix recent events missing in feeds
# 1.6.0
* Switch back to indexeddb to fix memory and performance
* Add pay invoice functionality
* Add space membership management and bans
* Add event info to profile dialog
* Add better room membership management
* Refactor stores for performance
* Hide nav when keyboard is open
* Handle flotilla links in-app
* Fix new messages indicator z-index
* Fix some display bugs
* Add date to chat items
* Refine data synchronization
* Hide nav when keyboard is open on mobile
# 1.5.3 # 1.5.3
* Add space edit form * Add space edit form
+34
View File
@@ -0,0 +1,34 @@
# Flotilla - AI Assistant Context
## Project Overview
Flotilla is a Discord-like Nostr client based on the concept of "relays as groups". It's built with SvelteKit, TypeScript, and Capacitor for cross-platform support (web, Android, iOS).
On boot, please run `tree -I assets src` to get an idea of the project structure.
## Key Dependencies
`@welshman/*` libraries contain the majority of nostr-related functionality.
`@app/core/*` contains additional app-specific data stores and commands.
When creating an import statement, first identify what functionality you need. Search the codebase for components with similar functionality, and imitate their imports.
## Dependency Graph (Acyclic)
The project follows a strict dependency hierarchy:
1. **External libraries** (bottom layer)
2. **`lib/`** - Only depends on external libraries
3. **`app/core/`** and **`app/util/`** - Can depend on `lib` only
4. **`app/components/`** - Can depend on anything in `app` or `lib`
5. **`routes/`** - Can depend on anything (top layer)
**Import Ordering Convention:** Always sort imports by dependency level:
1. Third-party libraries first
2. Then `lib` imports
3. Then `app` imports
## Development Conventions
When creating components related to a given space or room, parameterize them only with the entity's identifier (i.e., `url` and `h`). Only pass additional props if they can't be derived from the identifiers. For example, a room's `members` should be derived inside the child component, not passed in by the parent.
Do not use null, only undefined.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "social.flotilla" applicationId "social.flotilla"
minSdk rootProject.ext.minSdkVersion minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion targetSdk rootProject.ext.targetSdkVersion
versionCode 34 versionCode 38
versionName "1.5.3" versionName "1.6.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+4 -4
View File
@@ -358,14 +358,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25; CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.5.3; MARKETING_VERSION = 1.6.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -384,14 +384,14 @@
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements"; CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25; CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = S26U9DYW3A; DEVELOPMENT_TEAM = S26U9DYW3A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat"; INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.5.3; MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla; PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
+12 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "1.5.3", "version": "1.6.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -51,6 +51,7 @@
"@capacitor/push-notifications": "^7.0.3", "@capacitor/push-notifications": "^7.0.3",
"@capawesome/capacitor-android-dark-mode-support": "^7.0.0", "@capawesome/capacitor-android-dark-mode-support": "^7.0.0",
"@capawesome/capacitor-badge": "^7.0.1", "@capawesome/capacitor-badge": "^7.0.1",
"@getalby/lightning-tools": "^6.0.0",
"@getalby/sdk": "^5.1.2", "@getalby/sdk": "^5.1.2",
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.55.0", "@sentry/browser": "^8.55.0",
@@ -60,16 +61,16 @@
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.8", "@vite-pwa/sveltekit": "^0.6.8",
"@welshman/app": "^0.6.8", "@welshman/app": "^0.7.1",
"@welshman/content": "^0.6.8", "@welshman/content": "^0.7.1",
"@welshman/editor": "^0.6.8", "@welshman/editor": "^0.7.1",
"@welshman/feeds": "^0.6.8", "@welshman/feeds": "^0.7.1",
"@welshman/lib": "^0.6.8", "@welshman/lib": "^0.7.1",
"@welshman/net": "^0.6.8", "@welshman/net": "^0.7.1",
"@welshman/router": "^0.6.8", "@welshman/router": "^0.7.1",
"@welshman/signer": "^0.6.8", "@welshman/signer": "^0.7.1",
"@welshman/store": "^0.6.8", "@welshman/store": "^0.7.1",
"@welshman/util": "^0.6.8", "@welshman/util": "^0.7.1",
"compressorjs": "^1.2.1", "compressorjs": "^1.2.1",
"daisyui": "^4.12.24", "daisyui": "^4.12.24",
"date-picker-svelte": "^2.16.0", "date-picker-svelte": "^2.16.0",
+142 -78
View File
@@ -44,6 +44,9 @@ importers:
'@capawesome/capacitor-badge': '@capawesome/capacitor-badge':
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1(@capacitor/core@7.4.3) version: 7.0.1(@capacitor/core@7.4.3)
'@getalby/lightning-tools':
specifier: ^6.0.0
version: 6.0.0
'@getalby/sdk': '@getalby/sdk':
specifier: ^5.1.2 specifier: ^5.1.2
version: 5.1.2(typescript@5.9.3) version: 5.1.2(typescript@5.9.3)
@@ -72,35 +75,35 @@ importers:
specifier: ^0.6.8 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)) 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': '@welshman/app':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3) version: 0.7.1(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/content': '@welshman/content':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8(typescript@5.9.3) version: 0.7.1(typescript@5.9.3)
'@welshman/editor': '@welshman/editor':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8(@tiptap/extension-image@2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.27.1(@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) version: 0.7.1(@tiptap/extension-image@2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.27.1(@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': '@welshman/feeds':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3) version: 0.7.1(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/lib': '@welshman/lib':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8 version: 0.7.1
'@welshman/net': '@welshman/net':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8(typescript@5.9.3)(ws@8.18.3) version: 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/router': '@welshman/router':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8(typescript@5.9.3)(ws@8.18.3) version: 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/signer': '@welshman/signer':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3) version: 0.7.1(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/store': '@welshman/store':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8(typescript@5.9.3)(ws@8.18.3) version: 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': '@welshman/util':
specifier: ^0.6.8 specifier: ^0.7.1
version: 0.6.8(typescript@5.9.3) version: 0.7.1(typescript@5.9.3)
compressorjs: compressorjs:
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1 version: 1.2.1
@@ -986,6 +989,10 @@ packages:
resolution: {integrity: sha512-dxOmJLJAh6qJ8rsbA5/Bwj7MSI9X3RkxxqmCedl5rfP+yKwNSdfu8i4EiCZN/tk2hNBJb8GHSCcPRNZfwfmEHg==} resolution: {integrity: sha512-dxOmJLJAh6qJ8rsbA5/Bwj7MSI9X3RkxxqmCedl5rfP+yKwNSdfu8i4EiCZN/tk2hNBJb8GHSCcPRNZfwfmEHg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@getalby/lightning-tools@6.0.0':
resolution: {integrity: sha512-jpTO+7o1N1KhV5qT6qetPK+et6ZQshCzUMCRV8+Ek1NVlVU4ITIqOWRQ3kOrb0PhSxkbGN5G3d60HCi535hbDw==}
engines: {node: '>=14'}
'@getalby/sdk@5.1.2': '@getalby/sdk@5.1.2':
resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==} resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1092,6 +1099,15 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@jsr/fiatjaf__promenade-trusted-dealer@0.4.1':
resolution: {integrity: sha512-K9WjpDkQGyLl5gUZBLr3Gb+b5b1r8miZmDOo4+ZlzGQgoXD2TaqT+dkBjL/yLj/pYwBcd1Bschv0xuNpguL2ZQ==, tarball: https://npm.jsr.io/~/11/@jsr/fiatjaf__promenade-trusted-dealer/0.4.1.tgz}
'@jsr/henrygd__semaphore@0.0.2':
resolution: {integrity: sha512-nrwZ8HaqU1Agb2ij8omIxaOCAsKkDHWcwS9hTRumPhZuptwh6/0BJExBd8+eClTYM7jBnZxK+cP4WJRTcHBvCA==, tarball: https://npm.jsr.io/~/11/@jsr/henrygd__semaphore/0.0.2.tgz}
'@jsr/nostr__tools@2.16.2':
resolution: {integrity: sha512-QK1XwHvAnqEwbimD+ywbLQ3T2iI+/qE/zrRgOhmtjoEGlCWgtbPTNJ6Y/MEunXr6H/MnuHV+s400i/Yk4suvGQ==, tarball: https://npm.jsr.io/~/11/@jsr/nostr__tools/2.16.2.tgz}
'@noble/ciphers@0.5.3': '@noble/ciphers@0.5.3':
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
@@ -1117,6 +1133,10 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16} engines: {node: ^14.21.3 || >=16}
'@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1692,38 +1712,38 @@ packages:
'@vite-pwa/assets-generator': '@vite-pwa/assets-generator':
optional: true optional: true
'@welshman/app@0.6.8': '@welshman/app@0.7.1':
resolution: {integrity: sha512-bhl18VWA9tzHLY7D+b2xlkc/RbJr03XiA7+otcjzf8X48S4pih/F4TDw1yJbAWOMOx9G3NI6sWLffpZQeSUPiQ==} resolution: {integrity: sha512-gHXuUVplKEtV2J7BDXxz9r6Gv9PwIfhXFEhjOraPW9/BEYS1zK0KneCe87jwZe5B/zmMk3dwMhkaUx4H3WphIA==}
'@welshman/content@0.6.8': '@welshman/content@0.7.1':
resolution: {integrity: sha512-VLek8oOoMMTrEtpIfqFqM9BsbifWYwPC7UiuVuWYqaTSmiAbU3DM2J+tYFcrgnQF8xMnUi/JoVXJ+b2AtpjFrw==} resolution: {integrity: sha512-AHSwpodzQ9zjgbKy7CRIoQg7Irni8PUNyqlvcj4RYbY19bgaGcSoozwjbDat0wY4ULBnVsX1y2DE3+rm5R0T2A==}
'@welshman/editor@0.6.8': '@welshman/editor@0.7.1':
resolution: {integrity: sha512-QzNX7/Nobkh+bpjFnuW2REVpX7Sa+lj70LDdGmEJpjtXlTKlLuNZzpFLee5F9fSObcKCl1G2xBN3tYbZD3vHUA==} resolution: {integrity: sha512-fsCm+W8AQbygoN2+fm1LS6xkxdanB7v5FfhQKFsa8L1B9eYEYCAhwvrxy+nZsBEK/dt8zelk7qKQwq/CJ9sppQ==}
'@welshman/feeds@0.6.8': '@welshman/feeds@0.7.1':
resolution: {integrity: sha512-95VRR2QmGrBUyzYgdsMxhntVoOnaEMsMHRsci1/GX1oOFZPJFTiV7e/m/dD/aWVLUQV1hlRxxXosFFtEDkpjIw==} resolution: {integrity: sha512-i9SCE1jlVIBjM1pfPVW+5axQ0BSNBmOYeo9lKdFOjeTx1sHityb/Q3kK9lgie6IDgXhK/SshEH6bKdYSnOkVSg==}
'@welshman/lib@0.6.8': '@welshman/lib@0.7.1':
resolution: {integrity: sha512-1Wybkk8+vBdqv9nRhnNwIW9YVbhu3di07A2fUYWAQvldto49X26U8u7EV2CkUsz4iNC/799EBYuelcc6W9oZYw==} resolution: {integrity: sha512-NQkxPwnAoUY4uSroQcfvR4YPG63j7Ke0R9YrLNXF9SQn2t2p6iAQ6A3GEOVu/koUQiVBseYn514lS7X1XkCP3A==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
'@welshman/net@0.6.8': '@welshman/net@0.7.1':
resolution: {integrity: sha512-Lc1nIckdxW2ILiknowcbaKo+192QWQOBn6FLhFCEUZNyRNEOJYkAgDu4jKn7GXu91xpfJUFnq5KDvvq7hUeHqg==} resolution: {integrity: sha512-S3dFH73Cy4phLy5I2KKEeefkRmNBYWB2qONK8txUVDhx1u7ezpALzZEMSPVqVIZk/vCQU3KJ0CyagvbuGF+F9Q==}
'@welshman/router@0.6.8': '@welshman/router@0.7.1':
resolution: {integrity: sha512-+OJoD2Jm+yFiLc5FYb4/za66639CeIMYk7j4UAzR7n1z/gFQYMDviXqFYbcWHln3fgy4G7UF1HWBoU0sQD8EEw==} resolution: {integrity: sha512-PZnbGHtbnVbsY+b+FqQHNlyY2+MrEAJ3arFiO3fouayb/sWHdBfSd9EL5UM1FQd1q0fjoZIncTmffRcvQfeBqQ==}
'@welshman/signer@0.6.8': '@welshman/signer@0.7.1':
resolution: {integrity: sha512-lt9Qq89TWyx/zSWgHkeVUX7MBCx86iBCkvzTdUIS7Ad6KfjjcYtsL9wAtfCc+TlvE87okOg97hAOvw18yIwfbw==} resolution: {integrity: sha512-/WNEgXZemQ36A07lmrEy78Yn7kEngBjySmXW+xYmHc3OLhQ9XEq3FBCTR+vxsmp1w/t+7IEScPTKn/wvAQ/cSw==}
peerDependencies: peerDependencies:
nostr-signer-capacitor-plugin: ~0.0.4 nostr-signer-capacitor-plugin: ~0.0.4
'@welshman/store@0.6.8': '@welshman/store@0.7.1':
resolution: {integrity: sha512-s5s5+tdPyXB1m2vLn2wfo7nx+uNKWBdwCyomk+soWKWEY3LWvg4DAKgQ1gF5hyOcja+UIHOJY9hS3BBEo0DtDA==} resolution: {integrity: sha512-EE+vlMdUeVgQhzJqzhAkbLnnOL22gXW8afJzR377n+CvHABLV7/zY9aW0Hmgm1RnyI7fSfWF2YEa6l6VP8x4pw==}
'@welshman/util@0.6.8': '@welshman/util@0.7.1':
resolution: {integrity: sha512-Q4x3Jm3yIk4zORYOscMuxyC7fJGyZFetE5U4PVYNrvgtSLCtULYKs1y6WkAra4FD7zfAa7lqzTlQq4uIZWzdkA==} resolution: {integrity: sha512-UGryq1jfwRHFS7mjGa4fmuqN851iwKeR+616LmUpTJQHAfhGU7ifer2+JLdDLYBU/neI5iKHdRDO5hg92U6k8Q==}
'@xml-tools/parser@1.0.11': '@xml-tools/parser@1.0.11':
resolution: {integrity: sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA==} resolution: {integrity: sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA==}
@@ -3526,6 +3546,14 @@ packages:
typescript: typescript:
optional: true optional: true
nostr-tools@2.19.1:
resolution: {integrity: sha512-iEHSzRxD1gCMohtna5Jx6Cm90gGK4mrJD2+2VYMu346/EucSlz9gsUFubQ3B7f3SMsnQnh1Srm5nCcPfy2NsNw==}
peerDependencies:
typescript: '>=5.0.0'
peerDependenciesMeta:
typescript:
optional: true
nostr-wasm@0.1.0: nostr-wasm@0.1.0:
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
@@ -5839,6 +5867,8 @@ snapshots:
'@getalby/lightning-tools@5.2.1': {} '@getalby/lightning-tools@5.2.1': {}
'@getalby/lightning-tools@6.0.0': {}
'@getalby/sdk@5.1.2(typescript@5.9.3)': '@getalby/sdk@5.1.2(typescript@5.9.3)':
dependencies: dependencies:
'@getalby/lightning-tools': 5.2.1 '@getalby/lightning-tools': 5.2.1
@@ -6024,6 +6054,24 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@jsr/fiatjaf__promenade-trusted-dealer@0.4.1':
dependencies:
'@jsr/henrygd__semaphore': 0.0.2
'@jsr/nostr__tools': 2.16.2
'@noble/curves': 1.9.7
'@jsr/henrygd__semaphore@0.0.2': {}
'@jsr/nostr__tools@2.16.2':
dependencies:
'@noble/ciphers': 0.5.3
'@noble/curves': 1.2.0
'@noble/hashes': 1.3.1
'@scure/base': 1.1.1
'@scure/bip32': 1.3.1
'@scure/bip39': 1.2.1
nostr-wasm: 0.1.0
'@noble/ciphers@0.5.3': {} '@noble/ciphers@0.5.3': {}
'@noble/curves@1.1.0': '@noble/curves@1.1.0':
@@ -6044,6 +6092,8 @@ snapshots:
'@noble/hashes@1.8.0': {} '@noble/hashes@1.8.0': {}
'@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -6651,16 +6701,16 @@ snapshots:
optionalDependencies: optionalDependencies:
'@vite-pwa/assets-generator': 0.2.6 '@vite-pwa/assets-generator': 0.2.6
'@welshman/app@0.6.8(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)': '@welshman/app@0.7.1(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
dependencies: dependencies:
'@types/throttle-debounce': 5.0.2 '@types/throttle-debounce': 5.0.2
'@welshman/feeds': 0.6.8(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3) '@welshman/feeds': 0.7.1(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/lib': 0.6.8 '@welshman/lib': 0.7.1
'@welshman/net': 0.6.8(typescript@5.9.3)(ws@8.18.3) '@welshman/net': 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/router': 0.6.8(typescript@5.9.3)(ws@8.18.3) '@welshman/router': 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/signer': 0.6.8(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3) '@welshman/signer': 0.7.1(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/store': 0.6.8(typescript@5.9.3)(ws@8.18.3) '@welshman/store': 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.8(typescript@5.9.3) '@welshman/util': 0.7.1(typescript@5.9.3)
fuse.js: 7.1.0 fuse.js: 7.1.0
svelte: 4.2.20 svelte: 4.2.20
throttle-debounce: 5.0.2 throttle-debounce: 5.0.2
@@ -6669,14 +6719,14 @@ snapshots:
- typescript - typescript
- ws - ws
'@welshman/content@0.6.8(typescript@5.9.3)': '@welshman/content@0.7.1(typescript@5.9.3)':
dependencies: dependencies:
'@braintree/sanitize-url': 7.1.1 '@braintree/sanitize-url': 7.1.1
nostr-tools: 2.17.0(typescript@5.9.3) nostr-tools: 2.17.0(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@welshman/editor@0.6.8(@tiptap/extension-image@2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.27.1(@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.7.1(@tiptap/extension-image@2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.27.1(@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: dependencies:
'@tiptap/core': 2.26.3(@tiptap/pm@2.26.3) '@tiptap/core': 2.26.3(@tiptap/pm@2.26.3)
'@tiptap/extension-code': 2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) '@tiptap/extension-code': 2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
@@ -6691,8 +6741,8 @@ snapshots:
'@tiptap/extension-text': 2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)) '@tiptap/extension-text': 2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))
'@tiptap/pm': 2.26.3 '@tiptap/pm': 2.26.3
'@tiptap/suggestion': 2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3) '@tiptap/suggestion': 2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/pm@2.26.3)
'@welshman/lib': 0.6.8 '@welshman/lib': 0.7.1
'@welshman/util': 0.6.8(typescript@5.9.3) '@welshman/util': 0.7.1(typescript@5.9.3)
nostr-editor: 1.0.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/extension-image@2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.27.1(@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-editor: 1.0.2(@tiptap/core@2.26.3(@tiptap/pm@2.26.3))(@tiptap/extension-image@2.27.1(@tiptap/core@2.26.3(@tiptap/pm@2.26.3)))(@tiptap/extension-link@2.27.1(@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) nostr-tools: 2.17.0(typescript@5.9.3)
tippy.js: 6.3.7 tippy.js: 6.3.7
@@ -6707,71 +6757,73 @@ snapshots:
- tiptap-markdown - tiptap-markdown
- typescript - typescript
'@welshman/feeds@0.6.8(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)': '@welshman/feeds@0.7.1(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
dependencies: dependencies:
'@welshman/lib': 0.6.8 '@welshman/lib': 0.7.1
'@welshman/net': 0.6.8(typescript@5.9.3)(ws@8.18.3) '@welshman/net': 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/router': 0.6.8(typescript@5.9.3)(ws@8.18.3) '@welshman/router': 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/signer': 0.6.8(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3) '@welshman/signer': 0.7.1(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.8(typescript@5.9.3) '@welshman/util': 0.7.1(typescript@5.9.3)
trava: 1.2.1 trava: 1.2.1
transitivePeerDependencies: transitivePeerDependencies:
- nostr-signer-capacitor-plugin - nostr-signer-capacitor-plugin
- typescript - typescript
- ws - ws
'@welshman/lib@0.6.8': '@welshman/lib@0.7.1':
dependencies: dependencies:
'@scure/base': 1.2.6 '@scure/base': 1.2.6
'@types/events': 3.0.3 '@types/events': 3.0.3
events: 3.3.0 events: 3.3.0
'@welshman/net@0.6.8(typescript@5.9.3)(ws@8.18.3)': '@welshman/net@0.7.1(typescript@5.9.3)(ws@8.18.3)':
dependencies: dependencies:
'@welshman/lib': 0.6.8 '@welshman/lib': 0.7.1
'@welshman/util': 0.6.8(typescript@5.9.3) '@welshman/util': 0.7.1(typescript@5.9.3)
events: 3.3.0 events: 3.3.0
isomorphic-ws: 5.0.0(ws@8.18.3) isomorphic-ws: 5.0.0(ws@8.18.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
- ws - ws
'@welshman/router@0.6.8(typescript@5.9.3)(ws@8.18.3)': '@welshman/router@0.7.1(typescript@5.9.3)(ws@8.18.3)':
dependencies: dependencies:
'@welshman/lib': 0.6.8 '@welshman/lib': 0.7.1
'@welshman/net': 0.6.8(typescript@5.9.3)(ws@8.18.3) '@welshman/net': 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.8(typescript@5.9.3) '@welshman/util': 0.7.1(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
- ws - ws
'@welshman/signer@0.6.8(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)': '@welshman/signer@0.7.1(nostr-signer-capacitor-plugin@0.0.4(@capacitor/core@7.4.3))(typescript@5.9.3)(ws@8.18.3)':
dependencies: dependencies:
'@jsr/fiatjaf__promenade-trusted-dealer': 0.4.1
'@noble/curves': 1.9.7 '@noble/curves': 1.9.7
'@noble/hashes': 1.8.0 '@noble/hashes': 2.0.1
'@welshman/lib': 0.6.8 '@welshman/lib': 0.7.1
'@welshman/net': 0.6.8(typescript@5.9.3)(ws@8.18.3) '@welshman/net': 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.8(typescript@5.9.3) '@welshman/util': 0.7.1(typescript@5.9.3)
nostr-signer-capacitor-plugin: 0.0.4(@capacitor/core@7.4.3) nostr-signer-capacitor-plugin: 0.0.4(@capacitor/core@7.4.3)
nostr-tools: 2.17.0(typescript@5.9.3) nostr-tools: 2.19.1(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
- ws - ws
'@welshman/store@0.6.8(typescript@5.9.3)(ws@8.18.3)': '@welshman/store@0.7.1(typescript@5.9.3)(ws@8.18.3)':
dependencies: dependencies:
'@welshman/lib': 0.6.8 '@welshman/lib': 0.7.1
'@welshman/net': 0.6.8(typescript@5.9.3)(ws@8.18.3) '@welshman/net': 0.7.1(typescript@5.9.3)(ws@8.18.3)
'@welshman/util': 0.6.8(typescript@5.9.3) '@welshman/util': 0.7.1(typescript@5.9.3)
svelte: 4.2.20 svelte: 4.2.20
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
- ws - ws
'@welshman/util@0.6.8(typescript@5.9.3)': '@welshman/util@0.7.1(typescript@5.9.3)':
dependencies: dependencies:
'@noble/curves': 1.9.7
'@types/ws': 8.18.1 '@types/ws': 8.18.1
'@welshman/lib': 0.6.8 '@welshman/lib': 0.7.1
js-base64: 3.7.8 js-base64: 3.7.8
nostr-tools: 2.17.0(typescript@5.9.3) nostr-tools: 2.17.0(typescript@5.9.3)
nostr-wasm: 0.1.0 nostr-wasm: 0.1.0
@@ -8669,6 +8721,18 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
nostr-tools@2.19.1(typescript@5.9.3):
dependencies:
'@noble/ciphers': 0.5.3
'@noble/curves': 1.2.0
'@noble/hashes': 1.3.1
'@scure/base': 1.1.1
'@scure/bip32': 1.3.1
'@scure/bip39': 1.2.1
nostr-wasm: 0.1.0
optionalDependencies:
typescript: 5.9.3
nostr-wasm@0.1.0: {} nostr-wasm@0.1.0: {}
nth-check@2.1.1: nth-check@2.1.1:
+10
View File
@@ -392,6 +392,16 @@ progress[value]::-webkit-progress-value {
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)]; @apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
} }
/* Keyboard open state adjustments */
body.keyboard-open .cb {
@apply bottom-sai;
}
body.keyboard-open .hide-on-keyboard {
display: none;
}
/* chat view */ /* chat view */
.chat__compose { .chat__compose {
+6 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib" import {randomInt, map, displayList, identity, TIMEZONE} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util" import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds" import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
@@ -13,7 +13,7 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {alerts, userSpaceUrls} from "@app/core/state" import {alertsById, userSpaceUrls} from "@app/core/state"
import {requestRelayClaim} from "@app/core/requests" import {requestRelayClaim} from "@app/core/requests"
import {createAlert} from "@app/core/commands" import {createAlert} from "@app/core/commands"
import {canSendPushNotifications} from "@app/util/push" import {canSendPushNotifications} from "@app/util/push"
@@ -37,7 +37,7 @@
hideSpaceField = false, hideSpaceField = false,
}: Props = $props() }: Props = $props()
const timezoneOffset = parseInt(TIMEZONE.slice(3)) / 100 const timezoneOffset = parseInt(TIMEZONE.split(":")?.[0] || "00")
const minute = randomInt(0, 59) const minute = randomInt(0, 59)
const hour = (17 - timezoneOffset) % 24 const hour = (17 - timezoneOffset) % 24
const WEEKLY = `0 ${minute} ${hour} * * 1` const WEEKLY = `0 ${minute} ${hour} * * 1`
@@ -45,7 +45,9 @@
let loading = $state(false) let loading = $state(false)
let cron = $state(WEEKLY) let cron = $state(WEEKLY)
let email = $state($alerts.map(a => getTagValue("email", a.tags)).filter(identity)[0] || "") let email = $state(
map(a => getTagValue("email", a.tags), $alertsById.values()).filter(identity)[0] || "",
)
const back = () => history.back() const back = () => history.back()
+7 -7
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import {sleep} from "@welshman/lib" import {sleep, filter} from "@welshman/lib"
import {getTagValue, getAddress} from "@welshman/util" import {getTagValue, getAddress, RelayMode} from "@welshman/util"
import {isRelayFeed, findFeed} from "@welshman/feeds" import {isRelayFeed, findFeed} from "@welshman/feeds"
import {getPubkeyRelays, pubkey} from "@welshman/app"
import Inbox from "@assets/icons/inbox.svg?dataurl" import Inbox from "@assets/icons/inbox.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
@@ -12,10 +13,9 @@
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import { import {
alerts,
dmAlert, dmAlert,
alertsById,
deriveAlertStatus, deriveAlertStatus,
userInboxRelays,
getAlertFeed, getAlertFeed,
userSettingsValues, userSettingsValues,
} from "@app/core/state" } from "@app/core/state"
@@ -33,7 +33,7 @@
const dmStatus = $derived($dmAlert ? deriveAlertStatus(getAddress($dmAlert.event)) : undefined) const dmStatus = $derived($dmAlert ? deriveAlertStatus(getAddress($dmAlert.event)) : undefined)
const filteredAlerts = $derived( const filteredAlerts = $derived(
$alerts.filter(alert => { filter(alert => {
const feed = getAlertFeed(alert) const feed = getAlertFeed(alert)
// Skip non-feeds and DM alerts // Skip non-feeds and DM alerts
@@ -43,7 +43,7 @@
if (url) return findFeed(feed, f => isRelayFeed(f) && f.includes(url)) if (url) return findFeed(feed, f => isRelayFeed(f) && f.includes(url))
return true return true
}), }, $alertsById.values()),
) )
const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField}) const startAlert = () => pushModal(AlertAdd, {url, channel, hideSpaceField})
@@ -59,7 +59,7 @@
if ($dmAlert) { if ($dmAlert) {
deleteAlert($dmAlert) deleteAlert($dmAlert)
} else { } else {
if ($userInboxRelays.length === 0) { if (getPubkeyRelays($pubkey!, RelayMode.Messaging).length === 0) {
return uncheckDmAlert("Please set up your messaging relays before enabling alerts.") return uncheckDmAlert("Please set up your messaging relays before enabling alerts.")
} }
+35 -48
View File
@@ -28,8 +28,8 @@
tagPubkey, tagPubkey,
sendWrapped, sendWrapped,
mergeThunks, mergeThunks,
loadInboxRelaySelections, loadMessagingRelayList,
inboxRelaySelectionsByPubkey, messagingRelayListsByPubkey,
} from "@welshman/app" } from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -43,36 +43,31 @@
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ProfileList from "@app/components/ProfileList.svelte" import ChatMembers from "@app/components/ChatMembers.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte" import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte" import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte" import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import { import {INDEXER_RELAYS, userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
INDEXER_RELAYS,
userSettingsValues,
deriveChat,
splitChatId,
PLATFORM_NAME,
} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {prependParent} from "@app/core/commands" import {prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
type Props = { type Props = {
id: string pubkeys: string[]
info?: Snippet info?: Snippet
} }
const {id, info}: Props = $props() const {pubkeys, info}: Props = $props()
const chat = deriveChat(id) const chat = deriveChat(pubkeys)
const pubkeys = splitChatId(id)
const others = remove($pubkey!, pubkeys) const others = remove($pubkey!, pubkeys)
const missingInboxes = $derived(pubkeys.filter(pk => !$inboxRelaySelectionsByPubkey.has(pk))) const missingRelayLists = $derived(pubkeys.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
const showMembers = () => const showMembers = () =>
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`}) others.length === 1
? pushModal(ProfileDetail, {pubkey: others[0]})
: pushModal(ChatMembers, {pubkeys: others})
const replyTo = (event: TrustedEvent) => { const replyTo = (event: TrustedEvent) => {
parent = event parent = event
@@ -183,7 +178,7 @@
onMount(() => { onMount(() => {
for (const pubkey of others) { for (const pubkey of others) {
loadInboxRelaySelections(pubkey, INDEXER_RELAYS, true) loadMessagingRelayList(pubkey, INDEXER_RELAYS, true)
} }
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
@@ -208,19 +203,17 @@
<PageBar> <PageBar>
{#snippet title()} {#snippet title()}
<div class="flex flex-col gap-1 sm:flex-row sm:gap-2"> <Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{#if others.length === 0} {#if others.length === 0}
<div class="row-2"> <div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} /> <ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} /> <ProfileName pubkey={$pubkey!} />
</div> </div>
{:else if others.length === 1} {:else if others.length === 1}
{@const pubkey = others[0]} <div class="row-2">
{@const onClick = () => pushModal(ProfileDetail, {pubkey})} <ProfileCircle pubkey={others[0]} size={5} />
<Button onclick={onClick} class="row-2"> <ProfileName pubkey={others[0]} />
<ProfileCircle {pubkey} size={5} /> </div>
<ProfileName {pubkey} />
</Button>
{:else} {:else}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} /> <ProfileCircles pubkeys={others} size={5} />
@@ -235,55 +228,49 @@
{/if} {/if}
</p> </p>
</div> </div>
{#if others.length > 2}
<Button onclick={showMembers} class="btn btn-link hidden sm:block"
>Show all members</Button>
{/if}
{/if} {/if}
</div> </Button>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<div> {#if remove($pubkey, missingRelayLists).length > 0}
{#if remove($pubkey, missingInboxes).length > 0} {@const count = remove($pubkey, missingRelayLists).length}
{@const count = remove($pubkey, missingInboxes).length} {@const label = count > 1 ? "lists are" : "list is"}
{@const label = count > 1 ? "inboxes are" : "inbox is"} <div
<div class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer" data-tip="{count} messaging {label} not configured.">
data-tip="{count} {label} not configured."> <Icon icon={Danger} />
<Icon icon={Danger} /> {count}
{count} </div>
</div> {/if}
{/if}
</div>
{/snippet} {/snippet}
</PageBar> </PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4"> <PageContent class="flex flex-col-reverse gap-2 pt-4">
<div bind:this={dynamicPadding}></div> <div bind:this={dynamicPadding}></div>
{#if missingInboxes.includes($pubkey!)} {#if missingRelayLists.includes($pubkey!)}
<div class="py-12"> <div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center"> <div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error"> <p class="row-2 text-lg text-error">
<Icon icon={Danger} /> <Icon icon={Danger} />
Your inbox is not configured. Your messaging relays are not configured.
</p> </p>
<p> <p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
your <Link class="link" href="/settings/relays">relay settings page</Link> to set up your inbox. your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages.
</p> </p>
</div> </div>
</div> </div>
{:else if missingInboxes.length > 0} {:else if missingRelayLists.length > 0}
<div class="py-12"> <div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center"> <div class="card2 col-2 m-auto max-w-md items-center text-center">
<p class="row-2 text-lg text-error"> <p class="row-2 text-lg text-error">
<Icon icon={Danger} /> <Icon icon={Danger} />
{missingInboxes.length} {missingRelayLists.length} messaging
{missingInboxes.length > 1 ? "inboxes are" : "inbox is"} not configured. {missingRelayLists.length > 1 ? "lists are" : "list is"} not configured.
</p> </p>
<p> <p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
sure everyone in this conversation has set up their inbox relays. sure everyone in this conversation has set up their messaging relays.
</p> </p>
</div> </div>
</div> </div>
+7 -4
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {remove} from "@welshman/lib" import {remove, formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadInboxRelaySelections} from "@welshman/app" import {pubkey, loadMessagingRelayList} from "@welshman/app"
import {fade} from "@lib/transition" import {fade} from "@lib/transition"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
@@ -27,7 +27,7 @@
onMount(() => { onMount(() => {
for (const pk of others) { for (const pk of others) {
loadInboxRelaySelections(pk) loadMessagingRelayList(pk)
} }
}) })
</script> </script>
@@ -59,13 +59,16 @@
{/if} {/if}
</div> </div>
<p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm"> <p class="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
<span class="opacity-50"> <span class="opacity-70">
{#if props.messages[0].pubkey === $pubkey} {#if props.messages[0].pubkey === $pubkey}
You: You:
{/if} {/if}
</span> </span>
{props.messages[0].content} {props.messages[0].content}
</p> </p>
<p class="text-xs opacity-70">
{formatTimestamp(props.messages[0].created_at)}
</p>
</div> </div>
</div> </div>
</Link> </Link>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import Profile from "@app/components/Profile.svelte"
interface Props {
pubkeys: string[]
}
const {pubkeys}: Props = $props()
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>People in this conversation</div>
{/snippet}
</ModalHeader>
{#each pubkeys as pubkey (pubkey)}
<div class="card2 bg-alt">
<Profile {pubkey} />
</div>
{/each}
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
</div>
+4 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {waitForThunkCompletion} from "@welshman/app" import {RelayMode} from "@welshman/util"
import {waitForThunkCompletion, getPubkeyRelays, pubkey} from "@welshman/app"
import ChatSquare from "@assets/icons/chat-square.svg?dataurl" import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
import Check from "@assets/icons/check.svg?dataurl" import Check from "@assets/icons/check.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl"
@@ -11,7 +12,7 @@
import {setChecked} from "@app/util/notifications" import {setChecked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {dmAlert, userInboxRelays} from "@app/core/state" import {dmAlert} from "@app/core/state"
import {deleteAlert, createDmAlert} from "@app/core/commands" import {deleteAlert, createDmAlert} from "@app/core/commands"
const startChat = () => pushModal(ChatStart, {}, {replaceState: true}) const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
@@ -22,7 +23,7 @@
} }
const enableAlerts = async () => { const enableAlerts = async () => {
if ($userInboxRelays.length === 0) { if (getPubkeyRelays($pubkey!, RelayMode.Messaging).length === 0) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Please set up your messaging relays before enabling alerts.", message: "Please set up your messaging relays before enabling alerts.",
+10 -10
View File
@@ -46,20 +46,20 @@
</script> </script>
<div class="col-2"> <div class="col-2">
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}> <Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon={SmileCircle} /> <Icon size={4} icon={Code2} />
Send Reaction Message Info
</Button>
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Reply} />
Send Reply
</Button> </Button>
<Button class="btn btn-neutral w-full" onclick={copyText}> <Button class="btn btn-neutral w-full" onclick={copyText}>
<Icon size={4} icon={Copy} /> <Icon size={4} icon={Copy} />
Copy Text Copy Text
</Button> </Button>
<Button class="btn btn-neutral" onclick={showInfo}> <Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Code2} /> <Icon size={4} icon={Reply} />
Message Details Send Reply
</Button>
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
Send Reaction
</Button> </Button>
</div> </div>
+12 -4
View File
@@ -1,17 +1,25 @@
<script lang="ts"> <script lang="ts">
import {ellipsize, displayUrl, postJson} from "@welshman/lib" import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {dufflepud} from "@app/core/state" import {isRelayUrl} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html" import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte" import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte" import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {dufflepud, PLATFORM_URL} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props() const {value, event} = $props()
let hideImage = $state(false) let hideImage = $state(false)
const url = value.url.toString() const url = value.url.toString()
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const loadPreview = async () => { const loadPreview = async () => {
const json = await postJson(dufflepud("link/preview"), {url}) const json = await postJson(dufflepud("link/preview"), {url})
@@ -30,7 +38,7 @@
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script> </script>
<Link external href={url} class="my-2 block"> <Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box"> <div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/)} {#if url.match(/\.(mov|webm|mp4)$/)}
<video controls src={url} class="max-h-96 object-contain object-center"> <video controls src={url} class="max-h-96 object-contain object-center">
@@ -49,7 +57,7 @@
<div class="bg-alt flex max-w-xl flex-col leading-normal"> <div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage} {#if preview.image && !hideImage}
<img <img
alt="Link preview" alt=""
onerror={onError} onerror={onError}
src={preview.image} src={preview.image}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" /> class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
@@ -21,7 +21,8 @@
.map(tagsFromIMeta) .map(tagsFromIMeta)
.find(meta => getTagValue("url", meta) === url) || event.tags .find(meta => getTagValue("url", meta) === url) || event.tags
const hash = getTagValue("x", meta) // Fallback to filename if hash was omitted from the message for interoperability
const hash = getTagValue("x", meta) || url.split(/[\/\.]/).slice(-2)[0]
const key = getTagValue("decryption-key", meta) const key = getTagValue("decryption-key", meta)
const nonce = getTagValue("decryption-nonce", meta) const nonce = getTagValue("decryption-nonce", meta)
const algorithm = getTagValue("encryption-algorithm", meta) const algorithm = getTagValue("encryption-algorithm", meta)
+11 -2
View File
@@ -1,15 +1,24 @@
<script lang="ts"> <script lang="ts">
import {displayUrl} from "@welshman/lib" import {call, displayUrl} from "@welshman/lib"
import {isRelayUrl} from "@welshman/util"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte" import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {PLATFORM_URL} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value} = $props() const {value} = $props()
const url = value.url.toString() const url = value.url.toString()
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
</script> </script>
@@ -21,7 +30,7 @@
{displayUrl(url)} {displayUrl(url)}
</a> </a>
{:else} {:else}
<Link external href={url} class="link-content whitespace-nowrap"> <Link {external} {href} class="link-content whitespace-nowrap">
<Icon icon={LinkRound} size={3} class="inline-block" /> <Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)} {displayUrl(url)}
</Link> </Link>
+2 -2
View File
@@ -3,7 +3,7 @@
import {max, formatTimestampRelative} from "@welshman/lib" import {max, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util" import {COMMENT} from "@welshman/util"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {deriveEvents} from "@welshman/store" import {deriveArray, deriveEventsById} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app" import {repository} from "@welshman/app"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
@@ -13,7 +13,7 @@
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props() const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
const filters = [{kinds: [COMMENT], "#E": [event.id]}] const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const replies = deriveEvents(repository, {filters}) const replies = deriveArray(deriveEventsById({repository, filters}))
const lastActive = $derived(max([...$replies, event].map(e => e.created_at))) const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
onMount(() => { onMount(() => {
+2 -2
View File
@@ -4,6 +4,7 @@
import {LOCALE, secondsToDate} from "@welshman/lib" import {LOCALE, secondsToDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {tracker} from "@welshman/app"
import FileText from "@assets/icons/file-text.svg?dataurl" import FileText from "@assets/icons/file-text.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl"
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
@@ -11,7 +12,6 @@
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import {trackerStore} from "@app/core/state"
import {clip} from "@app/util/toast" import {clip} from "@app/util/toast"
type Props = { type Props = {
@@ -23,7 +23,7 @@
const relays = url ? [url] : Router.get().Event(event).getUrls() const relays = url ? [url] : Router.get().Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays}) const nevent1 = nip19.neventEncode({...event, relays})
const seenOn = $trackerStore.getRelays(event.id) const seenOn = tracker.getRelays(event.id)
const npub1 = nip19.npubEncode(event.pubkey) const npub1 = nip19.npubEncode(event.pubkey)
const json = JSON.stringify(event, null, 2) const json = JSON.stringify(event, null, 2)
const copyLink = () => clip(nevent1) const copyLink = () => clip(nevent1)
+36 -5
View File
@@ -3,21 +3,23 @@
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util" import {COMMENT, ManagementMethod} from "@welshman/util"
import {pubkey, relaysByUrl} from "@welshman/app" import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl" import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl" import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl" import Danger from "@assets/icons/danger.svg?dataurl"
import {setKey} from "@lib/implicit" import {setKey} from "@lib/implicit"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte" import Report from "@app/components/Report.svelte"
import EventShare from "@app/components/EventShare.svelte" import EventShare from "@app/components/EventShare.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {hasNip29} from "@app/core/state" import {hasNip29, deriveUserIsSpaceAdmin} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {makeSpaceChatPath} from "@app/util/routes" import {makeSpaceChatPath} from "@app/util/routes"
type Props = { type Props = {
@@ -31,8 +33,9 @@
const {url, noun, event, onClick, customActions}: Props = $props() const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT const isRoot = event.kind !== COMMENT
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const report = () => pushModal(EventReport, {url, event}) const report = () => pushModal(Report, {url, event})
const showInfo = () => pushModal(EventInfo, {url, event}) const showInfo = () => pushModal(EventInfo, {url, event})
@@ -47,6 +50,26 @@
const showDelete = () => pushModal(EventDeleteConfirm, {url, event}) const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
const showAdminDelete = () =>
pushModal(Confirm, {
title: `Delete ${noun}`,
message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [event.id],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Event has successfully been deleted!"})
repository.removeEvent(event.id)
history.back()
}
},
})
let ul: Element let ul: Element
onMount(() => { onMount(() => {
@@ -84,5 +107,13 @@
Report Content Report Content
</Button> </Button>
</li> </li>
{#if $userIsAdmin}
<li>
<Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} />
Delete {noun}
</Button>
</li>
{/if}
{/if} {/if}
</ul> </ul>
@@ -1,60 +0,0 @@
<script lang="ts">
import {getTag, REPORT} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {pubkey, repository} from "@welshman/app"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
const {url, event} = $props()
const shouldProtect = canEnforceNip70(url)
const reports = deriveEvents(repository, {
filters: [{kinds: [REPORT], "#e": [event.id]}],
})
const back = () => history.back()
const deleteReport = async (report: TrustedEvent) => {
publishDelete({event: report, relays: [url], protect: await shouldProtect})
if ($reports.length === 0) {
history.back()
}
}
const getReason = (tags: string[][]) => getTag("e", tags)?.[2] || "other"
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Report Details</div>
{/snippet}
{#snippet info()}
<div>All reports for this event are shown below.</div>
{/snippet}
</ModalHeader>
{#each $reports as report (report.id)}
{@const reason = getReason(report.tags)}
{@const remove = () => deleteReport(report)}
<div class="column gap-2">
<div class="flex justify-between">
<div>
<Profile pubkey={report.pubkey} {url} />
<span>Reported this event as "{reason}"</span>
</div>
{#if report.pubkey === $pubkey}
<Button class="btn-default btn" onclick={remove}>Delete Report</Button>
{/if}
</div>
{#if report.content}
<p>"{report.content}"</p>
{/if}
</div>
{/each}
<Button class="btn btn-primary" onclick={back}>Got it</Button>
</div>
+9 -6
View File
@@ -2,7 +2,7 @@
import {now, DAY, uniq, sum} from "@welshman/lib" import {now, DAY, uniq, sum} from "@welshman/lib"
import type {Zap, TrustedEvent} from "@welshman/util" import type {Zap, TrustedEvent} from "@welshman/util"
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util" import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
import {deriveEventsMapped} from "@welshman/store" import {deriveItemsByKey, deriveArray} from "@welshman/store"
import {repository, getValidZap} from "@welshman/app" import {repository, getValidZap} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -16,11 +16,14 @@
const {url, event, ...props}: Props = $props() const {url, event, ...props}: Props = $props()
const zaps = deriveEventsMapped<Zap>(repository, { const zaps = deriveArray(
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}], deriveItemsByKey<Zap>({
itemToEvent: item => item.response, repository,
eventToItem: (response: TrustedEvent) => getValidZap(response, event), getKey: zap => zap.response.id,
}) filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
}),
)
const goalAmount = parseInt(getTagValue("amount", event.tags) || "0") const goalAmount = parseInt(getTagValue("amount", event.tags) || "0")
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount)))) const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
+2 -1
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onMount, onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import type {Nip46ResponseWithResult} from "@welshman/signer" import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer" import {Nip46Broker} from "@welshman/signer"
import {makeSecret} from "@welshman/util"
import {loginWithNip01, loginWithNip46} from "@welshman/app" import {loginWithNip01, loginWithNip46} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onMount, onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import {postJson, stripProtocol} from "@welshman/lib" import {postJson, stripProtocol} from "@welshman/lib"
import {Nip46Broker, makeSecret} from "@welshman/signer" import {Nip46Broker} from "@welshman/signer"
import {normalizeRelayUrl} from "@welshman/util" import {normalizeRelayUrl, makeSecret} from "@welshman/util"
import {addSession, makeNip46Session} from "@welshman/app" import {addSession, makeNip46Session} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
+1
View File
@@ -34,6 +34,7 @@
target: element, target: element,
props: { props: {
onClose: closeModals, onClose: closeModals,
fullscreen: options.fullscreen,
children: createRawSnippet(() => ({ children: createRawSnippet(() => ({
render: () => "<div></div>", render: () => "<div></div>",
setup: (target: Element) => { setup: (target: Element) => {
+7 -14
View File
@@ -1,19 +1,16 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import * as nip19 from "nostr-tools/nip19"
import {formatTimestamp} from "@welshman/lib" import {formatTimestamp} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util" import {getListTags, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {Router} from "@welshman/router" import {userMuteList} from "@welshman/app"
import {userMutes} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import {entityLink} from "@app/core/state" import {goToEvent} from "@app/util/routes"
const { const {
event, event,
@@ -31,14 +28,11 @@
class?: string class?: string
} = $props() } = $props()
const relays = Router.get().Event(event).getUrls()
const nevent = nip19.neventEncode({id: event.id, relays})
const ignoreMute = () => { const ignoreMute = () => {
muted = false muted = false
} }
let muted = $state(getPubkeyTagValues(getListTags($userMutes)).includes(event.pubkey)) let muted = $state(getPubkeyTagValues(getListTags($userMuteList)).includes(event.pubkey))
</script> </script>
<div class="flex flex-col gap-2 {restProps.class}"> <div class="flex flex-col gap-2 {restProps.class}">
@@ -59,12 +53,11 @@
<Profile pubkey={event.pubkey} {url} /> <Profile pubkey={event.pubkey} {url} />
{/if} {/if}
{/if} {/if}
<Link <Button
external class={cx("text-sm opacity-75", {"text-xs": minimal})}
href={entityLink(nevent)} onclick={() => goToEvent(event)}>
class={cx("text-sm opacity-75", {"text-xs": minimal})}>
{formatTimestamp(event.created_at)} {formatTimestamp(event.created_at)}
</Link> </Button>
</div> </div>
{@render children()} {@render children()}
{/if} {/if}
@@ -3,7 +3,7 @@
import {sum} from "@welshman/lib" import {sum} from "@welshman/lib"
import type {Zap, TrustedEvent} from "@welshman/util" import type {Zap, TrustedEvent} from "@welshman/util"
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util" import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
import {deriveEventsMapped} from "@welshman/store" import {deriveItemsByKey, deriveArray} from "@welshman/store"
import {repository, getValidZap} from "@welshman/app" import {repository, getValidZap} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl" import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -14,11 +14,14 @@
const content = getTagValue("summary", props.event.tags) const content = getTagValue("summary", props.event.tags)
const fakeEvent = {content, tags: props.event.tags} const fakeEvent = {content, tags: props.event.tags}
const zaps = deriveEventsMapped<Zap>(repository, { const zaps = deriveArray(
filters: [{kinds: [ZAP_RESPONSE], "#e": [props.event.id]}], deriveItemsByKey<Zap>({
itemToEvent: item => item.response, repository,
eventToItem: (response: TrustedEvent) => getValidZap(response, props.event), getKey: zap => zap.response.id,
}) filters: [{kinds: [ZAP_RESPONSE], "#e": [props.event.id]}],
eventToItem: (response: TrustedEvent) => getValidZap(response, props.event),
}),
)
const goalAmount = parseInt(getTagValue("amount", props.event.tags) || "0") const goalAmount = parseInt(getTagValue("amount", props.event.tags) || "0")
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount)))) const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
+4 -2
View File
@@ -110,9 +110,11 @@
{@render children?.()} {@render children?.()}
<!-- a little extra something for ios --> <!-- a little extra something for ios -->
<div class="fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden"></div>
<div <div
class="border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden"> class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
</div>
<div
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
<div class="content-padding-x content-sizing flex justify-between px-2"> <div class="content-padding-x content-sizing flex justify-between px-2">
<div class="flex gap-2 sm:gap-6"> <div class="flex gap-2 sm:gap-6">
<PrimaryNavItem title="Home" href="/home"> <PrimaryNavItem title="Home" href="/home">
@@ -5,7 +5,11 @@
import {makeSpacePath, goToSpace} from "@app/util/routes" import {makeSpacePath, goToSpace} from "@app/util/routes"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
const {url} = $props() type Props = {
url: string
}
const {url}: Props = $props()
const onClick = () => goToSpace(url) const onClick = () => goToSpace(url)
</script> </script>
@@ -15,5 +19,5 @@
title={displayRelayUrl(url)} title={displayRelayUrl(url)}
class="tooltip-right" class="tooltip-right"
notification={$notifications.has(makeSpacePath(url))}> notification={$notifications.has(makeSpacePath(url))}>
<RelayIcon {url} size={10} class="rounded-full" /> <RelayIcon {url} size={7} class="rounded-full" />
</PrimaryNavItem> </PrimaryNavItem>
+7 -11
View File
@@ -3,17 +3,13 @@
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store" import {deriveArray, deriveEventsById} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib" import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, COMMENT} from "@welshman/util" import {NOTE, ROOMS, COMMENT} from "@welshman/util"
import {repository, loadRelaySelections} from "@welshman/app" import {repository, loadRelayList} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileSpaces from "@app/components/ProfileSpaces.svelte" import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
import { import {deriveGroupList, getSpaceUrlsFromGroupList, MESSAGE_KINDS} from "@app/core/state"
deriveGroupSelections,
getSpaceUrlsFromGroupSelections,
MESSAGE_KINDS,
} from "@app/core/state"
import {goToEvent} from "@app/util/routes" import {goToEvent} from "@app/util/routes"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -24,9 +20,9 @@
const {pubkey, url}: Props = $props() const {pubkey, url}: Props = $props()
const filters: Filter[] = [{authors: [pubkey], limit: 1}] const filters: Filter[] = [{authors: [pubkey], limit: 1}]
const events = deriveEvents(repository, {filters}) const events = deriveArray(deriveEventsById({repository, filters}))
const selections = deriveGroupSelections(pubkey) const groupList = deriveGroupList(pubkey)
const spaceUrls = $derived(getSpaceUrlsFromGroupSelections($selections)) const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
const viewEvent = () => goToEvent($events[0]!) const viewEvent = () => goToEvent($events[0]!)
@@ -34,7 +30,7 @@
onMount(async () => { onMount(async () => {
// Make sure we have their relay selections before we load their posts // Make sure we have their relay selections before we load their posts
await loadRelaySelections(pubkey) await loadRelayList(pubkey)
// Load groups and at least one note, regardless of time frame // Load groups and at least one note, regardless of time frame
load({ load({
+2 -2
View File
@@ -19,6 +19,6 @@
<ImageIcon <ImageIcon
{size} {size}
alt=""
class={cx(props.class, "rounded-full")} class={cx(props.class, "rounded-full")}
src={$profile?.picture || UserRounded} src={$profile?.picture || UserRounded} />
alt="Profile picture" />
+5 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {getProfile} from "@welshman/app"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
type Props = { type Props = {
@@ -10,7 +11,10 @@
</script> </script>
<div class="flex pr-3"> <div class="flex pr-3">
{#each pubkeys.toSorted().slice(0, 15) as pubkey (pubkey)} {#each pubkeys
.filter(p => getProfile(p)?.picture)
.toSorted()
.slice(0, 15) as pubkey (pubkey)}
<div class="z-feature -mr-3 inline-block"> <div class="z-feature -mr-3 inline-block">
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {size} /> <ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {size} />
</div> </div>
+4 -2
View File
@@ -7,8 +7,9 @@
DELETE, DELETE,
isReplaceable, isReplaceable,
getAddress, getAddress,
RelayMode,
} from "@welshman/util" } from "@welshman/util"
import {pubkey, publishThunk, repository} from "@welshman/app" import {pubkey, publishThunk, repository, derivePubkeyRelays} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl" import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
@@ -19,12 +20,13 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {logout} from "@app/core/commands" import {logout} from "@app/core/commands"
import {INDEXER_RELAYS, PLATFORM_NAME, userSpaceUrls, userWriteRelays} from "@app/core/state" import {INDEXER_RELAYS, PLATFORM_NAME, userSpaceUrls} from "@app/core/state"
let progress: number | undefined = $state(undefined) let progress: number | undefined = $state(undefined)
let confirmText = $state("") let confirmText = $state("")
const CONFIRM_TEXT = "permanently delete my nostr account" const CONFIRM_TEXT = "permanently delete my nostr account"
const userWriteRelays = derivePubkeyRelays($pubkey!, RelayMode.Write)
const confirmOk = $derived(confirmText.toLowerCase().trim() === CONFIRM_TEXT) const confirmOk = $derived(confirmText.toLowerCase().trim() === CONFIRM_TEXT)
const showProgress = $derived(progress !== undefined) const showProgress = $derived(progress !== undefined)
+82 -4
View File
@@ -1,19 +1,29 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {shouldUnwrap} from "@welshman/app" import {removeUndefined} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util"
import {shouldUnwrap, manageRelay, deriveProfile, displayProfileByPubkey} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Letter from "@assets/icons/letter-opened.svg?dataurl" import Letter from "@assets/icons/letter-opened.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte" import ProfileInfo from "@app/components/ProfileInfo.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte" import ChatEnable from "@app/components/ChatEnable.svelte"
import {pubkeyLink} from "@app/core/state" import {pubkeyLink, deriveUserIsSpaceAdmin} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {makeChatPath} from "@app/util/routes" import {makeChatPath} from "@app/util/routes"
export type Props = { export type Props = {
@@ -23,15 +33,83 @@
const {pubkey, url}: Props = $props() const {pubkey, url}: Props = $props()
const profile = deriveProfile(pubkey, removeUndefined([url]))
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const back = () => history.back() const back = () => history.back()
const chatPath = makeChatPath([pubkey]) const chatPath = makeChatPath([pubkey])
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
const openChat = () => ($shouldUnwrap ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath})) const openChat = () => ($shouldUnwrap ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
const toggleMenu = (pubkey: string) => {
showMenu = !showMenu
}
const closeMenu = () => {
showMenu = false
}
const banMember = () =>
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url!, {
method: ManagementMethod.BanPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
back()
}
},
})
let showMenu = $state(false)
</script> </script>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Profile showPubkey avatarSize={14} {pubkey} {url} /> <div class="flex justify-between">
<Profile showPubkey avatarSize={14} {pubkey} {url} />
{#if $profile || $userIsAdmin}
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if showMenu}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
{#if $profile}
<li>
<Button onclick={showInfo}>
<Icon icon={Code2} />
User Details
</Button>
</li>
{/if}
{#if $userIsAdmin}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
<ProfileInfo {pubkey} {url} /> <ProfileInfo {pubkey} {url} />
<ProfileBadges {pubkey} {url} /> <ProfileBadges {pubkey} {url} />
<ModalFooter> <ModalFooter>
@@ -41,7 +119,7 @@
</Button> </Button>
<div class="flex gap-2"> <div class="flex gap-2">
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral"> <Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
<ImageIcon alt="Open in Coracle" src="/coracle.png" /> <ImageIcon alt="" src="/coracle.png" />
Open in Coracle Open in Coracle
</Link> </Link>
<Button onclick={openChat} class="btn btn-primary"> <Button onclick={openChat} class="btn btn-primary">
+1 -1
View File
@@ -32,7 +32,7 @@
} }
success = true success = true
pushToast({message: "Success! Please check your inbox and continue when you're ready."}) pushToast({message: "Success! Please check your messages and continue when you're ready."})
await logout() await logout()
} finally { } finally {
+6 -8
View File
@@ -2,8 +2,6 @@
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {NOTE} from "@welshman/util" import {NOTE} from "@welshman/util"
import {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import NoteItem from "@app/components/NoteItem.svelte" import NoteItem from "@app/components/NoteItem.svelte"
interface Props { interface Props {
@@ -24,16 +22,16 @@
<div class="col-4"> <div class="col-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#await events} {#await events}
<p class="center my-12 flex"> <p class="center flex min-h-6">
<Spinner loading /> <span class="loading loading-spinner"></span>
</p> </p>
{:then events} {:then events}
{#each events as event (event.id)} {#each events as event (event.id)}
<div in:fly> <NoteItem {url} {event} />
<NoteItem {url} {event} />
</div>
{:else} {:else}
{@render fallback?.()} <div class="min-h-6">
{@render fallback?.()}
</div>
{/each} {/each}
{/await} {/await}
</div> </div>
+3 -3
View File
@@ -8,7 +8,7 @@
import RelayIcon from "@app/components/RelayIcon.svelte" import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
import {deriveGroupSelections, getSpaceUrlsFromGroupSelections} from "@app/core/state" import {deriveGroupList, getSpaceUrlsFromGroupList} from "@app/core/state"
type Props = { type Props = {
pubkey: string pubkey: string
@@ -16,8 +16,8 @@
const {pubkey}: Props = $props() const {pubkey}: Props = $props()
const selections = deriveGroupSelections(pubkey) const groupList = deriveGroupList(pubkey)
const spaceUrls = $derived(getSpaceUrlsFromGroupSelections($selections)) const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
const back = () => history.back() const back = () => history.back()
</script> </script>
+22 -19
View File
@@ -2,7 +2,7 @@
import cx from "classnames" import cx from "classnames"
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {groupBy, sum, uniq, uniqBy, batch, displayList} from "@welshman/lib" import {groupBy, map, sum, uniq, uniqBy, batch, displayList} from "@welshman/lib"
import { import {
REPORT, REPORT,
REACTION, REACTION,
@@ -15,14 +15,14 @@
DELETE, DELETE,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, EventContent, Zap} from "@welshman/util" import type {TrustedEvent, EventContent, Zap} from "@welshman/util"
import {deriveEvents, deriveEventsMapped} from "@welshman/store" import {deriveArray, deriveEventsById, deriveItemsByKey} from "@welshman/store"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {pubkey, repository, getValidZap, displayProfileByPubkey} from "@welshman/app" import {pubkey, repository, getValidZap, displayProfileByPubkey} from "@welshman/app"
import {isMobile, preventDefault, stopPropagation} from "@lib/html" import {isMobile, preventDefault, stopPropagation} from "@lib/html"
import Danger from "@assets/icons/danger-triangle.svg?dataurl" import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte" import Reaction from "@app/components/Reaction.svelte"
import EventReportDetails from "@app/components/EventReportDetails.svelte" import ReportDetails from "@app/components/ReportDetails.svelte"
import {REACTION_KINDS} from "@app/core/state" import {REACTION_KINDS} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -46,19 +46,22 @@
children, children,
}: Props = $props() }: Props = $props()
const reports = deriveEvents(repository, { const reports = deriveArray(
filters: [{kinds: [REPORT], "#e": [event.id]}], deriveEventsById({repository, filters: [{kinds: [REPORT], "#e": [event.id]}]}),
}) )
const reactions = deriveEvents(repository, { const reactions = deriveArray(
filters: [{kinds: [REACTION], "#e": [event.id]}], deriveEventsById({repository, filters: [{kinds: [REACTION], "#e": [event.id]}]}),
}) )
const zaps = deriveEventsMapped<Zap>(repository, { const zaps = deriveArray(
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}], deriveItemsByKey<Zap>({
itemToEvent: item => item.response, repository,
eventToItem: (response: TrustedEvent) => getValidZap(response, event), getKey: zap => zap.response.id,
}) filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
eventToItem: (response: TrustedEvent) => getValidZap(response, event),
}),
)
const onReactionClick = (events: TrustedEvent[]) => { const onReactionClick = (events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey) const reaction = events.find(e => e.pubkey === $pubkey)
@@ -75,20 +78,20 @@
} }
} }
const onReportClick = () => pushModal(EventReportDetails, {url, event}) const onReportClick = () => pushModal(ReportDetails, {url, event})
const reportReasons = $derived(uniq($reports.map(e => getTag("e", e.tags)?.[2]))) const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
const getReactionKey = (e: TrustedEvent) => getEmojiTag(e.content, e.tags)?.join("") || e.content const getReactionKey = (e: TrustedEvent) => getEmojiTag(e.content, e.tags)?.join("") || e.content
const groupedReactions = $derived( const groupedReactions = $derived(
groupBy( groupBy(
getReactionKey, getReactionKey,
uniqBy(e => `${e.pubkey}${getReactionKey(e)}`, $reactions), uniqBy(e => `${e.pubkey}${getReactionKey(e)}`, $reactions.values()),
), ),
) )
const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps)) const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps.values()))
onMount(() => { onMount(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -137,7 +140,7 @@
data-tip={tooltip} data-tip={tooltip}
class={cx( class={cx(
reactionClass, reactionClass,
"flex-inline btn btn-outline btn-neutral btn-xs gap-1 rounded-full text-xs font-normal", "flex-inline btn btn-outline btn-neutral btn-xs flex items-center gap-1 rounded-full text-xs font-normal",
{ {
tooltip: !noTooltip && !isMobile, tooltip: !noTooltip && !isMobile,
"border-neutral-content/20": !isOwn, "border-neutral-content/20": !isOwn,
+1 -5
View File
@@ -14,8 +14,4 @@
const relay = deriveRelay(url) const relay = deriveRelay(url)
</script> </script>
<ImageIcon <ImageIcon {size} alt="" src={$relay?.icon || RemoteControllerMinimalistic} class={props.class} />
{size}
src={$relay?.icon || RemoteControllerMinimalistic}
alt="Relay image"
class={props.class} />
+7 -13
View File
@@ -1,21 +1,19 @@
<script lang="ts"> <script lang="ts">
import {deriveRelay} from "@welshman/app"
import Ghost from "@assets/icons/ghost-smile.svg?dataurl"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl" import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte" import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {deriveSpaceMembers, deriveUserRooms} from "@app/core/state" import {deriveGroupListPubkeys, deriveUserRooms} from "@app/core/state"
type Props = { type Props = {
url: string url: string
} }
const {url}: Props = $props() const {url}: Props = $props()
const relay = deriveRelay(url)
const rooms = deriveUserRooms(url) const rooms = deriveUserRooms(url)
const members = deriveSpaceMembers(url) const favorited = deriveGroupListPubkeys(url)
</script> </script>
<div class="col-4 text-left"> <div class="col-4 text-left">
@@ -25,11 +23,7 @@
<div class="avatar relative"> <div class="avatar relative">
<div <div
class="center !flex h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300"> class="center !flex h-12 w-12 min-w-12 rounded-full border-2 border-solid border-base-300 bg-base-300">
{#if $relay?.icon} <RelayIcon {url} />
<img alt="" src={$relay.icon} />
{:else}
<Icon icon={Ghost} size={5} />
{/if}
</div> </div>
</div> </div>
{#if $rooms.includes(url)} {#if $rooms.includes(url)}
@@ -49,10 +43,10 @@
</div> </div>
<RelayDescription {url} /> <RelayDescription {url} />
</div> </div>
{#if $members.length > 0} {#if $favorited.size > 0}
<div class="row-2 card2 card2-sm bg-alt"> <div class="row-2 card2 card2-sm bg-alt">
Members: Favorited By:
<ProfileCircles pubkeys={$members} /> <ProfileCircles pubkeys={Array.from($favorited)} />
</div> </div>
{/if} {/if}
</div> </div>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import {REPORT} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {deriveEventsById} from "@welshman/store"
import {repository} from "@welshman/app"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import Button from "@lib/components/Button.svelte"
import ReportItem from "@app/components/ReportItem.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const reports = deriveEventsById({
repository,
filters: [{kinds: [REPORT], "#e": [event.id]}],
})
const back = () => history.back()
const onDelete = () => {
if ($reports.size === 0) {
back()
}
}
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Report Details</div>
{/snippet}
{#snippet info()}
<div>All reports for this event are shown below.</div>
{/snippet}
</ModalHeader>
{#each $reports.values() as report (report.id)}
<div class="card2 card2-sm bg-alt">
<ReportItem {url} event={report} {onDelete} />
</div>
{/each}
<Button class="btn btn-primary" onclick={back}>Got it</Button>
</div>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import {getTag, getIdFilters} from "@welshman/util"
import {load, LOCAL_RELAY_URL} from "@welshman/net"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import ReportMenu from "@app/components/ReportMenu.svelte"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {goToEvent} from "@app/util/routes"
type Props = {
url: string
event: TrustedEvent
onDelete?: () => void
}
const {url, event, onDelete}: Props = $props()
const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags)
const reason = etag?.[2] || ptag?.[2]
const shouldProtect = canEnforceNip70(url)
const onClick = (e: Event, event: TrustedEvent) => {
// @ts-ignore
if (e.target?.classList.contains("profile-name")) {
pushModal(ProfileDetail, {pubkey: event.pubkey, url})
} else {
goToEvent(event)
}
}
const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect})
onDelete?.()
}
</script>
<div class="column gap-4">
<div class="flex justify-between">
<div>
<Profile pubkey={event.pubkey} {url} avatarSize={5} />
<span>
Reported this event
{#if reason}
as "{reason}"
{/if}
</span>
</div>
{#if event.pubkey === $pubkey}
<Button class="btn-default btn" onclick={deleteReport}>Delete Report</Button>
{:else}
<ReportMenu {url} {event} />
{/if}
</div>
{#if event.content}
<div class="border-l-2 border-primary pl-3">
<NoteContent {event} />
</div>
{/if}
<div class="card2 card2-sm bg-alt">
{#if etag}
{#await load({relays: [url, LOCAL_RELAY_URL], filters: getIdFilters([etag[1]])})}
<p>Loading</p>
{:then reportedEvents}
{#if reportedEvents.length === 0}
<p>Unable to find reported note.</p>
{:else}
{@const event = reportedEvents[0]}
<Button class="col-2 w-full" onclick={(e: Event) => onClick(e, event)}>
<div class="flex items-center justify-between gap-2">
<span class="profile-name">
@<ProfileName pubkey={event.pubkey} {url} />
</span>
<span class="text-xs opacity-75">
{formatTimestamp(event.created_at)}
</span>
</div>
<NoteContent {event} />
</Button>
{/if}
{/await}
{:else if ptag}
<Profile pubkey={ptag[1]} />
{/if}
</div>
</div>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import {getTag, ManagementMethod} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags)
const toggleMenu = () => {
isOpen = !isOpen
}
const closeMenu = () => {
isOpen = false
}
const dismissReport = async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [event.id, "Dismissed by admin"],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
history.back()
}
}
const banContent = () => {
const [_, id, reason = ""] = etag!
pushModal(Confirm, {
title: `Delete Content`,
message: `Are you sure you want to delete this content from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [id, reason],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(id)
history.back()
}
},
})
}
const banMember = () => {
const [pubkey, reason = ""] = ptag!
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanPubkey,
params: [pubkey, reason],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
history.back()
}
},
})
}
let isOpen = $state(false)
</script>
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={toggleMenu}>
<Icon icon={MenuDots} />
</Button>
{#if isOpen}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button onclick={dismissReport}>
<Icon icon={InboxOut} />
Dismiss Report
</Button>
</li>
{#if etag}
<li>
<Button class="text-error" onclick={banContent}>
<Icon icon={TrashBin2} />
Remove Content
</Button>
</li>
{/if}
{#if ptag}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
+7 -11
View File
@@ -18,7 +18,7 @@
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileList from "@app/components/ProfileList.svelte" import RoomMembers from "@app/components/RoomMembers.svelte"
import RoomEdit from "@app/components/RoomEdit.svelte" import RoomEdit from "@app/components/RoomEdit.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import RoomImage from "@app/components/RoomImage.svelte" import RoomImage from "@app/components/RoomImage.svelte"
@@ -67,12 +67,7 @@
const leave = () => handleLoading(leaveRoom) const leave = () => handleLoading(leaveRoom)
const showMembers = () => const showMembers = () => pushModal(RoomMembers, {url, h})
pushModal(ProfileList, {
title: "Members",
subtitle: `of ${$room?.name || h}`,
pubkeys: $members,
})
const startDelete = () => const startDelete = () =>
pushModal(Confirm, { pushModal(Confirm, {
@@ -139,11 +134,12 @@
<p>{$room.about}</p> <p>{$room.about}</p>
{/if} {/if}
{#if $members.length > 0} {#if $members.length > 0}
<div class="card2 card2-sm bg-alt flex gap-4"> <div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
<span>Members:</span> <div class="flex items-center gap-4">
<Button onclick={showMembers}> <span>Members:</span>
<ProfileCircles pubkeys={$members} /> <ProfileCircles pubkeys={$members} />
</Button> </div>
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
</div> </div>
{/if} {/if}
<ModalFooter> <ModalFooter>
+17 -21
View File
@@ -6,7 +6,7 @@
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl" import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.svg?dataurl" import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl" import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault, compressFile} from "@lib/html" import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
@@ -30,7 +30,7 @@
const room = $state.snapshot(values) const room = $state.snapshot(values)
if (imageFile) { if (imageFile) {
const {error, result} = await uploadFile(imageFile) const {error, result} = await uploadFile(imageFile, {maxWidth: 128, maxHeight: 128})
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@@ -38,8 +38,6 @@
room.picture = result.url room.picture = result.url
room.pictureMeta = result.tags room.pictureMeta = result.tags
} else if (selectedIcon) {
room.picture = selectedIcon
} }
const createMessage = await waitForThunkError(createRoom(url, room)) const createMessage = await waitForThunkError(createRoom(url, room))
@@ -76,29 +74,34 @@
let loading = $state(false) let loading = $state(false)
let imageFile = $state<File | undefined>() let imageFile = $state<File | undefined>()
let imagePreview = $state(initialValues.picture) let imagePreview = $state(initialValues.picture)
let selectedIcon = $state<string | undefined>()
const handleImageUpload = async (event: Event) => { const handleImageUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0] const file = (event.target as HTMLInputElement).files?.[0]
if (file && file.type.startsWith("image/")) { if (file && file.type.startsWith("image/")) {
selectedIcon = undefined
imageFile = await compressFile(file, {maxWidth: 64, maxHeight: 64})
const reader = new FileReader() const reader = new FileReader()
reader.onload = e => { reader.onload = e => {
imageFile = file
imagePreview = e.target?.result as string imagePreview = e.target?.result as string
} }
reader.readAsDataURL(imageFile) reader.readAsDataURL(file)
} }
} }
const handleIconSelect = (iconUrl: string) => { const handleIconSelect = (iconUrl: string) => {
imageFile = undefined imagePreview = iconUrl
imagePreview = undefined
selectedIcon = iconUrl const parts = iconUrl.split(",")
const imageData = atob(parts[1])
const result = new Uint8Array(imageData.length)
for (let n = 0; n < imageData.length; n++) {
result[n] = imageData.charCodeAt(n)
}
imageFile = new File([result], `icon.svg`, {type: "image/svg+xml"})
} }
</script> </script>
@@ -114,12 +117,7 @@
{#if imagePreview} {#if imagePreview}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span> <span class="text-sm opacity-75">Selected:</span>
<ImageIcon src={imagePreview} alt="Room icon preview" /> <ImageIcon src={imagePreview} alt="" class="rounded-lg" />
</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> </div>
{:else} {:else}
<span class="text-sm opacity-75">No icon selected</span> <span class="text-sm opacity-75">No icon selected</span>
@@ -146,9 +144,7 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
{#if imagePreview} {#if imagePreview}
<ImageIcon src={imagePreview} alt="Room icon preview" /> <ImageIcon src={imagePreview} alt="" class="rounded-lg" />
{:else if selectedIcon}
<Icon icon={selectedIcon} class="h-8 w-8" />
{:else} {:else}
<Icon icon={Hashtag} /> <Icon icon={Hashtag} />
{/if} {/if}
+1 -1
View File
@@ -16,7 +16,7 @@
</script> </script>
{#if $room.picture} {#if $room.picture}
<ImageIcon src={$room.picture} {size} alt="Room icon" /> <ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
{:else} {:else}
<Icon icon={Hashtag} {size} /> <Icon icon={Hashtag} {size} />
{/if} {/if}
+37 -3
View File
@@ -1,15 +1,19 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app" import {ManagementMethod} from "@welshman/util"
import {pubkey, manageRelay, repository} from "@welshman/app"
import Code2 from "@assets/icons/code-2.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl" import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl" import Danger from "@assets/icons/danger.svg?dataurl"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte" import Report from "@app/components/Report.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {deriveUserIsSpaceAdmin} from "@app/core/state"
type Props = { type Props = {
url: string url: string
@@ -19,9 +23,11 @@
const {url, event, onClick}: Props = $props() const {url, event, onClick}: Props = $props()
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const report = () => { const report = () => {
onClick() onClick()
pushModal(EventReport, {url, event}) pushModal(Report, {url, event})
} }
const showInfo = () => { const showInfo = () => {
@@ -33,6 +39,26 @@
onClick() onClick()
pushModal(EventDeleteConfirm, {url, event}) pushModal(EventDeleteConfirm, {url, event})
} }
const showAdminDelete = () =>
pushModal(Confirm, {
title: `Delete Message`,
message: `Are you sure you want to delete this message from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [event.id],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Event has successfully been deleted!"})
repository.removeEvent(event.id)
history.back()
}
},
})
</script> </script>
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md"> <ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md">
@@ -56,5 +82,13 @@
Report Content Report Content
</Button> </Button>
</li> </li>
{#if $userIsAdmin}
<li>
<Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} />
Delete Message
</Button>
</li>
{/if}
{/if} {/if}
</ul> </ul>
+12 -12
View File
@@ -58,12 +58,12 @@
{#if event.pubkey === $pubkey} {#if event.pubkey === $pubkey}
<Button class="btn btn-neutral text-error" onclick={showDelete}> <Button class="btn btn-neutral text-error" onclick={showDelete}>
<Icon size={4} icon={TrashBin2} /> <Icon size={4} icon={TrashBin2} />
Delete Delete Message
</Button> </Button>
{/if} {/if}
<Button class="btn btn-neutral" onclick={showInfo}> <Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon={Code2} /> <Icon size={4} icon={Code2} />
Show JSON Message Info
</Button> </Button>
{#if path} {#if path}
<Link class="btn btn-neutral" href={path}> <Link class="btn btn-neutral" href={path}>
@@ -71,18 +71,18 @@
View Details View Details
</Link> </Link>
{/if} {/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} {#if ENABLE_ZAPS}
<ZapButton replaceState {url} {event} class="btn btn-primary w-full"> <ZapButton replaceState {url} {event} class="btn btn-neutral w-full">
<Icon size={4} icon={Bolt} /> <Icon size={4} icon={Bolt} />
Zap Send Zap
</ZapButton> </ZapButton>
{/if} {/if}
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Reply} />
Send Reply
</Button>
<Button class="btn btn-neutral w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
Send Reaction
</Button>
</div> </div>
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import {waitForThunkError, removeRoomMember} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import RoomName from "@app/components/RoomName.svelte"
import RoomMembersAdd from "@app/components/RoomMembersAdd.svelte"
import {deriveRoom, deriveRoomMembers, deriveUserIsRoomAdmin} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
interface Props {
url: string
h: string
}
const {url, h}: Props = $props()
const room = deriveRoom(url, h)
const members = deriveRoomMembers(url, h)
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
const back = () => history.back()
const toggleMenu = (pubkey: string) => {
menuPubkey = menuPubkey === pubkey ? undefined : pubkey
}
const closeMenu = () => {
menuPubkey = undefined
}
const addMember = () => pushModal(RoomMembersAdd, {url, h})
const removeMember = (pubkey: string) =>
pushModal(Confirm, {
title: "Remove Member",
message: "Are you sure you want to remove this user from the room?",
confirm: async () => {
const error = await waitForThunkError(removeRoomMember(url, $room, pubkey))
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has successfully been removed!"})
back()
}
},
})
let menuPubkey = $state<string | undefined>()
</script>
<div class="column gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">Members</h1>
<p class="ellipsize text-sm opacity-75">of <RoomName {url} {h} /></p>
</div>
{#if $userIsAdmin}
<div class="flex gap-2">
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
</Button>
</div>
{/if}
{#each $members as pubkey (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div>
</div>
{/each}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</div>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import {addRoomMember, waitForThunkError} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RoomName from "@app/components/RoomName.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {pushToast} from "@app/util/toast"
import {deriveRoom} from "@app/core/state"
interface Props {
url: string
h: string
}
const {url, h}: Props = $props()
const room = deriveRoom(url, h)
const back = () => history.back()
const addMember = async () => {
loading = true
try {
const errors = await Promise.all(
pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, $room, pubkey))),
)
for (const error of errors) {
if (error) {
return pushToast({theme: "error", message: errors[0]})
}
}
pushToast({message: "Members have successfully been added!"})
back()
} finally {
loading = false
}
}
let loading = $state(false)
let pubkeys: string[] = $state([])
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Add Members</div>
{/snippet}
{#snippet info()}
<div>to <RoomName {url} {h} /></div>
{/snippet}
</ModalHeader>
<Field>
{#snippet label()}
<p>Search for People</p>
{/snippet}
{#snippet input()}
<ProfileMultiSelect bind:value={pubkeys} />
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={addMember} disabled={loading}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
import {nsecEncode} from "nostr-tools/nip19" import {nsecEncode} from "nostr-tools/nip19"
import {encrypt} from "nostr-tools/nip49" import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@welshman/lib" import {hexToBytes} from "@welshman/lib"
import {makeSecret} from "@welshman/signer" import {makeSecret} from "@welshman/util"
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import {preventDefault, downloadText} from "@lib/html" import {preventDefault, downloadText} from "@lib/html"
import Key from "@assets/icons/key-minimalistic.svg?dataurl" import Key from "@assets/icons/key-minimalistic.svg?dataurl"
+27 -31
View File
@@ -6,12 +6,12 @@
import Pen from "@assets/icons/pen.svg?dataurl" import Pen from "@assets/icons/pen.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl" import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import BillList from "@assets/icons/bill-list.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 Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import SpaceEdit from "@app/components/SpaceEdit.svelte" import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte" import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte" import RelayDescription from "@app/components/RelayDescription.svelte"
@@ -34,25 +34,29 @@
</script> </script>
<div class="column gap-4"> <div class="column gap-4">
<div class="relative flex gap-4"> <div class="flex justify-between">
<div class="relative"> <div class="relative flex gap-4">
<div class="avatar relative"> <div class="relative">
<div <div class="avatar relative">
class="center !flex h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300"> <div
{#if $relay?.icon} class="center !flex h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
<img alt="" src={$relay.icon} /> <RelayIcon {url} size={10} />
{:else} </div>
<Icon icon={Ghost} size={6} />
{/if}
</div> </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> </div>
<div class="flex min-w-0 flex-col gap-1"> {#if $userIsAdmin}
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold"> <Button class="btn btn-primary" onclick={startEdit}>
<RelayName {url} /> <Icon icon={Pen} />
</h1> Edit
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p> </Button>
</div> {/if}
</div> </div>
<RelayDescription {url} /> <RelayDescription {url} />
{#if $relay?.terms_of_service || $relay?.privacy_policy} {#if $relay?.terms_of_service || $relay?.privacy_policy}
@@ -87,18 +91,10 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if $userIsAdmin} <ModalFooter>
<ModalFooter> <Button class="btn btn-link" onclick={back}>
<Button class="btn btn-link" onclick={back}> <Icon icon={AltArrowLeft} />
<Icon icon={AltArrowLeft} /> Go back
Go back </Button>
</Button> </ModalFooter>
<Button class="btn btn-primary" onclick={startEdit}>
<Icon icon={Pen} />
Edit Space
</Button>
</ModalFooter>
{:else}
<Button class="btn btn-primary" onclick={back}>Got it</Button>
{/if}
</div> </div>
+12 -8
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import {uniqBy, prop, append, ifLet} from "@welshman/lib" import {ifLet} from "@welshman/lib"
import type {RelayProfile} from "@welshman/util" import type {RelayProfile} from "@welshman/util"
import {displayRelayUrl, ManagementMethod} from "@welshman/util" import {displayRelayUrl, ManagementMethod} from "@welshman/util"
import {manageRelay, relays, fetchRelayProfileDirectly} from "@welshman/app" import {manageRelay, relaysByUrl, notifyRelay, fetchRelayDirectly} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl" import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl" import SettingsMinimalistic from "@assets/icons/settings-minimalistic.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@@ -57,8 +57,6 @@
if (imageFile) { if (imageFile) {
const {error, result} = await uploadFile(imageFile, {maxWidth: 128, maxHeight: 128}) const {error, result} = await uploadFile(imageFile, {maxWidth: 128, maxHeight: 128})
console.log(imageFile, result)
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
} }
@@ -74,8 +72,14 @@
} }
// Force-reload the relay // Force-reload the relay
ifLet(await fetchRelayProfileDirectly(url), relay => { ifLet(await fetchRelayDirectly(url), relay => {
relays.update($relays => uniqBy(prop("url"), append(relay, $relays))) relaysByUrl.update($relaysByUrl => {
$relaysByUrl.set(url, relay)
return new Map($relaysByUrl)
})
notifyRelay(relay)
}) })
pushToast({message: "Your changes have been saved!"}) pushToast({message: "Your changes have been saved!"})
@@ -145,7 +149,7 @@
{#if imagePreview} {#if imagePreview}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span> <span class="text-sm opacity-75">Selected:</span>
<ImageIcon src={imagePreview} alt="Room icon preview" /> <ImageIcon src={imagePreview} alt="" />
</div> </div>
{:else} {:else}
<span class="text-sm opacity-75">No icon selected</span> <span class="text-sm opacity-75">No icon selected</span>
@@ -172,7 +176,7 @@
{#snippet input()} {#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2"> <label class="input input-bordered flex w-full items-center gap-2">
{#if imagePreview} {#if imagePreview}
<ImageIcon src={imagePreview} alt="Room icon preview" /> <ImageIcon src={imagePreview} alt="" />
{:else} {:else}
<Icon icon={SettingsMinimalistic} /> <Icon icon={SettingsMinimalistic} />
{/if} {/if}
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
import {manageRelay, displayProfileByPubkey} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import SpaceMembersAdd from "@app/components/SpaceMembersAdd.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import {
deriveSpaceMembers,
deriveSpaceBannedPubkeyItems,
deriveUserIsSpaceAdmin,
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
interface Props {
url: string
}
const {url}: Props = $props()
const members = deriveSpaceMembers(url)
const bans = deriveSpaceBannedPubkeyItems(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const back = () => history.back()
const toggleMenu = (pubkey: string) => {
menuPubkey = menuPubkey === pubkey ? undefined : pubkey
}
const closeMenu = () => {
menuPubkey = undefined
}
const showBannedPubkeyItems = () => pushModal(SpaceMembersBanned, {url})
const addMember = () => pushModal(SpaceMembersAdd, {url})
const banMember = (pubkey: string) =>
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
back()
}
},
})
let menuPubkey = $state<string | undefined>()
</script>
<div class="column gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">Members</h1>
<p class="ellipsize text-sm opacity-75">of {displayRelayUrl(url)}</p>
</div>
{#if $userIsAdmin}
<div class="flex gap-2">
<Button class="btn btn-primary" onclick={addMember}>
<Icon icon={AddCircle} />
Add members
</Button>
{#if $bans.length > 0}
<Button class="btn btn-neutral" onclick={showBannedPubkeyItems}>
Banned users ({$bans.length})
</Button>
{/if}
</div>
{/if}
{#each $members as pubkey (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div>
</div>
{/each}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</div>
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {pushToast} from "@app/util/toast"
interface Props {
url: string
}
const {url}: Props = $props()
const back = () => history.back()
const addMember = async () => {
loading = true
try {
const results = await Promise.all(
pubkeys.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) {
if (error) {
return pushToast({theme: "error", message: error})
}
}
pushToast({message: "Members have successfully been added!"})
back()
} finally {
loading = false
}
}
let loading = $state(false)
let pubkeys: string[] = $state([])
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Add Members</div>
{/snippet}
{#snippet info()}
<div>to {displayRelayUrl(url)}</div>
{/snippet}
</ModalHeader>
<Field>
{#snippet label()}
<p>Search for People</p>
{/snippet}
{#snippet input()}
<ProfileMultiSelect bind:value={pubkeys} />
{/snippet}
</Field>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={addMember} disabled={loading}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
</div>
@@ -0,0 +1,95 @@
<script lang="ts">
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Restart from "@assets/icons/restart.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import {deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {pushToast} from "@app/util/toast"
interface Props {
url: string
}
const {url}: Props = $props()
const bans = deriveSpaceBannedPubkeyItems(url)
const back = () => history.back()
const toggleMenu = (pubkey: string) => {
menuPubkey = menuPubkey === pubkey ? undefined : pubkey
}
const closeMenu = () => {
menuPubkey = undefined
}
const restoreMember = async (pubkey: string) => {
const {error} = await manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been restored!"})
back()
}
}
let menuPubkey = $state<string | undefined>()
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Banned users</div>
{/snippet}
{#snippet info()}
<div>on {displayRelayUrl(url)}</div>
{/snippet}
</ModalHeader>
{#each $bans as { pubkey, reason } (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button onclick={() => restoreMember(pubkey)}>
<Icon icon={Restart} />
Restore User
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div>
</div>
{/each}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Got it
</Button>
</ModalFooter>
</div>
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {displayRelayUrl, getTagValue, EVENT_TIME, ZAP_GOAL, THREAD} from "@welshman/util" import {some} from "@welshman/lib"
import {deriveRelay} from "@welshman/app" import {displayRelayUrl, getTagValue, EVENT_TIME, ZAP_GOAL, THREAD, REPORT} from "@welshman/util"
import {deriveRelay, pubkey} from "@welshman/app"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl" import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl" import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import SquareTopDown from "@assets/icons/square-top-down.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl" import Exit from "@assets/icons/logout-3.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl" import Letter from "@assets/icons/letter.svg?dataurl"
import Login from "@assets/icons/login-3.svg?dataurl" import Login from "@assets/icons/login-3.svg?dataurl"
import History from "@assets/icons/history.svg?dataurl" import History from "@assets/icons/history.svg?dataurl"
import Tuning2 from "@assets/icons/tuning-2.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl" import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl" import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl" import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
@@ -32,7 +32,8 @@
import SpaceExit from "@app/components/SpaceExit.svelte" import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import ProfileList from "@app/components/ProfileList.svelte" import SpaceMembers from "@app/components/SpaceMembers.svelte"
import SpaceReports from "@app/components/SpaceReports.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte" import AlertAdd from "@app/components/AlertAdd.svelte"
import Alerts from "@app/components/Alerts.svelte" import Alerts from "@app/components/Alerts.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte" import RoomCreate from "@app/components/RoomCreate.svelte"
@@ -42,14 +43,14 @@
ENABLE_ZAPS, ENABLE_ZAPS,
CONTENT_KINDS, CONTENT_KINDS,
deriveSpaceMembers, deriveSpaceMembers,
deriveEventsForUrl,
deriveUserRooms, deriveUserRooms,
deriveOtherRooms, deriveOtherRooms,
userSpaceUrls, userSpaceUrls,
hasNip29, hasNip29,
alerts, alertsById,
deriveUserCanCreateRoom, deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin, deriveUserIsSpaceAdmin,
deriveEventsForUrl,
} from "@app/core/state" } from "@app/core/state"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -65,8 +66,11 @@
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url) const otherRooms = deriveOtherRooms(url)
const members = deriveSpaceMembers(url) const members = deriveSpaceMembers(url)
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
const userIsAdmin = deriveUserIsSpaceAdmin(url) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
const hasAlerts = $derived(
some(a => getTagValue("feed", a.tags)?.includes(url), $alertsById.values()),
)
const spaceKinds = derived( const spaceKinds = derived(
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]), deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
@@ -83,12 +87,9 @@
const showDetail = () => pushModal(SpaceDetail, {url}, {replaceState}) const showDetail = () => pushModal(SpaceDetail, {url}, {replaceState})
const showMembers = () => const showMembers = () => pushModal(SpaceMembers, {url}, {replaceState})
pushModal(
ProfileList, const showReports = () => pushModal(SpaceReports, {url}, {replaceState})
{url, pubkeys: $members, title: `Members of`, subtitle: displayRelayUrl(url)},
{replaceState},
)
const canCreateRoom = deriveUserCanCreateRoom(url) const canCreateRoom = deriveUserCanCreateRoom(url)
@@ -155,13 +156,13 @@
</li> </li>
{#if $userIsAdmin} {#if $userIsAdmin}
<li> <li>
<Link external href="https://landlubber.coracle.social"> <Button onclick={showReports}>
<Icon icon={Tuning2} /> <Icon icon={Danger} />
Manage Space View Reports ({$reports.length})
<Icon icon={SquareTopDown} size={4} class="opacity-50" /> </Button>
</Link>
</li> </li>
{:else if $relay?.pubkey} {/if}
{#if $relay?.pubkey && $relay.pubkey !== $pubkey}
<li> <li>
<Link href={makeChatPath([$relay.pubkey])}> <Link href={makeChatPath([$relay.pubkey])}>
<Icon icon={Letter} /> <Icon icon={Letter} />
@@ -2,7 +2,7 @@
import MenuDots from "@assets/icons/menu-dots.svg?dataurl" import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte" import SpaceMenu from "@app/components/SpaceMenu.svelte"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
import {pushDrawer} from "@app/util/modal" import {pushDrawer} from "@app/util/modal"
@@ -14,7 +14,7 @@
const status = deriveSocketStatus(url) const status = deriveSocketStatus(url)
const openMenu = () => pushDrawer(MenuSpace, {url}) const openMenu = () => pushDrawer(SpaceMenu, {url})
</script> </script>
<Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden"> <Button onclick={openMenu} class="btn btn-neutral btn-sm relative md:hidden">
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import {REPORT, displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ReportItem from "@app/components/ReportItem.svelte"
import {deriveEventsForUrl} from "@app/core/state"
interface Props {
url: string
}
const {url}: Props = $props()
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
const back = () => history.back()
</script>
<div class="column gap-4">
<div class="flex min-w-0 flex-col gap-1">
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">Reports</h1>
<p class="ellipsize text-sm opacity-75">on {displayRelayUrl(url)}</p>
</div>
{#each $reports as event (event.id)}
<ReportItem {url} {event} />
{/each}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</div>
+1 -1
View File
@@ -21,7 +21,7 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import WalletAsReceivingAddress from "@app/components/WalletAsReceivingAddress.svelte" import WalletAsReceivingAddress from "@app/components/WalletAsReceivingAddress.svelte"
import Divider from "@src/lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
const back = () => history.back() const back = () => history.back()
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import {Invoice} from "@getalby/lightning-tools/bolt11"
import {debounce} from "throttle-debounce"
import {session} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
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 FieldInline from "@lib/components/FieldInline.svelte"
import Scanner from "@lib/components/Scanner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {payInvoice} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
import {clearModals} from "@app/util/modal"
const back = () => history.back()
const onScan = debounce(1000, async (data: string) => {
invoice = new Invoice({pr: data})
sats = invoice.satoshi || 0
})
const confirm = async () => {
loading = true
try {
await payInvoice(invoice!.paymentRequest, sats * 1000)
pushToast({message: `Payment sent!`})
clearModals()
} catch (e) {
console.error(e)
const message = String(e).replace(/^.*Error: /, "")
pushToast({
theme: "error",
message: `Failed to send payment: ${message}`,
})
} finally {
loading = false
}
}
let loading = $state(false)
let invoice: Invoice | undefined = $state()
let sats = $state(0)
</script>
<div class="column gap-4">
<ModalHeader>
{#snippet title()}
<div>Pay with Lightning</div>
{/snippet}
{#snippet info()}
Use your Nostr wallet to send Bitcoin payments over lightning.
{/snippet}
</ModalHeader>
{#if invoice}
<div class="card2 bg-alt flex flex-col gap-2">
{#if $session?.wallet?.type === "webln" && invoice.satoshi === 0}
<p class="text-sm opacity-75">
Uh oh! It looks like your current wallet doesn't support invoices without an amount. See
if you can get a lightning invoice with a pre-set amount.
</p>
{:else}
<FieldInline>
{#snippet label()}
Amount (satoshis)
{/snippet}
{#snippet input()}
<div class="flex flex-grow justify-end">
<label class="input input-bordered flex items-center gap-2">
<Icon icon={Bolt} />
<input
bind:value={sats}
type="number"
class="w-14"
disabled={invoice!.satoshi > 0} />
</label>
</div>
{/snippet}
</FieldInline>
<p class="text-sm opacity-75">
You're about to pay a bitcoin lightning invoice with the following description:
<strong>{invoice.description || "[no description]"}</strong>"
</p>
{/if}
</div>
{:else}
<Scanner onscan={onScan} />
<p class="text-center text-sm opacity-75">
To make a payment, scan a lightning invoice with your camera.
</p>
{/if}
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={confirm} disabled={!invoice || sats === 0 || loading}>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Bolt} />
{/if}
Confirm Payment
</Button>
</ModalFooter>
</div>
+29 -34
View File
@@ -31,7 +31,7 @@ import {
DELETE, DELETE,
REPORT, REPORT,
PROFILE, PROFILE,
INBOX_RELAYS, MESSAGING_RELAYS,
RELAYS, RELAYS,
FOLLOWS, FOLLOWS,
REACTION, REACTION,
@@ -46,7 +46,6 @@ import {
APP_DATA, APP_DATA,
isSignedEvent, isSignedEvent,
makeEvent, makeEvent,
displayProfile,
normalizeRelayUrl, normalizeRelayUrl,
makeList, makeList,
addToListPublicly, addToListPublicly,
@@ -79,21 +78,21 @@ import {
session, session,
repository, repository,
publishThunk, publishThunk,
profilesByPubkey,
tagEvent, tagEvent,
tagEventForReaction, tagEventForReaction,
userRelaySelections, userRelayList,
userInboxRelaySelections, userMessagingRelayList,
nip44EncryptToSelf, nip44EncryptToSelf,
dropSession, dropSession,
tagEventForComment, tagEventForComment,
tagEventForQuote, tagEventForQuote,
waitForThunkError, waitForThunkError,
getPubkeyRelays, getPubkeyRelays,
userBlossomServers, userBlossomServerList,
shouldUnwrap, shouldUnwrap,
} from "@welshman/app" } from "@welshman/app"
import {compressFile} from "@src/lib/html" import {compressFile} from "@lib/html"
import {kv, db} from "@app/core/storage"
import type {SettingsValues, Alert} from "@app/core/state" import type {SettingsValues, Alert} from "@app/core/state"
import { import {
SETTINGS, SETTINGS,
@@ -105,13 +104,11 @@ import {
userSpaceUrls, userSpaceUrls,
userSettingsValues, userSettingsValues,
getSetting, getSetting,
userInboxRelays, userGroupList,
userGroupSelections,
shouldIgnoreError, shouldIgnoreError,
} from "@app/core/state" } from "@app/core/state"
import {loadAlertStatuses} from "@app/core/requests" import {loadAlertStatuses} from "@app/core/requests"
import {platform, platformName, getPushInfo} from "@app/util/push" import {platform, platformName, getPushInfo} from "@app/util/push"
import {preferencesStorageProvider, Collection} from "@src/lib/storage"
// Utils // Utils
@@ -122,13 +119,6 @@ export const getPubkeyHints = (pubkey: string) => {
return hints return hints
} }
export const getPubkeyPetname = (pubkey: string) => {
const profile = profilesByPubkey.get().get(pubkey)
const display = displayProfile(profile)
return display
}
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => { export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) { if (parent) {
const nevent = nip19.neventEncode({ const nevent = nip19.neventEncode({
@@ -156,15 +146,15 @@ export const logout = async () => {
localStorage.clear() localStorage.clear()
await preferencesStorageProvider.clear() await kv.clear()
await Collection.clearAll() await db.clear()
} }
// Synchronization // Synchronization
export const broadcastUserData = async (relays: string[]) => { export const broadcastUserData = async (relays: string[]) => {
const authors = [pubkey.get()!] const authors = [pubkey.get()!]
const kinds = [RELAYS, INBOX_RELAYS, FOLLOWS, PROFILE] const kinds = [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE]
const events = repository.query([{kinds, authors}]) const events = repository.query([{kinds, authors}])
for (const event of events) { for (const event of events) {
@@ -177,7 +167,7 @@ export const broadcastUserData = async (relays: string[]) => {
// List updates // List updates
export const addSpaceMembership = async (url: string) => { export const addSpaceMembership = async (url: string) => {
const list = get(userGroupSelections) || makeList({kind: ROOMS}) const list = get(userGroupList) || makeList({kind: ROOMS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -185,8 +175,8 @@ export const addSpaceMembership = async (url: string) => {
} }
export const removeSpaceMembership = async (url: string) => { export const removeSpaceMembership = async (url: string) => {
const list = get(userGroupSelections) || makeList({kind: ROOMS}) const list = get(userGroupList) || makeList({kind: ROOMS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url const pred = (t: string[]) => normalizeRelayUrl(t[t[0] === "r" ? 1 : 2]) === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -194,7 +184,7 @@ export const removeSpaceMembership = async (url: string) => {
} }
export const addRoomMembership = async (url: string, h: string) => { export const addRoomMembership = async (url: string, h: string) => {
const list = get(userGroupSelections) || makeList({kind: ROOMS}) const list = get(userGroupList) || makeList({kind: ROOMS})
const newTags = [ const newTags = [
["r", url], ["r", url],
["group", h, url], ["group", h, url],
@@ -206,7 +196,7 @@ export const addRoomMembership = async (url: string, h: string) => {
} }
export const removeRoomMembership = async (url: string, h: string) => { export const removeRoomMembership = async (url: string, h: string) => {
const list = get(userGroupSelections) || makeList({kind: ROOMS}) const list = get(userGroupList) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", h, url], t.slice(0, 3)) const pred = (t: string[]) => equals(["group", h, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -215,7 +205,7 @@ export const removeRoomMembership = async (url: string, h: string) => {
} }
export const setRelayPolicy = (url: string, read: boolean, write: boolean) => { export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
const list = get(userRelaySelections) || makeList({kind: RELAYS}) const list = get(userRelayList) || makeList({kind: RELAYS})
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url) const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (read && write) { if (read && write) {
@@ -232,10 +222,10 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
}) })
} }
export const setInboxRelayPolicy = (url: string, enabled: boolean) => { export const setMessagingRelayPolicy = (url: string, enabled: boolean) => {
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS}) const list = get(userMessagingRelayList) || makeList({kind: MESSAGING_RELAYS})
// Only update inbox policies if they already exist or we're adding them // Only update messaging policies if they already exist or we're adding them
if (enabled || getRelaysFromList(list).includes(url)) { if (enabled || getRelaysFromList(list).includes(url)) {
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url) const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
@@ -538,11 +528,13 @@ export const createDmAlert = async () => {
shouldUnwrap.set(true) shouldUnwrap.set(true)
} }
const $pubkey = pubkey.get()!
return createAlert({ return createAlert({
description: `for direct messages.`, description: `for direct messages.`,
feed: makeIntersectionFeed( feed: makeIntersectionFeed(
feedFromFilters([{kinds: [WRAP], "#p": [pubkey.get()!]}]), feedFromFilters([{kinds: [WRAP], "#p": [$pubkey]}]),
makeRelayFeed(...get(userInboxRelays)), makeRelayFeed(...getPubkeyRelays($pubkey, RelayMode.Messaging)),
), ),
}) })
} }
@@ -592,7 +584,7 @@ export const publishLeaveRequest = (params: LeaveRequestParams) =>
export const getWebLn = () => (window as any).webln export const getWebLn = () => (window as any).webln
export const payInvoice = async (invoice: string) => { export const payInvoice = async (invoice: string, msats?: number) => {
const $session = session.get() const $session = session.get()
if (!$session?.wallet) { if (!$session?.wallet) {
@@ -600,8 +592,11 @@ export const payInvoice = async (invoice: string) => {
} }
if ($session.wallet.type === "nwc") { if ($session.wallet.type === "nwc") {
return new nwc.NWCClient($session.wallet.info).payInvoice({invoice}) const params: {invoice: string; amount?: number} = {invoice}
if (msats) params.amount = msats
return new nwc.NWCClient($session.wallet.info).payInvoice(params)
} else if ($session.wallet.type === "webln") { } else if ($session.wallet.type === "webln") {
if (msats) throw new Error("Unable to pay zero invoices with webln")
return getWebLn() return getWebLn()
.enable() .enable()
.then(() => getWebLn().sendPayment(invoice)) .then(() => getWebLn().sendPayment(invoice))
@@ -648,7 +643,7 @@ export const getBlossomServer = async (options: GetBlossomServerOptions = {}) =>
} }
} }
const userUrls = getTagValues("server", getListTags(userBlossomServers.get())) const userUrls = getTagValues("server", getListTags(get(userBlossomServerList)))
for (const url of userUrls) { for (const url of userUrls) {
return normalizeBlossomUrl(url) return normalizeBlossomUrl(url)
+7 -5
View File
@@ -47,10 +47,9 @@ export const makeFeed = ({
element: HTMLElement element: HTMLElement
onExhausted?: () => void onExhausted?: () => void
}) => { }) => {
const initialEvents = getEventsForUrl(url, filters) const seen = new Set<string>()
const seen = new Set(initialEvents.map(e => e.id))
const controller = new AbortController() const controller = new AbortController()
const buffer = writable(initialEvents) const buffer = writable<TrustedEvent[]>([])
const events = writable<TrustedEvent[]>([]) const events = writable<TrustedEvent[]>([])
const insertEvent = (event: TrustedEvent) => { const insertEvent = (event: TrustedEvent) => {
@@ -121,6 +120,10 @@ export const makeFeed = ({
}, },
}) })
for (const event of getEventsForUrl(url, filters)) {
insertEvent(event)
}
return { return {
events, events,
cleanup: () => { cleanup: () => {
@@ -144,7 +147,6 @@ export const makeCalendarFeed = ({
}) => { }) => {
const interval = int(5, DAY) const interval = int(5, DAY)
const controller = new AbortController() const controller = new AbortController()
const initialEvents = getEventsForUrl(url, filters)
let exhaustedScrollers = 0 let exhaustedScrollers = 0
let backwardWindow = [now() - interval, now()] let backwardWindow = [now() - interval, now()]
@@ -154,7 +156,7 @@ export const makeCalendarFeed = ({
const getEnd = (event: TrustedEvent) => parseInt(getTagValue("end", event.tags) || "") const getEnd = (event: TrustedEvent) => parseInt(getTagValue("end", event.tags) || "")
const events = writable(sortBy(getStart, initialEvents)) const events = writable(sortBy(getStart, getEventsForUrl(url, filters)))
const insertEvent = (event: TrustedEvent) => { const insertEvent = (event: TrustedEvent) => {
const start = getStart(event) const start = getStart(event)
+295 -313
View File
@@ -1,33 +1,33 @@
import twColors from "tailwindcss/colors" import twColors from "tailwindcss/colors"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {get, derived, writable} from "svelte/store" import {get, derived, readable, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import { import {
on, on,
gt, gt,
max, max,
find,
spec, spec,
call, call,
first, first,
assoc,
remove,
uniqBy, uniqBy,
sortBy, sortBy,
append,
sort, sort,
prop,
uniq, uniq,
pushToMapKey, indexBy,
partition,
shuffle, shuffle,
parseJson, parseJson,
memoize, memoize,
addToMapKey, addToMapKey,
identity, identity,
groupBy,
always, always,
tryCatch, tryCatch,
fromPairs, fromPairs,
} from "@welshman/lib" } from "@welshman/lib"
import type {Socket} from "@welshman/net" import type {Override} from "@welshman/lib"
import type {RepositoryUpdate} from "@welshman/net"
import { import {
Pool, Pool,
load, load,
@@ -37,7 +37,18 @@ import {
SocketEvent, SocketEvent,
netContext, netContext,
} from "@welshman/net" } from "@welshman/net"
import {collection, custom, throttled, deriveEvents, deriveEventsMapped} from "@welshman/store" import {
getter,
throttled,
deriveArray,
makeDeriveEvent,
makeLoadItem,
makeDeriveItem,
deriveItemsByKey,
deriveEventsByIdByUrl,
deriveEventsByIdForUrl,
getEventsByIdForUrl,
} from "@welshman/store"
import {isKindFeed, findFeed} from "@welshman/feeds" import {isKindFeed, findFeed} from "@welshman/feeds"
import { import {
ALERT_ANDROID, ALERT_ANDROID,
@@ -72,16 +83,14 @@ import {
ROOMS, ROOMS,
THREAD, THREAD,
WRAP, WRAP,
PROFILE,
ZAP_GOAL, ZAP_GOAL,
ZAP_REQUEST, ZAP_REQUEST,
ZAP_RESPONSE, ZAP_RESPONSE,
asDecryptedEvent, asDecryptedEvent,
displayProfile,
getGroupTags, getGroupTags,
getIdFilters,
getListTags, getListTags,
getPubkeyTagValues, getPubkeyTagValues,
getRelaysFromList,
getRelayTagValues, getRelayTagValues,
getTagValue, getTagValue,
getTagValues, getTagValues,
@@ -89,47 +98,33 @@ import {
makeEvent, makeEvent,
normalizeRelayUrl, normalizeRelayUrl,
readList, readList,
RelayMode,
verifyEvent, verifyEvent,
readRoomMeta, readRoomMeta,
makeRoomMeta, makeRoomMeta,
ManagementMethod, ManagementMethod,
} from "@welshman/util" } from "@welshman/util"
import type { import type {TrustedEvent, RelayProfile, PublishedRoomMeta, List, Filter} from "@welshman/util"
TrustedEvent,
RelayProfile,
PublishedList,
PublishedRoomMeta,
List,
Filter,
} from "@welshman/util"
import {decrypt} from "@welshman/signer" import {decrypt} from "@welshman/signer"
import {routerContext, Router} from "@welshman/router" import {routerContext, Router} from "@welshman/router"
import { import {
pubkey, pubkey,
repository, repository,
profilesByPubkey,
tracker, tracker,
makeTrackerStore,
makeRepositoryStore,
createSearch, createSearch,
userFollows, userFollowList,
ensurePlaintext, ensurePlaintext,
thunks,
sign, sign,
signer, signer,
makeOutboxLoader, makeOutboxLoader,
appContext, appContext,
getThunkError, getThunkError,
publishThunk, publishThunk,
userRelaySelections,
userInboxRelaySelections,
deriveRelay, deriveRelay,
makeUserData, makeUserData,
makeUserLoader, makeUserLoader,
manageRelay, manageRelay,
displayProfileByPubkey,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk} from "@welshman/app"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity) export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
@@ -208,87 +203,31 @@ export const entityLink = (entity: string) => `https://coracle.social/${entity}`
export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) => export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
entityLink(nip19.nprofileEncode({pubkey, relays})) entityLink(nip19.nprofileEncode({pubkey, relays}))
export const bootstrapPubkeys = derived(userFollows, $userFollows => { export const bootstrapPubkeys = derived(userFollowList, $userFollowList => {
const appPubkeys = DEFAULT_PUBKEYS.split(",") const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollows))) const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollowList)))
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys] return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
}) })
export const trackerStore = makeTrackerStore() export const deriveEvent = makeDeriveEvent({
repository,
export const repositoryStore = makeRepositoryStore() includeDeleted: true,
onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}),
export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
let attempted = false
const filters = getIdFilters([idOrAddress])
const relays = [...hints, ...INDEXER_RELAYS]
return derived(
deriveEvents(repository, {filters, includeDeleted: true}),
(events: TrustedEvent[]) => {
if (!attempted && events.length === 0) {
load({relays, filters})
attempted = true
}
return events[0]
},
)
}
export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thunks]) => {
const getThunksByEventId = memoize(() => {
const thunksByEventId = new Map<string, Thunk[]>()
for (const thunk of $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)
}
}
return uniq(urls)
}
}) })
export const getEventsForUrl = (url: string, filters: Filter[]) => { export const getEventsForUrl = (url: string, filters: Filter[]) =>
const ids = uniq([ getEventsByIdForUrl({url, tracker, repository, filters}).values()
...tracker.getIds(url),
...get(thunks)
.filter(t => t.options.relays.includes(url))
.map(t => t.event.id),
])
return repository.query(filters.map(assoc("ids", ids)))
}
export const deriveEventsForUrl = (url: string, filters: Filter[]) => export const deriveEventsForUrl = (url: string, filters: Filter[]) =>
derived([trackerStore, thunks], ([$tracker, $thunks]) => { deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters}))
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 deriveRelaySignedEvents = (url: string, filters: Filter[]) =>
})
export const deriveSignedEventsForUrl = (url: string, filters: Filter[]) =>
derived( derived(
[deriveEventsForUrl(url, filters), deriveRelay(url)], [deriveRelay(url), deriveEventsForUrl(url, filters)],
([$events, $relay]) => $events, ([relay, events]) => events,
// Disable this check for now since khatru doesn't support self // khatru doesn't support relay.self, uncomment when it's ready
// $relay?.self ? $events.filter(spec({pubkey: $relay.self})) : [], // filter(spec({pubkey: relay.self}), events)
) )
// Context // Context
@@ -346,35 +285,32 @@ export const defaultSettings = {
report_usage: true, report_usage: true,
report_errors: true, report_errors: true,
send_delay: 0, send_delay: 0,
font_size: 1, font_size: 1.1,
play_notification_sound: true, play_notification_sound: true,
show_notifications_badge: true, show_notifications_badge: true,
} }
export const settings = deriveEventsMapped<Settings>(repository, { export const settingsByPubkey = deriveItemsByKey({
filters: [{kinds: [APP_DATA], "#d": [SETTINGS]}], repository,
itemToEvent: item => item.event,
eventToItem: async (event: TrustedEvent) => ({
event,
values: {...defaultSettings, ...parseJson(await ensurePlaintext(event))},
}),
})
export const {
indexStore: settingsByPubkey,
deriveItem: deriveSettings,
loadItem: loadSettings,
} = collection({
name: "settings",
store: settings,
getKey: settings => settings.event.pubkey, getKey: settings => settings.event.pubkey,
load: makeOutboxLoader(APP_DATA, {"#d": [SETTINGS]}), filters: [{kinds: [APP_DATA], "#d": [SETTINGS]}],
eventToItem: async (event: TrustedEvent) => {
const values = {...defaultSettings, ...parseJson(await ensurePlaintext(event))}
return {event, values}
},
}) })
export const userSettings = makeUserData({ export const getSettingsByPubkey = getter(settingsByPubkey)
mapStore: settingsByPubkey,
loadItem: loadSettings, export const getSettings = (pubkey: string) => getSettingsByPubkey().get(pubkey)
})
export const loadSettings = makeLoadItem(
makeOutboxLoader(APP_DATA, {"#d": [SETTINGS]}),
getSettings,
)
export const userSettings = makeUserData(settingsByPubkey, loadSettings)
export const loadUserSettings = makeUserLoader(loadSettings) export const loadUserSettings = makeUserLoader(loadSettings)
@@ -390,18 +326,6 @@ export const relaysPendingTrust = writable<string[]>([])
export const relaysMostlyRestricted = writable<Record<string, string>>({}) export const relaysMostlyRestricted = writable<Record<string, string>>({})
// Relay selections
export const userReadRelays = derived(userRelaySelections, $l =>
getRelaysFromList($l, RelayMode.Read),
)
export const userWriteRelays = derived(userRelaySelections, $l =>
getRelaysFromList($l, RelayMode.Write),
)
export const userInboxRelays = derived(userInboxRelaySelections, $l => getRelaysFromList($l))
// Alerts // Alerts
export type Alert = { export type Alert = {
@@ -409,9 +333,10 @@ export type Alert = {
tags: string[][] tags: string[][]
} }
export const alerts = deriveEventsMapped<Alert>(repository, { export const alertsById = deriveItemsByKey<Alert>({
repository,
getKey: alert => alert.event.id,
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]}], filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]}],
itemToEvent: item => item.event,
eventToItem: async event => { eventToItem: async event => {
const $signer = signer.get() const $signer = signer.get()
@@ -426,13 +351,13 @@ export const alerts = deriveEventsMapped<Alert>(repository, {
export const getAlertFeed = (alert: Alert) => export const getAlertFeed = (alert: Alert) =>
tryCatch(() => JSON.parse(getTagValue("feed", alert.tags)!)) tryCatch(() => JSON.parse(getTagValue("feed", alert.tags)!))
export const dmAlert = derived(alerts, $alerts => export const dmAlert = derived(alertsById, $alertsById => {
$alerts.find(alert => { for (const alert of $alertsById.values()) {
const feed = getAlertFeed(alert) if (findFeed(getAlertFeed(alert), f => isKindFeed(f) && f.includes(WRAP))) {
return alert
return findFeed(feed, f => isKindFeed(f) && f.includes(WRAP)) }
}), }
) })
// Alert Statuses // Alert Statuses
@@ -441,9 +366,10 @@ export type AlertStatus = {
tags: string[][] tags: string[][]
} }
export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, { export const alertStatusesByAddress = deriveItemsByKey<AlertStatus>({
repository,
filters: [{kinds: [ALERT_STATUS]}], filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event, getKey: alertStatus => getTagValue("d", alertStatus.event.tags)!,
eventToItem: async event => { eventToItem: async event => {
const $signer = signer.get() const $signer = signer.get()
@@ -455,15 +381,10 @@ export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, {
}, },
}) })
export const deriveAlertStatus = (address: string) => export const deriveAlertStatus = makeDeriveItem(alertStatusesByAddress)
derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address))
// Chats // Chats
export const chatMessages = deriveEvents(repository, {
filters: [{kinds: [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]}],
})
export type Chat = { export type Chat = {
id: string id: string
pubkeys: string[] pubkeys: string[]
@@ -472,66 +393,85 @@ export type Chat = {
search_text: string search_text: string
} }
export const makeChatId = (pubkeys: string[]) => sort(uniq(pubkeys.concat(pubkey.get()!))).join(",") export const makeChatId = (pubkeys: string[]) => sort(uniq(pubkeys)).join(",")
export const splitChatId = (id: string) => id.split(",") export const splitChatId = (id: string) => id.split(",")
export const chats = derived( export const chatsById = call(() => {
[pubkey, chatMessages, profilesByPubkey], const chatsById = new Map<string, Chat>()
([$pubkey, $messages, $profilesByPubkey]) => { const chatsByPubkey = new Map<string, Chat[]>()
const messagesByChatId = new Map<string, TrustedEvent[]>()
for (const message of $messages) { const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
const chatId = makeChatId(getPubkeyTagValues(message.tags).concat(message.pubkey)) chat.search_text =
chat.pubkeys.length === 1
? displayProfileByPubkey(chat.pubkeys[0]) + " note to self"
: chat.pubkeys.map(displayProfileByPubkey).join(" ")
pushToMapKey(messagesByChatId, chatId, message) return chat as Chat
}
return readable(chatsById, set => {
const addEvents = (events: TrustedEvent[]) => {
let dirty = false
for (const event of events) {
if ([DIRECT_MESSAGE, DIRECT_MESSAGE_FILE].includes(event.kind)) {
const pubkeys = getPubkeyTagValues(event.tags).concat(event.pubkey)
const id = makeChatId(pubkeys)
const chat = chatsById.get(id)
const messages = append(event, chat?.messages || [])
const last_activity = Math.max(chat?.last_activity || 0, event.created_at)
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
chatsById.set(id, updatedChat)
for (const pubkey of pubkeys) {
const pubkeyChats = chatsByPubkey.get(pubkey) || []
const uniqueChats = uniqBy(chat => chat.id, append(updatedChat, pubkeyChats))
chatsByPubkey.set(pubkey, uniqueChats)
}
dirty = true
}
if (event.kind === PROFILE) {
for (const chat of chatsByPubkey.get(event.pubkey) || []) {
addSearchText(chat)
dirty = true
}
}
}
if (dirty) {
set(chatsById)
}
} }
const displayPubkey = (pubkey: string) => { addEvents(repository.query([{kinds: [DIRECT_MESSAGE, PROFILE]}]))
const profile = $profilesByPubkey.get(pubkey)
return profile ? displayProfile(profile) : "" const unsubscribers = [
} on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)),
]
return sortBy( return () => unsubscribers.forEach(call)
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, uniqBy(prop("id"), events))
const last_activity = messages[0].created_at
const search_text =
pubkeys.length === 0
? displayPubkey($pubkey!) + " note to self"
: pubkeys.map(displayPubkey).join(" ")
return {id, pubkeys, messages, last_activity, search_text}
}),
)
},
)
export const {
indexStore: chatsById,
deriveItem: deriveChat,
loadItem: loadChat,
} = collection({
name: "chats",
store: chats,
getKey: chat => chat.id,
load: always(Promise.resolve()),
}) })
export const chatSearch = derived(chats, $chats => export const deriveChat = call(() => {
createSearch($chats, { const _deriveChat = makeDeriveItem(chatsById)
return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys))
})
export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
return createSearch(Array.from($chatsByPubkey.values()), {
getValue: (chat: Chat) => chat.id, getValue: (chat: Chat) => chat.id,
fuseOptions: {keys: ["search_text"]}, fuseOptions: {keys: ["search_text"]},
}), })
) })
// Rooms // Rooms
export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]})
export type Room = PublishedRoomMeta & { export type Room = PublishedRoomMeta & {
id: string id: string
url: string url: string
@@ -544,95 +484,108 @@ export const splitRoomId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) => export const hasNip29 = (relay?: RelayProfile) =>
relay?.supported_nips?.map?.(String)?.includes?.("29") relay?.supported_nips?.map?.(String)?.includes?.("29")
export const roomMetas = deriveEventsMapped<PublishedRoomMeta>(repository, { export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
filters: [{kinds: [ROOM_META]}], tracker,
itemToEvent: item => item.event, repository,
eventToItem: readRoomMeta, filters: [{kinds: [ROOM_META, ROOM_DELETE]}],
}) })
export const roomDeletes = deriveEvents(repository, { export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => {
filters: [{kinds: [ROOM_DELETE]}], const metaByIdByUrl = new Map<string, Map<string, Room>>()
})
export const rooms = derived( for (const [url, events] of roomMetaEventsByIdByUrl.entries()) {
[roomMetas, roomDeletes, getUrlsForEvent], const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values())
([$roomMetas, $roomDeletes, $getUrlsForEvent]) => {
const result = new Map<string, Room>()
const deletedByH = new Map<string, number>() const deletedByH = new Map<string, number>()
for (const event of $roomDeletes) { for (const event of deleteEvents) {
for (const h of getTagValues("h", event.tags)) { for (const h of getTagValues("h", event.tags)) {
deletedByH.set(h, max([deletedByH.get(h), event.created_at])) deletedByH.set(h, max([deletedByH.get(h), event.created_at]))
} }
} }
for (const meta of $roomMetas) { for (const event of metaEvents) {
const meta = readRoomMeta(event)
if (gt(deletedByH.get(meta.h), meta.event.created_at)) { if (gt(deletedByH.get(meta.h), meta.event.created_at)) {
continue continue
} }
for (const url of $getUrlsForEvent(meta.event.id)) { let metaById = metaByIdByUrl.get(url)
const id = makeRoomId(url, meta.h) if (!metaById) {
metaById = new Map()
result.set(id, {...meta, url, id}) metaByIdByUrl.set(url, metaById)
} }
}
return Array.from(result.values()) const id = makeRoomId(url, meta.h)
},
metaById.set(id, {...meta, url, id})
}
}
const result = new Map<string, Room[]>()
for (const [url, metaById] of metaByIdByUrl.entries()) {
result.set(url, Array.from(metaById.values()))
}
return result
})
export const roomsById = derived(roomsByUrl, roomsByUrl =>
indexBy(room => room.id, Array.from(roomsByUrl.values()).flatMap(identity)),
) )
export const roomsByUrl = derived(rooms, $rooms => groupBy(c => c.url, $rooms)) export const getRoomsById = getter(roomsById)
export const { export const getRoom = (id: string) => getRoomsById().get(id)
indexStore: roomsById,
deriveItem: _deriveRoom, export const loadRoom = call(() => {
loadItem: _loadRoom, const _fetchRoom = async (id: string) => {
} = collection({
name: "rooms",
store: rooms,
getKey: room => room.id,
load: async (id: string) => {
const [url, h] = splitRoomId(id) const [url, h] = splitRoomId(id)
await load({ await load({
relays: [url], relays: [url],
filters: [{kinds: [ROOM_META], "#d": [h]}], filters: [{kinds: [ROOM_META], "#d": [h]}],
}) })
}, }
const _loadRoom = makeLoadItem(_fetchRoom, getRoom)
return (url: string, h: string) => _loadRoom(makeRoomId(url, h))
}) })
export const deriveRoom = (url: string, h: string) => export const deriveRoom = call(() => {
derived(_deriveRoom(makeRoomId(url, h)), $meta => $meta || makeRoomMeta({h})) const _deriveRoom = makeDeriveItem(roomsById, loadRoom)
export const displayRoom = (url: string, h: string) => return (url: string, h: string) =>
roomsById.get().get(makeRoomId(url, h))?.name || h derived(_deriveRoom(makeRoomId(url, h)), room => room || makeRoomMeta({h}))
})
export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h))?.name || h
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
// User space/room selections // User space/room lists
export const groupSelections = deriveEventsMapped<PublishedList>(repository, { export const groupListsByPubkey = deriveItemsByKey({
repository,
filters: [{kinds: [ROOMS]}], filters: [{kinds: [ROOMS]}],
itemToEvent: item => item.event, getKey: list => list.event.pubkey,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
}) })
export const { export const getGroupListsByPubkey = getter(groupListsByPubkey)
indexStore: groupSelectionsByPubkey,
deriveItem: deriveGroupSelections,
loadItem: loadGroupSelections,
} = collection({
name: "groupSelections",
store: groupSelections,
getKey: list => list.event.pubkey,
load: makeOutboxLoader(ROOMS),
})
export const groupSelectionsPubkeysByUrl = derived(groupSelections, $groupSelections => { export const getGroupList = (pubkey: string) => getGroupListsByPubkey().get(pubkey)
export const loadGroupList = makeLoadItem(makeOutboxLoader(ROOMS), getGroupList)
export const deriveGroupList = makeDeriveItem(groupListsByPubkey, loadGroupList)
export const groupListPubkeysByUrl = derived(groupListsByPubkey, $groupListsByPubkey => {
const result = new Map<string, Set<string>>() const result = new Map<string, Set<string>>()
for (const list of $groupSelections) { for (const list of $groupListsByPubkey.values()) {
const tags = getListTags(list) const tags = getListTags(list)
for (const url of getRelayTagValues(tags)) { for (const url of getRelayTagValues(tags)) {
@@ -651,8 +604,11 @@ export const groupSelectionsPubkeysByUrl = derived(groupSelections, $groupSelect
return result return result
}) })
export const getSpaceUrlsFromGroupSelections = ($groupSelections: List | undefined) => { export const deriveGroupListPubkeys = (url: string) =>
const tags = getListTags($groupSelections) derived(groupListPubkeysByUrl, $groupListPubkeysByUrl => new Set($groupListPubkeysByUrl.get(url)))
export const getSpaceUrlsFromGroupList = (groupList: List | undefined) => {
const tags = getListTags(groupList)
const urls = getRelayTagValues(tags) const urls = getRelayTagValues(tags)
for (const tag of getGroupTags(tags)) { for (const tag of getGroupTags(tags)) {
@@ -666,13 +622,10 @@ export const getSpaceUrlsFromGroupSelections = ($groupSelections: List | undefin
return uniq(urls.map(normalizeRelayUrl)) return uniq(urls.map(normalizeRelayUrl))
} }
export const getSpaceRoomsFromGroupSelections = ( export const getSpaceRoomsFromGroupList = (url: string, groupList: List | undefined) => {
url: string,
$groupSelections: List | undefined,
) => {
const rooms: string[] = [] const rooms: string[] = []
for (const [_, h, relay] of getGroupTags(getListTags($groupSelections))) { for (const [_, h, relay] of getGroupTags(getListTags(groupList))) {
if (url === relay) { if (url === relay) {
rooms.push(h) rooms.push(h)
} }
@@ -681,20 +634,17 @@ export const getSpaceRoomsFromGroupSelections = (
return sortBy(roomComparator(url), rooms) return sortBy(roomComparator(url), rooms)
} }
export const userGroupSelections = makeUserData({ export const userGroupList = makeUserData(groupListsByPubkey, loadGroupList)
mapStore: groupSelectionsByPubkey,
loadItem: loadGroupSelections,
})
export const loadUserGroupSelections = makeUserLoader(loadGroupSelections) export const loadUserGroupList = makeUserLoader(loadGroupList)
export const userSpaceUrls = derived(userGroupSelections, getSpaceUrlsFromGroupSelections) export const userSpaceUrls = derived(userGroupList, getSpaceUrlsFromGroupList)
export const deriveUserRooms = (url: string) => export const deriveUserRooms = (url: string) =>
derived([userGroupSelections, roomsById], ([$userGroupSelections, $roomsById]) => { derived([userGroupList, roomsById], ([$userGroupList, $roomsById]) => {
const rooms: string[] = [] const rooms: string[] = []
for (const h of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) { for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
if ($roomsById.has(makeRoomId(url, h))) { if ($roomsById.has(makeRoomId(url, h))) {
rooms.push(h) rooms.push(h)
} }
@@ -720,14 +670,12 @@ export const deriveOtherRooms = (url: string) =>
export const deriveSpaceMembers = (url: string) => export const deriveSpaceMembers = (url: string) =>
derived( derived(
deriveSignedEventsForUrl(url, [ deriveRelaySignedEvents(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}]),
{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]},
]),
$events => { $events => {
const membersEvent = $events.find(spec({kind: RELAY_MEMBERS})) const membersEvent = $events.find(spec({kind: RELAY_MEMBERS}))
if (membersEvent) { if (membersEvent) {
return getTagValues("member", membersEvent.tags) return uniq(getTagValues("member", membersEvent.tags))
} }
const members = new Set<string>() const members = new Set<string>()
@@ -752,43 +700,63 @@ export const deriveSpaceMembers = (url: string) =>
}, },
) )
export const deriveRoomMembers = (url: string, h: string) => export type BannedPubkeyItem = {
derived( pubkey: string
deriveEventsForUrl(url, [ reason: string
{kinds: [ROOM_MEMBERS], "#d": [h]}, }
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
]),
$events => {
const membersEvent = $events.find(spec({kind: ROOM_MEMBERS}))
if (membersEvent) { export const spaceBannedPubkeyItems = new Map<string, BannedPubkeyItem[]>()
return getPubkeyTagValues(membersEvent.tags)
}
const members = new Set<string>() export const deriveSpaceBannedPubkeyItems = (url: string) => {
const store = writable(spaceBannedPubkeyItems.get(url) || [])
for (const event of sortBy(e => -e.created_at, $events)) { manageRelay(url, {method: ManagementMethod.ListBannedPubkeys, params: []}).then(res => {
const pubkeys = getPubkeyTagValues(event.tags) spaceBannedPubkeyItems.set(url, res.result)
store.set(res.result)
})
if (event.kind === ROOM_ADD_MEMBER) { return store
for (const pubkey of pubkeys) { }
members.add(pubkey)
}
}
if (event.kind === ROOM_REMOVE_MEMBER) { export const deriveRoomMembers = (url: string, h: string) => {
for (const pubkey of pubkeys) { const filters: Filter[] = [
members.delete(pubkey) {kinds: [ROOM_MEMBERS], "#d": [h]},
} {kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
]
return derived(deriveEventsForUrl(url, filters), $events => {
const membersEvent = find(spec({kind: ROOM_MEMBERS}), $events)
if (membersEvent) {
return uniq(getPubkeyTagValues(membersEvent.tags))
}
const members = new Set<string>()
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)
} }
} }
return Array.from(members) if (event.kind === ROOM_REMOVE_MEMBER) {
}, for (const pubkey of pubkeys) {
) members.delete(pubkey)
}
}
}
export const deriveRoomAdmins = (url: string, h: string) => return Array.from(members)
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ADMINS], "#d": [h]}]), $events => { })
}
export const deriveRoomAdmins = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}]
return derived(deriveEventsForUrl(url, filters), $events => {
const adminsEvent = first($events) const adminsEvent = first($events)
if (adminsEvent) { if (adminsEvent) {
@@ -797,6 +765,7 @@ export const deriveRoomAdmins = (url: string, h: string) =>
return [] return []
}) })
}
// User membership status // User membership status
@@ -806,22 +775,26 @@ export enum MembershipStatus {
Granted, Granted,
} }
export const deriveUserIsSpaceAdmin = (url: string) => { export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
const store = writable(false) const store = writable(false)
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res => if (url) {
store.set(Boolean(res.result?.length)), manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res =>
) store.set(Boolean(res.result?.length)),
)
}
return store return store
} })
export const deriveUserSpaceMembershipStatus = (url: string) => export const deriveUserSpaceMembershipStatus = (url: string) => {
derived( const filters: Filter[] = [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]
return derived(
[ [
pubkey, pubkey,
deriveSpaceMembers(url), deriveSpaceMembers(url),
deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]), deriveEventsForUrl(url, filters),
deriveUserIsSpaceAdmin(url), deriveUserIsSpaceAdmin(url),
], ],
([$pubkey, $members, $events, $isAdmin]) => { ([$pubkey, $members, $events, $isAdmin]) => {
@@ -844,6 +817,7 @@ export const deriveUserSpaceMembershipStatus = (url: string) =>
return isMember ? MembershipStatus.Granted : MembershipStatus.Initial return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
}, },
) )
}
export const deriveUserIsRoomAdmin = (url: string, h: string) => export const deriveUserIsRoomAdmin = (url: string, h: string) =>
derived( derived(
@@ -851,12 +825,14 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) =>
([$pubkey, $admins, $isSpaceAdmin]) => $isSpaceAdmin || $admins.includes($pubkey!), ([$pubkey, $admins, $isSpaceAdmin]) => $isSpaceAdmin || $admins.includes($pubkey!),
) )
export const deriveUserRoomMembershipStatus = (url: string, h: string) => export const deriveUserRoomMembershipStatus = (url: string, h: string) => {
derived( const filters: Filter[] = [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]
return derived(
[ [
pubkey, pubkey,
deriveRoomMembers(url, h), deriveRoomMembers(url, h),
deriveEventsForUrl(url, [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]), deriveEventsForUrl(url, filters),
deriveUserIsRoomAdmin(url, h), deriveUserIsRoomAdmin(url, h),
], ],
([$pubkey, $members, $events, $isAdmin]) => { ([$pubkey, $members, $events, $isAdmin]) => {
@@ -879,16 +855,24 @@ export const deriveUserRoomMembershipStatus = (url: string, h: string) =>
return isMember ? MembershipStatus.Granted : MembershipStatus.Initial return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
}, },
) )
}
export const deriveUserCanCreateRoom = (url: string) => export const deriveUserCanCreateRoom = (url: string) => {
derived( const filters: Filter[] = [{kinds: [ROOM_CREATE_PERMISSION]}]
[pubkey, deriveEventsForUrl(url, [{kinds: [ROOM_CREATE_PERMISSION]}])],
([$pubkey, $events]) => {
const event = first($events)
return event ? getPubkeyTagValues(event.tags).includes($pubkey!) : true return derived(
[pubkey, deriveEventsForUrl(url, filters), deriveUserIsSpaceAdmin(url)],
([$pubkey, $events, $isAdmin]) => {
for (const event of $events) {
if (getPubkeyTagValues(event.tags).includes($pubkey!)) {
return true
}
}
return $isAdmin
}, },
) )
}
// Other utils // Other utils
@@ -907,13 +891,10 @@ export const displayReaction = (content: string) => {
return content return content
} }
export const deriveSocket = (url: string) => export const deriveSocket = (url: string) => {
custom<Socket>(set => { const socket = Pool.get().get(url)
const pool = Pool.get()
const socket = pool.get(url)
set(socket)
return readable(socket, set => {
const subs = [ const subs = [
on(socket, SocketEvent.Error, () => set(socket)), on(socket, SocketEvent.Error, () => set(socket)),
on(socket, SocketEvent.Status, () => set(socket)), on(socket, SocketEvent.Status, () => set(socket)),
@@ -922,6 +903,7 @@ export const deriveSocket = (url: string) =>
return () => subs.forEach(call) return () => subs.forEach(call)
}) })
}
export const deriveSocketStatus = (url: string) => export const deriveSocketStatus = (url: string) =>
throttled( throttled(
+52
View File
@@ -0,0 +1,52 @@
import {call} from "@welshman/lib"
import {Preferences} from "@capacitor/preferences"
import {Filesystem, Directory} from "@capacitor/filesystem"
import {IDB} from "@lib/indexeddb"
export const kv = call(() => {
let p = Promise.resolve()
const get = async <T>(key: string): Promise<T | undefined> => {
const result = await Preferences.get({key})
if (!result.value) return undefined
try {
return JSON.parse(result.value)
} catch (e) {
return undefined
}
}
const set = async <T>(key: string, value: T): Promise<void> => {
p = p.then(() => Preferences.set({key, value: JSON.stringify(value)}))
await p
}
const clear = async () => {
p = p.then(() => Preferences.clear())
await p
}
return {get, set, clear}
})
export const db = new IDB({name: "flotilla-9gl", version: 1})
// Migration - we used to use capacitor's filesystem for storage, clear it out since we're
// going back to indexeddb
call(async () => {
const res = await Filesystem.readdir({
path: "",
directory: Directory.Data,
})
await Promise.all(
res.files.map(file =>
Filesystem.deleteFile({
path: file.name,
directory: Directory.Data,
}),
),
)
})
+65 -98
View File
@@ -24,15 +24,16 @@ import {request, load, pull} from "@welshman/net"
import { import {
pubkey, pubkey,
loadRelay, loadRelay,
userFollows, userFollowList,
userRelaySelections, userRelayList,
userInboxRelaySelections, userMessagingRelayList,
loadRelaySelections, loadRelayList,
loadInboxRelaySelections, loadMessagingRelayList,
loadBlossomServers, loadBlossomServerList,
loadFollows, loadFollowList,
loadMutes, loadMuteList,
loadProfile, loadProfile,
tracker,
repository, repository,
shouldUnwrap, shouldUnwrap,
hasNegentropy, hasNegentropy,
@@ -43,14 +44,13 @@ import {
CONTENT_KINDS, CONTENT_KINDS,
INDEXER_RELAYS, INDEXER_RELAYS,
loadSettings, loadSettings,
loadGroupSelections, loadGroupList,
userSpaceUrls, userSpaceUrls,
userGroupSelections, userGroupList,
bootstrapPubkeys, bootstrapPubkeys,
decodeRelay, decodeRelay,
getUrlsForEvent, getSpaceUrlsFromGroupList,
getSpaceUrlsFromGroupSelections, getSpaceRoomsFromGroupList,
getSpaceRoomsFromGroupSelections,
makeCommentFilter, makeCommentFilter,
} from "@app/core/state" } from "@app/core/state"
import {loadAlerts, loadAlertStatuses} from "@app/core/requests" import {loadAlerts, loadAlertStatuses} from "@app/core/requests"
@@ -65,7 +65,6 @@ type PullOpts = {
} }
const pullWithFallback = ({relays, filters, signal}: PullOpts) => { const pullWithFallback = ({relays, filters, signal}: PullOpts) => {
const $getUrlsForEvent = get(getUrlsForEvent)
const [smart, dumb] = partition(hasNegentropy, relays) const [smart, dumb] = partition(hasNegentropy, relays)
const events = repository.query(filters, {shouldSort: false}).filter(isSignedEvent) const events = repository.query(filters, {shouldSort: false}).filter(isSignedEvent)
const promises: Promise<TrustedEvent[]>[] = [pull({relays: smart, filters, signal, events})] const promises: Promise<TrustedEvent[]>[] = [pull({relays: smart, filters, signal, events})]
@@ -73,7 +72,7 @@ const pullWithFallback = ({relays, filters, signal}: PullOpts) => {
// Since pulling from relays without negentropy is expensive, limit how many // Since pulling from relays without negentropy is expensive, limit how many
// duplicates we repeatedly download // duplicates we repeatedly download
for (const url of dumb) { for (const url of dumb) {
const urlEvents = events.filter(e => $getUrlsForEvent(e.id).includes(url)) const urlEvents = events.filter(e => tracker.getRelays(e.id).has(url))
if (urlEvents.length >= 100) { if (urlEvents.length >= 100) {
filters = filters.map(assoc("since", sortBy(e => -e.created_at, urlEvents)[10]!.created_at)) filters = filters.map(assoc("since", sortBy(e => -e.created_at, urlEvents)[10]!.created_at))
@@ -138,10 +137,9 @@ const syncUserSpaceMembership = (url: string) => {
relays: [url], relays: [url],
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{ {kinds: [RELAY_ADD_MEMBER], "#p": [$pubkey], limit: 1},
kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, ROOM_CREATE_PERMISSION], {kinds: [RELAY_REMOVE_MEMBER], "#p": [$pubkey], limit: 1},
"#p": [$pubkey], {kinds: [ROOM_CREATE_PERMISSION], "#p": [$pubkey], limit: 1},
},
], ],
}) })
} }
@@ -158,11 +156,8 @@ const syncUserRoomMembership = (url: string, h: string) => {
relays: [url], relays: [url],
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{ {kinds: [ROOM_ADD_MEMBER], "#p": [$pubkey], "#h": [h], limit: 1},
kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], {kinds: [ROOM_REMOVE_MEMBER], "#p": [$pubkey], "#h": [h], limit: 1},
"#p": [$pubkey],
"#h": [h],
},
], ],
}) })
} }
@@ -173,20 +168,18 @@ const syncUserRoomMembership = (url: string, h: string) => {
const syncUserData = () => { const syncUserData = () => {
const unsubscribersByKey = new Map<string, Unsubscriber>() const unsubscribersByKey = new Map<string, Unsubscriber>()
const unsubscribeGroupSelections = userGroupSelections.subscribe($l => { const unsubscribeGroupList = userGroupList.subscribe($userGroupList => {
const $pubkey = pubkey.get() if ($userGroupList) {
if ($pubkey) {
const keys = new Set<string>() const keys = new Set<string>()
for (const url of getSpaceUrlsFromGroupSelections($l)) { for (const url of getSpaceUrlsFromGroupList($userGroupList)) {
if (!unsubscribersByKey.has(url)) { if (!unsubscribersByKey.has(url)) {
unsubscribersByKey.set(url, syncUserSpaceMembership(url)) unsubscribersByKey.set(url, syncUserSpaceMembership(url))
} }
keys.add(url) keys.add(url)
for (const h of getSpaceRoomsFromGroupSelections(url, $l)) { for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
const key = `${url}'${h}` const key = `${url}'${h}`
if (!unsubscribersByKey.has(key)) { if (!unsubscribersByKey.has(key)) {
@@ -206,50 +199,41 @@ const syncUserData = () => {
} }
}) })
const unsubscribeSelections = userRelaySelections.subscribe($l => { const unsubscribeRelayList = userRelayList.subscribe($userRelayList => {
const $pubkey = pubkey.get() if ($userRelayList) {
loadAlerts($userRelayList.event.pubkey)
if ($pubkey) { loadAlertStatuses($userRelayList.event.pubkey)
loadAlerts($pubkey) loadBlossomServerList($userRelayList.event.pubkey)
loadAlertStatuses($pubkey) loadFollowList($userRelayList.event.pubkey)
loadBlossomServers($pubkey) loadGroupList($userRelayList.event.pubkey)
loadFollows($pubkey) loadMuteList($userRelayList.event.pubkey)
loadGroupSelections($pubkey) loadProfile($userRelayList.event.pubkey)
loadMutes($pubkey) loadSettings($userRelayList.event.pubkey)
loadProfile($pubkey)
loadSettings($pubkey)
} }
}) })
const unsubscribeFollows = userFollows.subscribe(async $l => { const unsubscribeFollows = userFollowList.subscribe(async $userFollowList => {
for (const pubkeys of chunk(10, get(bootstrapPubkeys))) { for (const pubkeys of chunk(10, get(bootstrapPubkeys))) {
// This isn't urgent, avoid clogging other stuff up // This isn't urgent, avoid clogging other stuff up
await sleep(1000) await sleep(1000)
await Promise.all( await Promise.all(
pubkeys.map(async pk => { pubkeys.map(async pk => {
await loadRelaySelections(pk) await loadRelayList(pk)
await loadGroupSelections(pk) await loadGroupList(pk)
await loadProfile(pk) await loadProfile(pk)
await loadFollows(pk) await loadFollowList(pk)
await loadMutes(pk) await loadMuteList(pk)
}), }),
) )
} }
}) })
const unsubscribePubkey = pubkey.subscribe($pubkey => {
if ($pubkey) {
loadRelaySelections($pubkey)
}
})
return () => { return () => {
unsubscribersByKey.forEach(call) unsubscribersByKey.forEach(call)
unsubscribeGroupSelections() unsubscribeGroupList()
unsubscribeSelections() unsubscribeRelayList()
unsubscribeFollows() unsubscribeFollows()
unsubscribePubkey()
} }
} }
@@ -263,8 +247,8 @@ const syncSpace = (url: string) => {
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{kinds: [RELAY_MEMBERS]}, {kinds: [RELAY_MEMBERS]},
{kinds: [ROOM_META, ROOM_DELETE]},
{kinds: [ROOM_ADMINS, ROOM_MEMBERS]}, {kinds: [ROOM_ADMINS, ROOM_MEMBERS]},
{kinds: [ROOM_META, ROOM_DELETE], limit: 1000},
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]}, {kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]},
{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}, {kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]},
...MESSAGE_KINDS.map(kind => ({kinds: [kind]})), ...MESSAGE_KINDS.map(kind => ({kinds: [kind]})),
@@ -277,54 +261,37 @@ const syncSpace = (url: string) => {
} }
const syncSpaces = () => { const syncSpaces = () => {
const membershipUnsubscribersByUrl = new Map<string, Unsubscriber>() const store = derived([userSpaceUrls, page], identity)
const unsubscribersByUrl = new Map<string, Unsubscriber>()
const unsubscribe = store.subscribe(([$userSpaceUrls, $page]) => {
const urls = Array.from($userSpaceUrls)
if ($page.params.relay) {
urls.push(decodeRelay($page.params.relay))
}
const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => {
// stop syncing removed spaces // stop syncing removed spaces
for (const [url, unsubscribe] of membershipUnsubscribersByUrl.entries()) { for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.includes(url)) { if (!urls.includes(url)) {
membershipUnsubscribersByUrl.delete(url) unsubscribersByUrl.delete(url)
unsubscribe() unsubscribe()
} }
} }
// Start syncing newly added spaces // Start syncing newly added spaces
for (const url of urls) { for (const url of urls) {
if (!membershipUnsubscribersByUrl.has(url)) { if (!unsubscribersByUrl.has(url)) {
membershipUnsubscribersByUrl.set(url, syncSpace(url)) unsubscribersByUrl.set(url, syncSpace(url))
} }
} }
}) })
const pageUnsubscribersByUrl = 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)
// Don't subscribe twice if the user is a member
if (!pageUnsubscribersByUrl.has(url) && !get(userSpaceUrls).includes(url)) {
pageUnsubscribersByUrl.set(url, syncSpace(url))
}
// Clean up old subscriptions
for (const [oldUrl, unsubscribe] of pageUnsubscribersByUrl.entries()) {
if (url !== oldUrl) {
pageUnsubscribersByUrl.delete(oldUrl)
unsubscribe()
}
}
} else {
Array.from(pageUnsubscribersByUrl.values()).forEach(call)
}
})
return () => { return () => {
Array.from(membershipUnsubscribersByUrl.values()).forEach(call) for (const unsubscriber of unsubscribersByUrl.values()) {
Array.from(pageUnsubscribersByUrl.values()).forEach(call) unsubscriber()
unsubscribeSpaceUrls() }
unsubscribePage()
unsubscribe()
} }
} }
@@ -386,10 +353,10 @@ const syncDMs = () => {
unsubscribeAll() unsubscribeAll()
} }
// If we have a pubkey, refresh our user's relay selections then sync our subscriptions // If we have a pubkey, refresh our user's relay list then sync our subscriptions
if ($pubkey && $shouldUnwrap) { if ($pubkey && $shouldUnwrap) {
loadRelaySelections($pubkey) loadRelayList($pubkey)
.then(() => loadInboxRelaySelections($pubkey)) .then(() => loadMessagingRelayList($pubkey))
.then($l => subscribeAll($pubkey, getRelayTagValues(getListTags($l)))) .then($l => subscribeAll($pubkey, getRelayTagValues(getListTags($l))))
} }
@@ -397,20 +364,20 @@ const syncDMs = () => {
}, },
) )
// When user inbox relays change, update synchronization // When user messaging relays change, update synchronization
const unsubscribeSelections = userInboxRelaySelections.subscribe($userInboxRelaySelections => { const unsubscribeList = userMessagingRelayList.subscribe($userMessagingRelayList => {
const $pubkey = pubkey.get() const $pubkey = pubkey.get()
const $shouldUnwrap = shouldUnwrap.get() const $shouldUnwrap = shouldUnwrap.get()
if ($pubkey && $shouldUnwrap) { if ($pubkey && $shouldUnwrap) {
subscribeAll($pubkey, getRelayTagValues(getListTags($userInboxRelaySelections))) subscribeAll($pubkey, getRelayTagValues(getListTags($userMessagingRelayList)))
} }
}) })
return () => { return () => {
unsubscribeAll() unsubscribeAll()
unsubscribePubkey() unsubscribePubkey()
unsubscribeSelections() unsubscribeList()
} }
} }
+6 -5
View File
@@ -10,11 +10,11 @@ import {
profiles, profiles,
searchProfiles, searchProfiles,
handlesByNip05, handlesByNip05,
maxWot, getMaxWot,
wotGraph, getWotGraph,
} from "@welshman/app" } from "@welshman/app"
import type {FileAttributes} from "@welshman/editor" import type {FileAttributes} from "@welshman/editor"
import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor" import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor"
import {makeMentionNodeView} from "@app/editor/MentionNodeView" import {makeMentionNodeView} from "@app/editor/MentionNodeView"
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte" import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import {uploadFile} from "@app/core/commands" import {uploadFile} from "@app/core/commands"
@@ -62,10 +62,10 @@ export const makeEditor = async ({
onSearch: searchProfiles, onSearch: searchProfiles,
getValue: (profile: PublishedProfile) => profile.event.pubkey, getValue: (profile: PublishedProfile) => profile.event.pubkey,
sortFn: ({score = 1, item}) => { sortFn: ({score = 1, item}) => {
const wotScore = wotGraph.get().get(item.event.pubkey) || 0 const wotScore = getWotGraph().get(item.event.pubkey) || 0
const membershipScale = $spaceMembers.includes(item.event.pubkey) ? 2 : 1 const membershipScale = $spaceMembers.includes(item.event.pubkey) ? 2 : 1
return dec(score) * inc(wotScore / maxWot.get()) * membershipScale return dec(score) * inc(wotScore / getMaxWot()) * membershipScale
}, },
fuseOptions: { fuseOptions: {
keys: [ keys: [
@@ -84,6 +84,7 @@ export const makeEditor = async ({
return new Editor({ return new Editor({
content, content,
autofocus, autofocus,
editorProps,
element: document.createElement("div"), element: document.createElement("div"),
extensions: [ extensions: [
WelshmanExtension.configure({ WelshmanExtension.configure({
+21
View File
@@ -0,0 +1,21 @@
import {Capacitor} from "@capacitor/core"
import {Keyboard} from "@capacitor/keyboard"
import {noop} from "@welshman/lib"
export const syncKeyboard = () => {
if (!Capacitor.isNativePlatform()) return noop
const showListener = Keyboard.addListener("keyboardWillShow", () => {
document.body.classList.add("keyboard-open")
})
const hideListener = Keyboard.addListener("keyboardWillHide", () => {
document.body.classList.remove("keyboard-open")
})
return () => {
showListener.then(listener => listener.remove())
hideListener.then(listener => listener.remove())
document.body.classList.remove("keyboard-open")
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import {writable} from "svelte/store" import {writable} from "svelte/store"
import type {Nip46ResponseWithResult} from "@welshman/signer" import type {Nip46ResponseWithResult} from "@welshman/signer"
import {Nip46Broker, makeSecret} from "@welshman/signer" import {Nip46Broker} from "@welshman/signer"
import {makeSecret} from "@welshman/util"
import {PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/core/state" import {PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/core/state"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
+164 -154
View File
@@ -1,8 +1,10 @@
import {derived, get} from "svelte/store" import {derived, get} from "svelte/store"
import {Badge} from "@capawesome/capacitor-badge"
import {synced, throttled} from "@welshman/store" import {synced, throttled} from "@welshman/store"
import {pubkey, relaysByUrl} from "@welshman/app" import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
import {prop, spec, identity, now, groupBy} from "@welshman/lib" import {prop, find, call, spec, first, identity, now, groupBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {deriveEventsByIdByUrl} from "@welshman/store"
import {ZAP_GOAL, EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util" import {ZAP_GOAL, EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
import { import {
makeSpacePath, makeSpacePath,
@@ -14,24 +16,21 @@ import {
makeRoomPath, makeRoomPath,
} from "@app/util/routes" } from "@app/util/routes"
import { import {
chats, chatsById,
hasNip29, hasNip29,
getUrlsForEvent,
repositoryStore,
userSettingsValues, userSettingsValues,
userGroupSelections, userGroupList,
getSpaceUrlsFromGroupSelections, getSpaceUrlsFromGroupList,
getSpaceRoomsFromGroupSelections, getSpaceRoomsFromGroupList,
} from "@app/core/state" } from "@app/core/state"
import {preferencesStorageProvider} from "@src/lib/storage" import {kv} from "@app/core/storage"
import {Badge} from "@capawesome/capacitor-badge"
// Checked state // Checked state
export const checked = synced<Record<string, number>>({ export const checked = synced<Record<string, number>>({
key: "checked", key: "checked",
defaultValue: {}, defaultValue: {},
storage: preferencesStorageProvider, storage: kv,
}) })
export const deriveChecked = (key: string) => derived(checked, prop(key)) export const deriveChecked = (key: string) => derived(checked, prop(key))
@@ -40,159 +39,170 @@ export const setChecked = (key: string) => checked.update(state => ({...state, [
// Derived notifications state // Derived notifications state
export const notifications = derived( export const notifications = call(() => {
throttled( const goalCommentFilters = [{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]}]
1000, const threadCommentFilters = [{kinds: [COMMENT], "#K": [String(THREAD)]}]
derived( const calendarCommentFilters = [{kinds: [COMMENT], "#K": [String(EVENT_TIME)]}]
[pubkey, checked, chats, userGroupSelections, repositoryStore, getUrlsForEvent, relaysByUrl], const messageFilters = [{kinds: [MESSAGE, THREAD, ZAP_GOAL, EVENT_TIME]}]
identity,
return derived(
throttled(
1000,
derived(
[
pubkey,
checked,
chatsById,
userGroupList,
relaysByUrl,
deriveEventsByIdByUrl({tracker, repository, filters: goalCommentFilters}),
deriveEventsByIdByUrl({tracker, repository, filters: threadCommentFilters}),
deriveEventsByIdByUrl({tracker, repository, filters: calendarCommentFilters}),
deriveEventsByIdByUrl({tracker, repository, filters: messageFilters}),
],
identity,
),
), ),
), ([
([ $pubkey,
$pubkey, $checked,
$checked, $chatsById,
$chats, $userGroupList,
$userGroupSelections, $relaysByUrl,
$repository, goalCommentsByUrl,
$getUrlsForEvent, threadCommentsByUrl,
$relaysByUrl, calendarCommentsByUrl,
]) => { messagesByUrl,
const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => { ]) => {
if (!latestEvent || latestEvent.pubkey === $pubkey) { const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => {
return false if (!latestEvent || latestEvent.pubkey === $pubkey) {
}
for (const [entryPath, ts] of Object.entries($checked)) {
const isMatch =
entryPath === "*" ||
entryPath.startsWith(path) ||
(entryPath === "/chat/*" && path.startsWith("/chat/"))
if (isMatch && ts > latestEvent.created_at) {
return false return false
} }
}
return true for (const [entryPath, ts] of Object.entries($checked)) {
} const isMatch =
entryPath === "*" ||
entryPath.startsWith(path) ||
(entryPath === "/chat/*" && path.startsWith("/chat/"))
const paths = new Set<string>() if (isMatch && ts > latestEvent.created_at) {
return false
for (const {pubkeys, messages} of $chats) {
const chatPath = makeChatPath(pubkeys)
if (hasNotification(chatPath, messages[0])) {
paths.add("/chat")
paths.add(chatPath)
}
}
const allGoalComments = $repository.query([{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]}])
const allThreadComments = $repository.query([{kinds: [COMMENT], "#K": [String(THREAD)]}])
const allCalendarComments = $repository.query([{kinds: [COMMENT], "#K": [String(EVENT_TIME)]}])
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 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))
const commentsByGoalId = groupBy(
e => getTagValue("E", e.tags),
goalComments.filter(spec({kind: COMMENT})),
)
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),
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)
}
}
const commentsByEventId = groupBy(
e => getTagValue("E", e.tags),
calendarComments.filter(spec({kind: COMMENT})),
)
for (const [eventId, [comment]] of commentsByEventId.entries()) {
const calendarItemPath = makeCalendarPath(url, eventId)
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 h of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) {
const roomPath = makeRoomPath(url, h)
const latestEvent = messages.find(e => e.tags.some(spec(["h", h])))
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePathMobile)
paths.add(spacePath)
paths.add(roomPath)
} }
} }
} else {
if (hasNotification(messagesPath, messages[0])) { return true
paths.add(spacePathMobile) }
paths.add(spacePath)
paths.add(messagesPath) const paths = new Set<string>()
for (const {pubkeys, messages} of $chatsById.values()) {
const chatPath = makeChatPath(pubkeys)
if (hasNotification(chatPath, messages[0])) {
paths.add("/chat")
paths.add(chatPath)
} }
} }
}
return paths for (const url of getSpaceUrlsFromGroupList($userGroupList)) {
}, 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 goalComments = goalCommentsByUrl.get(url)?.values() || []
const threadComments = threadCommentsByUrl.get(url)?.values() || []
const calendarComments = calendarCommentsByUrl.get(url)?.values() || []
const messages = messagesByUrl.get(url)?.values() || []
const commentsByGoalId = groupBy(
e => getTagValue("E", e.tags),
goalComments.filter(spec({kind: COMMENT})),
)
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),
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)
}
}
const commentsByEventId = groupBy(
e => getTagValue("E", e.tags),
calendarComments.filter(spec({kind: COMMENT})),
)
for (const [eventId, [comment]] of commentsByEventId.entries()) {
const calendarItemPath = makeCalendarPath(url, eventId)
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 h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
const roomPath = makeRoomPath(url, h)
const latestEvent = find(e => e.tags.some(spec(["h", h])), messages)
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePathMobile)
paths.add(spacePath)
paths.add(roomPath)
}
}
} else {
if (hasNotification(messagesPath, first(messages))) {
paths.add(spacePathMobile)
paths.add(spacePath)
paths.add(messagesPath)
}
}
}
return paths
},
)
})
export const badgeCount = derived(notifications, notifications => { export const badgeCount = derived(notifications, notifications => {
return notifications.size return notifications.size
+7 -3
View File
@@ -2,9 +2,9 @@ import type {Page} from "@sveltejs/kit"
import {get} from "svelte/store" import {get} from "svelte/store"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {nthEq, sleep} from "@welshman/lib" import {nthEq, remove, sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {tracker, loadRelay} from "@welshman/app" import {pubkey, tracker, loadRelay} from "@welshman/app"
import {scrollToEvent} from "@lib/html" import {scrollToEvent} from "@lib/html"
import {identity} from "@welshman/lib" import {identity} from "@welshman/lib"
import { import {
@@ -55,7 +55,11 @@ export const goToSpace = async (url: string) => {
} }
} }
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}` export const makeChatPath = (pubkeys: string[]) => {
const id = makeChatId(remove(pubkey.get()!, pubkeys))
return `/chat/${id}`
}
export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(url)}/${h}` export const makeRoomPath = (url: string, h: string) => `/spaces/${encodeRelay(url)}/${h}`
+186 -177
View File
@@ -1,5 +1,5 @@
import {prop, call, on, throttle, fromPairs, batch} from "@welshman/lib" import {on, throttle, indexBy, fromPairs, batch} from "@welshman/lib"
import {throttled, freshness} from "@welshman/store" import {throttled} from "@welshman/store"
import { import {
ALERT_ANDROID, ALERT_ANDROID,
ALERT_EMAIL, ALERT_EMAIL,
@@ -12,7 +12,7 @@ import {
DIRECT_MESSAGE, DIRECT_MESSAGE,
EVENT_TIME, EVENT_TIME,
FOLLOWS, FOLLOWS,
INBOX_RELAYS, MESSAGING_RELAYS,
MESSAGE, MESSAGE,
MUTES, MUTES,
PROFILE, PROFILE,
@@ -38,45 +38,27 @@ import type {Zapper, TrustedEvent, RelayProfile} from "@welshman/util"
import type {RepositoryUpdate, WrapItem} from "@welshman/net" import type {RepositoryUpdate, WrapItem} from "@welshman/net"
import type {Handle, RelayStats} from "@welshman/app" import type {Handle, RelayStats} from "@welshman/app"
import { import {
plaintext,
tracker, tracker,
relays, plaintext,
relayStats,
repository, repository,
handles, relaysByUrl,
zappers, relayStatsByUrl,
onRelayStats,
handlesByNip05,
zappersByLnurl,
onZapper, onZapper,
onHandle, onHandle,
wrapManager, wrapManager,
onRelay,
} from "@welshman/app" } from "@welshman/app"
import {Collection} from "@lib/storage"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import type {IDBTable} from "@lib/indexeddb"
const syncEvents = async () => { const kinds = {
const collection = new Collection<TrustedEvent>({table: "events", getId: prop("id")}) meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID],
const initialEvents = await collection.get() space: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE],
room: [
// Mark events verified to avoid re-verification of signatures
for (const event of initialEvents) {
event[verifiedSymbol] = true
}
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_META,
ROOM_DELETE, ROOM_DELETE,
ROOM_ADMINS, ROOM_ADMINS,
@@ -84,178 +66,205 @@ const syncEvents = async () => {
ROOM_ADD_MEMBER, ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
ROOM_CREATE_PERMISSION, ROOM_CREATE_PERMISSION,
] ],
const contentKinds = [EVENT_TIME, THREAD, MESSAGE, ZAP_GOAL, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] content: [EVENT_TIME, THREAD, MESSAGE, ZAP_GOAL, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE],
}
const rankEvent = (event: TrustedEvent) => { const rankEvent = (event: TrustedEvent) => {
if (metaKinds.includes(event.kind)) return 9 if (kinds.meta.includes(event.kind)) return 9
if (alertKinds.includes(event.kind)) return 8 if (kinds.alert.includes(event.kind)) return 8
if (spaceKinds.includes(event.kind)) return 7 if (kinds.space.includes(event.kind)) return 7
if (roomKinds.includes(event.kind)) return 6 if (kinds.room.includes(event.kind)) return 6
if (!isMobile && contentKinds.includes(event.kind)) return 5 if (!isMobile && kinds.content.includes(event.kind)) return 5
return 0 return 0
} }
return on( const eventsAdapter = {
repository, name: "events",
"update", keyPath: "id",
batch(3000, async (updates: RepositoryUpdate[]) => { init: async (table: IDBTable<TrustedEvent>) => {
const add: TrustedEvent[] = [] const initialEvents = await table.getAll()
const remove = new Set<string>()
for (const update of updates) { // Mark events verified to avoid re-verification of signatures
for (const event of update.added) { for (const event of initialEvents) {
if (rankEvent(event) > 0) { event[verifiedSymbol] = true
add.push(event) }
remove.delete(event.id)
repository.load(initialEvents)
return on(
repository,
"update",
batch(3000, async (updates: RepositoryUpdate[]) => {
const add: TrustedEvent[] = []
const remove = new Set<string>()
for (const update of updates) {
for (const event of update.added) {
if (rankEvent(event) > 0) {
add.push(event)
remove.delete(event.id)
}
}
for (const id of update.removed) {
remove.add(id)
} }
} }
for (const id of update.removed) { if (add.length > 0) {
remove.add(id) await table.bulkPut(add)
} }
if (remove.size > 0) {
await table.bulkDelete(remove)
}
}),
)
},
}
type TrackerItem = {id: string; relays: string[]}
const trackerAdapter = {
name: "tracker",
keyPath: "id",
init: async (table: IDBTable<TrackerItem>) => {
const relaysById = new Map<string, Set<string>>()
for (const {id, relays} of await table.getAll()) {
relaysById.set(id, new Set(relays))
}
tracker.load(relaysById)
const _onAdd = async (ids: Iterable<string>) => {
const items: TrackerItem[] = []
for (const id of ids) {
const event = repository.getEvent(id)
if (!event || rankEvent(event) === 0) continue
const relays = Array.from(tracker.getRelays(id))
if (relays.length === 0) continue
items.push({id, relays})
} }
await collection.update({add, remove}) await table.bulkPut(items)
}), }
)
const _onRemove = async (ids: Iterable<string>) => {
await table.bulkDelete(Array.from(ids))
}
const onAdd = batch(3000, _onAdd)
const onRemove = batch(3000, _onRemove)
const onLoad = () => _onAdd(tracker.relaysById.keys())
const onClear = () => _onRemove(tracker.relaysById.keys())
tracker.on("add", onAdd)
tracker.on("remove", onRemove)
tracker.on("load", onLoad)
tracker.on("clear", onClear)
return () => {
tracker.off("add", onAdd)
tracker.off("remove", onRemove)
tracker.off("load", onLoad)
tracker.off("clear", onClear)
}
},
} }
type TrackerItem = [string, string[]] const relaysAdapter = {
name: "relays",
keyPath: "url",
init: async (table: IDBTable<RelayProfile>) => {
relaysByUrl.set(indexBy(r => r.url, await table.getAll()))
const syncTracker = async () => { return onRelay(batch(1000, table.bulkPut))
const collection = new Collection<TrackerItem>({ },
table: "tracker",
getId: (item: TrackerItem) => item[0],
})
const relaysById = new Map<string, Set<string>>()
for (const [id, relays] of await collection.get()) {
relaysById.set(id, new Set(relays))
}
tracker.load(relaysById)
const updateOne = batch(3000, (ids: string[]) => {
collection.add(ids.map(id => [id, Array.from(tracker.getRelays(id))]))
})
const updateAll = throttle(3000, () => {
collection.set(
Array.from(tracker.relaysById.entries()).map(([id, relays]) => [id, Array.from(relays)]),
)
})
tracker.on("add", updateOne)
tracker.on("remove", updateOne)
tracker.on("load", updateAll)
tracker.on("clear", updateAll)
return () => {
tracker.off("add", updateOne)
tracker.off("remove", updateOne)
tracker.off("load", updateAll)
tracker.off("clear", updateAll)
}
} }
const syncRelays = async () => { const relayStatsAdapter = {
const collection = new Collection<RelayProfile>({table: "relays", getId: prop("url")}) name: "relayStats",
keyPath: "url",
init: async (table: IDBTable<RelayStats>) => {
relayStatsByUrl.set(indexBy(r => r.url, await table.getAll()))
relays.set(await collection.get()) return onRelayStats(batch(1000, table.bulkPut))
},
return throttled(3000, relays).subscribe(collection.set)
} }
const syncRelayStats = async () => { const handlesAdapter = {
const collection = new Collection<RelayStats>({table: "relayStats", getId: prop("url")}) name: "handles",
keyPath: "nip05",
init: async (table: IDBTable<Handle>) => {
handlesByNip05.set(indexBy(r => r.nip05, await table.getAll()))
relayStats.set(await collection.get()) return onHandle(batch(1000, table.bulkPut))
},
return throttled(3000, relayStats).subscribe(collection.set)
} }
const syncHandles = async () => { const zappersAdapter = {
const collection = new Collection<Handle>({table: "handles", getId: prop("nip05")}) name: "zappers",
keyPath: "lnurl",
init: async (table: IDBTable<Zapper>) => {
zappersByLnurl.set(indexBy(z => z.lnurl, await table.getAll()))
handles.set(await collection.get()) return onZapper(batch(3000, table.bulkPut))
},
return onHandle(batch(3000, collection.add))
} }
const syncZappers = async () => { type PlaintextItem = {key: string; value: string}
const collection = new Collection<Zapper>({table: "zappers", getId: prop("lnurl")})
zappers.set(await collection.get()) const plaintextAdapter = {
name: "plaintext",
keyPath: "key",
init: async (table: IDBTable<PlaintextItem>) => {
const initialRecords = await table.getAll()
return onZapper(batch(3000, collection.add)) plaintext.set(fromPairs(initialRecords.map(({key, value}) => [key, value])))
return throttled(3000, plaintext).subscribe($plaintext => {
table.bulkPut(Object.entries($plaintext).map(([key, value]) => ({key, value})))
})
},
} }
type FreshnessItem = [string, number] const wrapManagerAdapter = {
name: "wrapManager",
keyPath: "id",
init: async (table: IDBTable<WrapItem>) => {
wrapManager.load(await table.getAll())
const syncFreshness = async () => { const addOne = batch(3000, table.bulkPut)
const collection = new Collection<FreshnessItem>({
table: "freshness",
getId: (item: FreshnessItem) => item[0],
})
freshness.set(fromPairs(await collection.get())) const removeOne = throttle(3000, table.bulkDelete)
return throttled(3000, freshness).subscribe($freshness => { wrapManager.on("add", addOne)
collection.set(Object.entries($freshness)) wrapManager.on("remove", removeOne)
})
return () => {
wrapManager.off("add", addOne)
wrapManager.off("remove", removeOne)
}
},
} }
type PlaintextItem = [string, string] export const adapters = [
eventsAdapter,
const syncPlaintext = async () => { trackerAdapter,
const collection = new Collection<PlaintextItem>({ relaysAdapter,
table: "plaintext", relayStatsAdapter,
getId: (item: PlaintextItem) => item[0], handlesAdapter,
}) zappersAdapter,
plaintextAdapter,
plaintext.set(fromPairs(await collection.get())) wrapManagerAdapter,
]
return throttled(3000, plaintext).subscribe($plaintext => {
collection.set(Object.entries($plaintext))
})
}
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 promises = [
syncEvents(),
syncTracker(),
syncRelays(),
syncHandles(),
syncZappers(),
syncPlaintext(),
syncWrapManager(),
]
if (!isMobile) {
promises.push(syncFreshness(), syncRelayStats())
}
const unsubscribers = await Promise.all(promises)
return () => unsubscribers.forEach(call)
}
+2 -2
View File
@@ -1,8 +1,8 @@
import {preferencesStorageProvider} from "@src/lib/storage" import {kv} from "@app/core/storage"
import {synced} from "@welshman/store" import {synced} from "@welshman/store"
export const theme = synced({ export const theme = synced({
key: "theme", key: "theme",
defaultValue: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", defaultValue: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
storage: preferencesStorageProvider, storage: kv,
}) })
File diff suppressed because it is too large Load Diff
+19 -9
View File
@@ -11,13 +11,21 @@
const {onClose = noop, fullscreen = false, children}: Props = $props() const {onClose = noop, fullscreen = false, children}: Props = $props()
const extraClass = $derived( const wrapperClass = $derived(
!fullscreen && cx("absolute inset-0 flex sm:relative pointer-events-none", {
cx( "items-center justify-center": fullscreen,
"bg-alt text-base-content overflow-auto text-base-content shadow-md", "items-end sm:w-[520px] sm:items-center": !fullscreen,
"px-4 py-6 bottom-0 left-0 right-0 top-20 rounded-t-box absolute", }),
"sm:p-6 sm:max-h-[90vh] sm:w-[520px] sm:rounded-box sm:relative sm:top-0", )
),
const innerClass = $derived(
cx(
"relative text-base-content text-base-content flex-grow pointer-events-auto",
"px-4 py-6 rounded-t-box sm:p-6 sm:rounded-box sm:mt-0",
{
"bg-alt shadow-m max-h-[90vh] scroll-container overflow-auto": !fullscreen,
},
),
) )
</script> </script>
@@ -28,7 +36,9 @@
transition:fade={{duration: 300}} transition:fade={{duration: 300}}
onclick={onClose}> onclick={onClose}>
</button> </button>
<div class="scroll-container {extraClass}" transition:fly={{duration: 300}}> <div class={wrapperClass}>
{@render children?.()} <div class={innerClass} transition:fly={{duration: 300}}>
{@render children?.()}
</div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -26,4 +26,4 @@
}) })
</script> </script>
<emoji-picker bind:this={element}></emoji-picker> <emoji-picker bind:this={element} class="m-auto"></emoji-picker>
+4 -1
View File
@@ -14,5 +14,8 @@
{#if src.includes("image/svg") || src.endsWith(".svg")} {#if src.includes("image/svg") || src.endsWith(".svg")}
<Icon icon={src} {size} class={props.class} /> <Icon icon={src} {size} class={props.class} />
{:else} {:else}
<img {src} {alt} class="h-{size} w-{size} aspect-square object-cover {props.class}" /> <img
{src}
{alt}
class="h-{size} w-{size} min-w-{size} min-h-{size} aspect-square object-cover {props.class}" />
{/if} {/if}
+1 -1
View File
@@ -14,6 +14,6 @@
{...props} {...props}
bind:this={element} bind:this={element}
data-component="PageContent" data-component="PageContent"
class="scroll-container cw md:bottom-sai fixed bottom-[calc(var(--saib)+3.5rem)] top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden {props.class}"> class="scroll-container cw cb fixed top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden {props.class}">
{@render children?.()} {@render children?.()}
</div> </div>
+38 -1
View File
@@ -5,11 +5,38 @@
const {onscan} = $props() const {onscan} = $props()
const changeCamera = async () => {
if (camera && scanner) {
loading = true
try {
await scanner.setCamera(camera)
} catch (error) {
console.error("Failed to switch camera:", error)
} finally {
loading = false
}
}
}
let video: HTMLVideoElement let video: HTMLVideoElement
let scanner: QrScanner let scanner: QrScanner
let loading = $state(true) let loading = $state(true)
let cameras = $state<QrScanner.Camera[]>([])
let camera = $state<string>("")
onMount(() => { onMount(() => {
QrScanner.listCameras(true)
.then(async () => {
cameras = await QrScanner.listCameras(true)
if (cameras.length > 0) {
camera = cameras[0].id
}
})
.catch(error => {
console.error("Failed to list cameras:", error)
})
scanner = new QrScanner(video, r => onscan(r.data), { scanner = new QrScanner(video, r => onscan(r.data), {
returnDetailedScanResult: true, returnDetailedScanResult: true,
}) })
@@ -22,11 +49,21 @@
}) })
</script> </script>
<div class="bg-alt flex min-h-48 w-full flex-col items-center justify-center rounded p-px"> <div class="bg-alt relative flex min-h-48 w-full flex-col items-center justify-center rounded p-px">
{#if loading} {#if loading}
<p class="py-20"> <p class="py-20">
<Spinner loading>Loading your camera...</Spinner> <Spinner loading>Loading your camera...</Spinner>
</p> </p>
{/if} {/if}
<video class="m-auto rounded" class:h-0={loading} bind:this={video}></video> <video class="m-auto rounded" class:h-0={loading} bind:this={video}></video>
{#if cameras.length > 1}
<select
class="select select-bordered select-sm absolute bottom-1 right-1"
bind:value={camera}
onchange={changeCamera}>
{#each cameras as camera}
<option value={camera.id}>{camera.label || `Camera ${camera.id}`}</option>
{/each}
</select>
{/if}
</div> </div>
+127
View File
@@ -0,0 +1,127 @@
import {openDB, deleteDB} from "idb"
import type {IDBPDatabase} from "idb"
import type {Unsubscriber} from "svelte/store"
import {call} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
export type IDBAdapter = {
name: string
keyPath: string
init: (table: IDBTable<any>) => Promise<Unsubscriber>
}
export type IDBAdapters = IDBAdapter[]
export type IDBOptions = {
name: string
version: number
}
export class IDB {
adapters: IDBAdapters = []
connection: Maybe<Promise<IDBPDatabase>>
unsubscribers: Maybe<Unsubscriber[]>
constructor(readonly options: IDBOptions) {}
async connect() {
if (!this.connection) {
const {name, version} = this.options
const adapters = this.adapters
this.connection = openDB(name, version, {
upgrade(idbDb: IDBPDatabase) {
const names = new Set(adapters.map(a => a.name))
for (const table of idbDb.objectStoreNames) {
if (!names.has(table)) {
idbDb.deleteObjectStore(table)
}
}
for (const {name, keyPath} of adapters) {
try {
idbDb.createObjectStore(name, {keyPath})
} catch (e) {
console.warn(e)
}
}
},
blocked() {},
blocking() {},
})
this.unsubscribers = await Promise.all(adapters.map(({name, init}) => init(this.table(name))))
}
return this.connection
}
table = <T>(name: string) => new IDBTable<T>(this, name)
getAll = async <T>(table: string): Promise<T[]> => {
const connection = await this.connect()
const tx = connection.transaction(table, "readwrite")
const store = tx.objectStore(table)
const result = await store.getAll()
await tx.done
return result || []
}
bulkPut = async <T>(table: string, data: Iterable<T>) => {
const connection = await this.connect()
const tx = connection.transaction(table, "readwrite")
const store = tx.objectStore(table)
await Promise.all(
Array.from(data).map(item => {
try {
store.put(item)
} catch (e) {
console.error(e, item)
}
}),
)
await tx.done
}
bulkDelete = async (table: string, ids: Iterable<string>) => {
const connection = await this.connect()
const tx = connection.transaction(table, "readwrite")
const store = tx.objectStore(table)
await Promise.all(Array.from(ids).map(id => store.delete(id)))
await tx.done
}
close = () => {
this.unsubscribers?.forEach(call)
this.unsubscribers = undefined
this.connection?.then(c => c.close())
this.connection = undefined
}
clear = async () => {
await this.connection?.then(c => c.close())
await deleteDB(this.options.name, {
blocked() {},
})
}
}
export class IDBTable<T> {
constructor(
readonly db: IDB,
readonly name: string,
) {}
getAll = () => this.db.getAll<T>(this.name)
bulkPut = (data: Iterable<T>) => this.db.bulkPut(this.name, data)
bulkDelete = (ids: Iterable<string>) => this.db.bulkDelete(this.name, ids)
}
-108
View File
@@ -1,108 +0,0 @@
import {reject, identity} from "@welshman/lib"
import {type StorageProvider} from "@welshman/store"
import {Preferences} from "@capacitor/preferences"
import {Encoding, Filesystem, Directory} from "@capacitor/filesystem"
export class PreferencesStorageProvider implements StorageProvider {
p = Promise.resolve()
get = async <T>(key: string): Promise<T | undefined> => {
const result = await Preferences.get({key})
if (!result.value) return undefined
try {
return JSON.parse(result.value)
} catch (e) {
return undefined
}
}
set = async <T>(key: string, value: T): Promise<void> => {
this.p = this.p.then(() => Preferences.set({key, value: JSON.stringify(value)}))
await this.p
}
clear = async () => {
this.p = this.p.then(() => Preferences.clear())
await this.p
}
}
export const preferencesStorageProvider = new PreferencesStorageProvider()
export type CollectionOptions<T> = {
table: string
getId: (item: T) => string
}
export class Collection<T> {
constructor(readonly options: CollectionOptions<T>) {}
static clearAll = async (): Promise<void> => {
const res = await Filesystem.readdir({
path: "",
directory: Directory.Data,
})
await Promise.all(
res.files.map(file =>
Filesystem.deleteFile({
path: file.name,
directory: Directory.Data,
}),
),
)
}
#path = () => `collection_${this.options.table}.json`
get = async (): Promise<T[]> => {
try {
const file = await Filesystem.readFile({
path: this.#path(),
directory: Directory.Data,
encoding: Encoding.UTF8,
})
// Speed things up by parsing only once
return JSON.parse("[" + file.data.toString().split("\n").filter(identity).join(",") + "]")
} catch (err) {
// file doesn't exist, or isn't valid json
return []
}
}
set = (items: T[]) =>
Filesystem.writeFile({
path: this.#path(),
directory: Directory.Data,
encoding: Encoding.UTF8,
data: items.map(v => JSON.stringify(v)).join("\n"),
})
add = (items: T[]) =>
Filesystem.appendFile({
path: this.#path(),
directory: Directory.Data,
encoding: Encoding.UTF8,
data: "\n" + items.map(v => JSON.stringify(v)).join("\n"),
})
remove = async (ids: Set<string>) =>
this.set(reject(item => ids.has(this.options.getId(item)), await this.get()))
update = async ({add, remove}: {add?: T[]; remove?: Set<string>}) => {
if (remove && remove.size > 0) {
const items = reject(item => remove.has(this.options.getId(item)), await this.get())
if (add) {
items.push(...add)
}
await this.set(items)
} else if (add && add.length > 0) {
await this.add(add)
}
}
}
+21 -8
View File
@@ -20,23 +20,24 @@
import * as welshmanSigner from "@welshman/signer" import * as welshmanSigner from "@welshman/signer"
import * as net from "@welshman/net" import * as net from "@welshman/net"
import * as app from "@welshman/app" import * as app from "@welshman/app"
import {preferencesStorageProvider} from "@lib/storage"
import AppContainer from "@app/components/AppContainer.svelte" import AppContainer from "@app/components/AppContainer.svelte"
import ModalContainer from "@app/components/ModalContainer.svelte" import ModalContainer from "@app/components/ModalContainer.svelte"
import {setupHistory} from "@app/util/history" import {setupHistory} from "@app/util/history"
import {setupTracking} from "@app/util/tracking" import {setupTracking} from "@app/util/tracking"
import {setupAnalytics} from "@app/util/analytics" import {setupAnalytics} from "@app/util/analytics"
import {authPolicy, trustPolicy, mostlyRestrictedPolicy} from "@app/util/policies" import {authPolicy, trustPolicy, mostlyRestrictedPolicy} from "@app/util/policies"
import {kv, db} from "@app/core/storage"
import {userSettingsValues} from "@app/core/state" import {userSettingsValues} from "@app/core/state"
import {syncApplicationData} from "@app/core/sync" import {syncApplicationData} from "@app/core/sync"
import {theme} from "@app/util/theme"
import {toast, pushToast} from "@app/util/toast"
import {initializePushNotifications} from "@app/util/push"
import * as commands from "@app/core/commands" import * as commands from "@app/core/commands"
import * as requests from "@app/core/requests" import * as requests from "@app/core/requests"
import * as appState from "@app/core/state" import * as appState from "@app/core/state"
import {theme} from "@app/util/theme"
import {toast, pushToast} from "@app/util/toast"
import {initializePushNotifications} from "@app/util/push"
import * as notifications from "@app/util/notifications" import * as notifications from "@app/util/notifications"
import * as storage from "@app/util/storage" import * as storage from "@app/util/storage"
import {syncKeyboard} from "@app/util/keyboard"
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte" import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
const {children} = $props() const {children} = $props()
@@ -88,6 +89,9 @@
} }
}) })
// Cleanup on page close
window.addEventListener("beforeunload", () => db.close())
const unsubscribe = call(async () => { const unsubscribe = call(async () => {
const unsubscribers: Unsubscriber[] = [] const unsubscribers: Unsubscriber[] = []
@@ -96,22 +100,28 @@
sync({ sync({
key: "pubkey", key: "pubkey",
store: pubkey, store: pubkey,
storage: preferencesStorageProvider, storage: kv,
}), }),
sync({ sync({
key: "sessions", key: "sessions",
store: sessions, store: sessions,
storage: preferencesStorageProvider, storage: kv,
}), }),
sync({ sync({
key: "shouldUnwrap", key: "shouldUnwrap",
store: shouldUnwrap, store: shouldUnwrap,
storage: preferencesStorageProvider, storage: kv,
}), }),
]) ])
// Set up our storage adapters
db.adapters = storage.adapters
// Wait until data storage is initialized before syncing other stuff // Wait until data storage is initialized before syncing other stuff
unsubscribers.push(await storage.syncDataStores()) await db.connect()
// Close the database connection on reload
unsubscribers.push(() => db.close())
// Add our extra policies now that we're set up // Add our extra policies now that we're set up
defaultSocketPolicies.push(...policies) defaultSocketPolicies.push(...policies)
@@ -125,6 +135,9 @@
// Subscribe to badge count for changes // Subscribe to badge count for changes
unsubscribers.push(notifications.badgeCount.subscribe(notifications.handleBadgeCountChanges)) unsubscribers.push(notifications.badgeCount.subscribe(notifications.handleBadgeCountChanges))
// Initialize keyboard state tracking
unsubscribers.push(syncKeyboard())
// Listen for signer errors, report to user via toast // Listen for signer errors, report to user via toast
unsubscribers.push( unsubscribers.push(
signerLog.subscribe( signerLog.subscribe(
+5 -1
View File
@@ -1,10 +1,14 @@
<script lang="ts"> <script lang="ts">
import {page} from "$app/stores" import {page} from "$app/stores"
import type {MakeNonOptional} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib"
import {append, uniq} from "@welshman/lib"
import {pubkey} from "@welshman/app"
import Chat from "@app/components/Chat.svelte" import Chat from "@app/components/Chat.svelte"
import {notifications, setChecked} from "@app/util/notifications" import {notifications, setChecked} from "@app/util/notifications"
import {splitChatId} from "@app/core/state"
const {chat} = $page.params as MakeNonOptional<typeof $page.params> const {chat} = $page.params as MakeNonOptional<typeof $page.params>
const pubkeys = uniq(append($pubkey!, splitChatId(chat)))
// We have to watch this one, since on mobile the badge will be visible when active // We have to watch this one, since on mobile the badge will be visible when active
$effect(() => { $effect(() => {
@@ -14,4 +18,4 @@
}) })
</script> </script>
<Chat id={chat} /> <Chat {pubkeys} />
+7 -7
View File
@@ -25,9 +25,9 @@
import SpaceCheck from "@app/components/SpaceCheck.svelte" import SpaceCheck from "@app/components/SpaceCheck.svelte"
import { import {
bootstrapPubkeys, bootstrapPubkeys,
loadGroupSelections, loadGroupList,
getSpaceUrlsFromGroupSelections, getSpaceUrlsFromGroupList,
groupSelectionsPubkeysByUrl, groupListPubkeysByUrl,
parseInviteLink, parseInviteLink,
} from "@app/core/state" } from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -50,8 +50,8 @@
relays: Router.get().Index().getUrls(), relays: Router.get().Index().getUrls(),
}), }),
...$bootstrapPubkeys.map(async pubkey => { ...$bootstrapPubkeys.map(async pubkey => {
const list = await loadGroupSelections(pubkey) const list = await loadGroupList(pubkey)
const urls = getSpaceUrlsFromGroupSelections(list) const urls = getSpaceUrlsFromGroupList(list)
await Promise.all(urls.map(url => loadRelay(url))) await Promise.all(urls.map(url => loadRelay(url)))
}), }),
@@ -59,13 +59,13 @@
const relaySearch = $derived( const relaySearch = $derived(
createSearch( createSearch(
$relays.filter(r => $groupSelectionsPubkeysByUrl.has(r.url) && r.url !== inviteData?.url), $relays.filter(r => $groupListPubkeysByUrl.has(r.url) && r.url !== inviteData?.url),
{ {
getValue: (relay: RelayProfile) => relay.url, getValue: (relay: RelayProfile) => relay.url,
sortFn: ({score, item}) => { sortFn: ({score, item}) => {
if (score && score > 0.1) return -score! if (score && score > 0.1) return -score!
const wotScore = $groupSelectionsPubkeysByUrl.get(item.url)!.size const wotScore = $groupListPubkeysByUrl.get(item.url)!.size
return score ? dec(score) * wotScore : -wotScore return score ? dec(score) * wotScore : -wotScore
}, },
+11 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {debounce} from "throttle-debounce"
import {createScroller, isMobile} from "@lib/html" import {createScroller, isMobile} from "@lib/html"
import {profileSearch} from "@welshman/app" import {profileSearch} from "@welshman/app"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -11,9 +12,18 @@
let term = $state("") let term = $state("")
let limit = $state(10) let limit = $state(10)
let pubkeys = $state($bootstrapPubkeys)
let element: Element | undefined = $state() let element: Element | undefined = $state()
const pubkeys = $derived(term ? $profileSearch.searchValues(term) : $bootstrapPubkeys) const search = debounce(200, (term: string) => {
if (term) {
pubkeys = $profileSearch.searchValues(term)
} else {
pubkeys = $bootstrapPubkeys
}
})
$effect(() => search(term))
onMount(() => { onMount(() => {
const scroller = createScroller({ const scroller = createScroller({
+5 -5
View File
@@ -9,7 +9,7 @@
BLOSSOM_SERVERS, BLOSSOM_SERVERS,
} from "@welshman/util" } from "@welshman/util"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
import {userMutes, tagPubkey, publishThunk, userBlossomServers} from "@welshman/app" import {userMuteList, tagPubkey, publishThunk, userBlossomServerList} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
@@ -22,8 +22,8 @@
const reset = () => { const reset = () => {
settings = {...$userSettingsValues} settings = {...$userSettingsValues}
mutedPubkeys = getPubkeyTagValues(getListTags($userMutes)) mutedPubkeys = getPubkeyTagValues(getListTags($userMuteList))
blossomServers = getTagValues("server", getListTags($userBlossomServers)) blossomServers = getTagValues("server", getListTags($userBlossomServerList))
} }
const onsubmit = preventDefault(async () => { const onsubmit = preventDefault(async () => {
@@ -43,8 +43,8 @@
}) })
let settings = $state({...$userSettingsValues}) let settings = $state({...$userSettingsValues})
let mutedPubkeys = $state(getPubkeyTagValues(getListTags($userMutes))) let mutedPubkeys = $state(getPubkeyTagValues(getListTags($userMuteList)))
let blossomServers = $state(getTagValues("server", getListTags($userBlossomServers))) let blossomServers = $state(getTagValues("server", getListTags($userBlossomServerList)))
</script> </script>
<form class="content column gap-4" {onsubmit}> <form class="content column gap-4" {onsubmit}>
+11 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {pubkey, relaySelections, inboxRelaySelections, derivePubkeyRelays} from "@welshman/app" import {pubkey, getRelayLists, getMessagingRelayLists, derivePubkeyRelays} from "@welshman/app"
import {RelayMode} from "@welshman/util" import {RelayMode} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -9,7 +9,7 @@
import RelayAdd from "@app/components/RelayAdd.svelte" import RelayAdd from "@app/components/RelayAdd.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {discoverRelays} from "@app/core/requests" import {discoverRelays} from "@app/core/requests"
import {setRelayPolicy, setInboxRelayPolicy} from "@app/core/commands" import {setRelayPolicy, setMessagingRelayPolicy} from "@app/core/commands"
import Globus from "@assets/icons/globus.svg?dataurl" import Globus from "@assets/icons/globus.svg?dataurl"
import Inbox from "@assets/icons/inbox.svg?dataurl" import Inbox from "@assets/icons/inbox.svg?dataurl"
import Mailbox from "@assets/icons/mailbox.svg?dataurl" import Mailbox from "@assets/icons/mailbox.svg?dataurl"
@@ -18,7 +18,7 @@
const readRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Read) const readRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Read)
const writeRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Write) const writeRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Write)
const inboxRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Inbox) const messagingRelayUrls = derivePubkeyRelays($pubkey!, RelayMode.Messaging)
const addReadRelay = () => const addReadRelay = () =>
pushModal(RelayAdd, { pushModal(RelayAdd, {
@@ -32,20 +32,20 @@
addRelay: (url: string) => setRelayPolicy(url, $readRelayUrls.includes(url), true), addRelay: (url: string) => setRelayPolicy(url, $readRelayUrls.includes(url), true),
}) })
const addInboxRelay = () => const addMessagingRelay = () =>
pushModal(RelayAdd, { pushModal(RelayAdd, {
relays: inboxRelayUrls, relays: messagingRelayUrls,
addRelay: (url: string) => setInboxRelayPolicy(url, true), addRelay: (url: string) => setMessagingRelayPolicy(url, true),
}) })
const removeReadRelay = (url: string) => setRelayPolicy(url, false, $writeRelayUrls.includes(url)) const removeReadRelay = (url: string) => setRelayPolicy(url, false, $writeRelayUrls.includes(url))
const removeWriteRelay = (url: string) => setRelayPolicy(url, $readRelayUrls.includes(url), false) const removeWriteRelay = (url: string) => setRelayPolicy(url, $readRelayUrls.includes(url), false)
const removeInboxRelay = (url: string) => setInboxRelayPolicy(url, false) const removeMessagingRelay = (url: string) => setMessagingRelayPolicy(url, false)
onMount(() => { onMount(() => {
discoverRelays([...$relaySelections, ...$inboxRelaySelections]) discoverRelays([...getRelayLists(), ...getMessagingRelayLists()])
}) })
</script> </script>
@@ -130,19 +130,19 @@
</p> </p>
{/snippet} {/snippet}
<div class="column gap-2"> <div class="column gap-2">
{#each $inboxRelayUrls.sort() as url (url)} {#each $messagingRelayUrls.sort() as url (url)}
<RelayItem {url}> <RelayItem {url}>
<Button <Button
class="tooltip flex items-center" class="tooltip flex items-center"
data-tip="Stop using this relay" data-tip="Stop using this relay"
onclick={() => removeInboxRelay(url)}> onclick={() => removeMessagingRelay(url)}>
<Icon icon={CloseCircle} /> <Icon icon={CloseCircle} />
</Button> </Button>
</RelayItem> </RelayItem>
{:else} {:else}
<p class="text-center text-sm">No relays found</p> <p class="text-center text-sm">No relays found</p>
{/each} {/each}
<Button class="btn btn-primary mt-2" onclick={addInboxRelay}> <Button class="btn btn-primary mt-2" onclick={addMessagingRelay}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Add Relay Add Relay
</Button> </Button>
+10
View File
@@ -3,8 +3,10 @@
import {LOCALE} from "@welshman/lib" import {LOCALE} from "@welshman/lib"
import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util" import {displayRelayUrl, isNWCWallet, fromMsats} from "@welshman/util"
import {session, pubkey, profilesByPubkey} from "@welshman/app" import {session, pubkey, profilesByPubkey} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import WalletPay from "@app/components/WalletPay.svelte"
import WalletConnect from "@app/components/WalletConnect.svelte" import WalletConnect from "@app/components/WalletConnect.svelte"
import WalletDisconnect from "@app/components/WalletDisconnect.svelte" import WalletDisconnect from "@app/components/WalletDisconnect.svelte"
import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte" import WalletUpdateReceivingAddress from "@app/components/WalletUpdateReceivingAddress.svelte"
@@ -27,6 +29,8 @@
const walletLud16 = $derived( const walletLud16 = $derived(
$session?.wallet && isNWCWallet($session.wallet) ? $session.wallet.info.lud16 : undefined, $session?.wallet && isNWCWallet($session.wallet) ? $session.wallet.info.lud16 : undefined,
) )
const pay = () => pushModal(WalletPay)
</script> </script>
<div class="content column gap-4"> <div class="content column gap-4">
@@ -118,4 +122,10 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex justify-center py-12">
<Button class="btn btn-primary" onclick={pay}>
<Icon icon={Bolt} />
Pay With Lightning
</Button>
</div>
</div> </div>
+2 -2
View File
@@ -8,7 +8,7 @@
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte" import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceAdd from "@app/components/SpaceAdd.svelte"
import {userSpaceUrls, loadUserGroupSelections, PLATFORM_RELAYS} from "@app/core/state" import {userSpaceUrls, loadUserGroupList, PLATFORM_RELAYS} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
const addSpace = () => pushModal(SpaceAdd) const addSpace = () => pushModal(SpaceAdd)
@@ -37,7 +37,7 @@
{#each PLATFORM_RELAYS as url (url)} {#each PLATFORM_RELAYS as url (url)}
<MenuSpacesItem {url} /> <MenuSpacesItem {url} />
{:else} {:else}
{#await loadUserGroupSelections()} {#await loadUserGroupList()}
<div class="flex justify-center items-center py-20"> <div class="flex justify-center items-center py-20">
<span class="loading loading-spinner mr-3"></span> <span class="loading loading-spinner mr-3"></span>
Loading your spaces... Loading your spaces...
+2 -2
View File
@@ -4,7 +4,7 @@
import {once} from "@welshman/lib" import {once} from "@welshman/lib"
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte" import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte" import SpaceMenu from "@app/components/SpaceMenu.svelte"
import SpaceAuthError from "@app/components/SpaceAuthError.svelte" import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte" import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -46,7 +46,7 @@
</script> </script>
<SecondaryNav> <SecondaryNav>
<MenuSpace {url} /> <SpaceMenu {url} />
</SecondaryNav> </SecondaryNav>
<Page> <Page>
{#key $page.url.pathname} {#key $page.url.pathname}
+12 -5
View File
@@ -5,7 +5,7 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import type {MakeNonOptional} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib"
import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" import {now, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import { import {
makeEvent, makeEvent,
@@ -28,7 +28,7 @@
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import RoomImage from "@app/components/RoomImage.svelte" import RoomImage from "@app/components/RoomImage.svelte"
import RoomDetail from "@app/components/RoomDetail.svelte" import RoomDetail from "@app/components/RoomDetail.svelte"
@@ -211,7 +211,9 @@
const seen = new Set() const seen = new Set()
let previousDate let previousDate
let previousKind
let previousPubkey let previousPubkey
let previousCreatedAt = 0
let newMessagesSeen = false let newMessagesSeen = false
if (events) { if (events) {
@@ -247,11 +249,16 @@
id: event.id, id: event.id,
type: "note", type: "note",
value: event, value: event,
showPubkey: date !== previousDate || previousPubkey !== event.pubkey, showPubkey:
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
[ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER].includes(previousKind!),
}) })
previousDate = date previousDate = date
previousKind = event.kind
previousPubkey = event.pubkey previousPubkey = event.pubkey
previousCreatedAt = event.created_at
seen.add(event.id) seen.add(event.id)
} }
} }
@@ -350,7 +357,7 @@
onclick={showRoomDetail}> onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} /> <Icon size={4} icon={InfoCircle} />
</Button> </Button>
<MenuSpaceButton {url} /> <SpaceMenuButton {url} />
</div> </div>
{/snippet} {/snippet}
</PageBar> </PageBar>
@@ -480,7 +487,7 @@
{/if} {/if}
{#if showFixedNewMessages} {#if showFixedNewMessages}
<div class="relative z-feature flex justify-center"> <div class="relative z-popover flex justify-center">
<div transition:fly={{duration: 200}} class="fixed top-12"> <div transition:fly={{duration: 200}} class="fixed top-12">
<Button class="btn btn-primary btn-xs rounded-full" onclick={scrollToNewMessages}> <Button class="btn btn-primary btn-xs rounded-full" onclick={scrollToNewMessages}>
New Messages New Messages
@@ -15,7 +15,7 @@
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte" import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte" import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -128,7 +128,7 @@
<Icon icon={CalendarAdd} /> <Icon icon={CalendarAdd} />
Create an Event Create an Event
</Button> </Button>
<MenuSpaceButton {url} /> <SpaceMenuButton {url} />
</div> </div>
{/snippet} {/snippet}
</PageBar> </PageBar>
@@ -6,7 +6,7 @@
import {COMMENT, getTagValue} from "@welshman/util" import {COMMENT, getTagValue} from "@welshman/util"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {repository} from "@welshman/app" import {repository} from "@welshman/app"
import {deriveEvents} from "@welshman/store" import {deriveEventsById, deriveEventsDesc} from "@welshman/store"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl" import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
@@ -17,7 +17,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte" import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte" import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte" import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte"
@@ -28,9 +28,9 @@
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params> const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay) const url = decodeRelay(relay)
const event = deriveEvent(id) const event = deriveEvent(id, [url])
const filters = [{kinds: [COMMENT], "#E": [id]}] const filters = [{kinds: [COMMENT], "#E": [id]}]
const replies = deriveEvents(repository, {filters}) const replies = deriveEventsDesc(deriveEventsById({filters, repository}))
const back = () => history.back() const back = () => history.back()
@@ -74,7 +74,7 @@
<h1 class="text-xl">{getTagValue("title", $event?.tags || []) || ""}</h1> <h1 class="text-xl">{getTagValue("title", $event?.tags || []) || ""}</h1>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<MenuSpaceButton {url} /> <SpaceMenuButton {url} />
{/snippet} {/snippet}
</PageBar> </PageBar>
+12 -5
View File
@@ -3,7 +3,7 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {now, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib" import {now, int, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util" import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app" import {pubkey, publishThunk} from "@welshman/app"
@@ -17,7 +17,7 @@
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import RoomItem from "@app/components/RoomItem.svelte" import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte" import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte" import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
@@ -136,7 +136,9 @@
const seen = new Set() const seen = new Set()
let previousDate let previousDate
let previousKind
let previousPubkey let previousPubkey
let previousCreatedAt = 0
let newMessagesSeen = false let newMessagesSeen = false
if (events) { if (events) {
@@ -172,11 +174,16 @@
id: event.id, id: event.id,
type: "note", type: "note",
value: event, value: event,
showPubkey: date !== previousDate || previousPubkey !== event.pubkey, showPubkey:
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
[RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER].includes(previousKind!),
}) })
previousDate = date previousDate = date
previousKind = event.kind
previousPubkey = event.pubkey previousPubkey = event.pubkey
previousCreatedAt = event.created_at
seen.add(event.id) seen.add(event.id)
} }
} }
@@ -259,7 +266,7 @@
<strong>Chat</strong> <strong>Chat</strong>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<MenuSpaceButton {url} /> <SpaceMenuButton {url} />
{/snippet} {/snippet}
</PageBar> </PageBar>
@@ -337,7 +344,7 @@
{/if} {/if}
{#if showFixedNewMessages} {#if showFixedNewMessages}
<div class="relative z-feature flex justify-center"> <div class="relative z-popover flex justify-center">
<div transition:fly={{duration: 200}} class="fixed top-12"> <div transition:fly={{duration: 200}} class="fixed top-12">
<Button class="btn btn-primary btn-xs rounded-full" onclick={scrollToNewMessages}> <Button class="btn btn-primary btn-xs rounded-full" onclick={scrollToNewMessages}>
New Messages New Messages
+2 -2
View File
@@ -13,7 +13,7 @@
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import GoalItem from "@app/components/GoalItem.svelte" import GoalItem from "@app/components/GoalItem.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte" import GoalCreate from "@app/components/GoalCreate.svelte"
import {decodeRelay, makeCommentFilter} from "@app/core/state" import {decodeRelay, makeCommentFilter} from "@app/core/state"
@@ -78,7 +78,7 @@
<Icon icon={NotesMinimalistic} /> <Icon icon={NotesMinimalistic} />
Create a Goal Create a Goal
</Button> </Button>
<MenuSpaceButton {url} /> <SpaceMenuButton {url} />
</div> </div>
{/snippet} {/snippet}
</PageBar> </PageBar>
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {sortBy, sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib"
import {COMMENT, getTagValue} from "@welshman/util" import {COMMENT, getTagValue} from "@welshman/util"
import {repository} from "@welshman/app" import {repository} from "@welshman/app"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {deriveEvents} from "@welshman/store" import {deriveEventsById, deriveEventsDesc} from "@welshman/store"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl" import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
@@ -17,7 +17,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import GoalSummary from "@app/components/GoalSummary.svelte" import GoalSummary from "@app/components/GoalSummary.svelte"
import GoalActions from "@app/components/GoalActions.svelte" import GoalActions from "@app/components/GoalActions.svelte"
import CommentActions from "@app/components/CommentActions.svelte" import CommentActions from "@app/components/CommentActions.svelte"
@@ -27,10 +27,10 @@
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params> const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay) const url = decodeRelay(relay)
const event = deriveEvent(id) const event = deriveEvent(id, [url])
const filters = [{kinds: [COMMENT], "#E": [id]}] const filters = [{kinds: [COMMENT], "#E": [id]}]
const replies = deriveEvents(repository, {filters}) const replies = deriveEventsDesc(deriveEventsById({repository, filters}))
const summary = getTagValue("summary", $event.tags) const summary = getTagValue("summary", $event?.tags || [])
const back = () => history.back() const back = () => history.back()
@@ -71,11 +71,11 @@
</div> </div>
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
<h1 class="text-xl">{$event.content}</h1> <h1 class="text-xl">{$event?.content}</h1>
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<div> <div>
<MenuSpaceButton {url} /> <SpaceMenuButton {url} />
</div> </div>
{/snippet} {/snippet}
</PageBar> </PageBar>
@@ -98,7 +98,7 @@
</Button> </Button>
</div> </div>
{/if} {/if}
{#each sortBy(e => e.created_at, $replies).slice(0, showAll ? undefined : 4) as reply (reply.id)} {#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)}
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full"> <NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12"> <div class="col-3 ml-12">
<Content showEntire event={reply} {url} /> <Content showEntire event={reply} {url} />
@@ -10,7 +10,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import ConversationCard from "@app/components/ConversationCard.svelte" import ConversationCard from "@app/components/ConversationCard.svelte"
import {decodeRelay, deriveEventsForUrl} from "@app/core/state" import {decodeRelay, deriveEventsForUrl} from "@app/core/state"
@@ -85,7 +85,7 @@
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<div class="row-2"> <div class="row-2">
<MenuSpaceButton {url} /> <SpaceMenuButton {url} />
</div> </div>
{/snippet} {/snippet}
</PageBar> </PageBar>
@@ -13,7 +13,7 @@
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte" import ThreadItem from "@app/components/ThreadItem.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte" import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {decodeRelay} from "@app/core/state" import {decodeRelay} from "@app/core/state"
@@ -79,7 +79,7 @@
<Icon icon={NotesMinimalistic} /> <Icon icon={NotesMinimalistic} />
Create a Thread Create a Thread
</Button> </Button>
<MenuSpaceButton {url} /> <SpaceMenuButton {url} />
</div> </div>
{/snippet} {/snippet}
</PageBar> </PageBar>
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {sortBy, sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib"
import {COMMENT, getTagValue} from "@welshman/util" import {COMMENT, getTagValue} from "@welshman/util"
import {repository} from "@welshman/app" import {repository} from "@welshman/app"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {deriveEvents} from "@welshman/store" import {deriveEventsById, deriveEventsDesc} from "@welshman/store"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl" import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl"
@@ -17,7 +17,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte" import ThreadActions from "@app/components/ThreadActions.svelte"
import CommentActions from "@app/components/CommentActions.svelte" import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte" import EventReply from "@app/components/EventReply.svelte"
@@ -26,9 +26,9 @@
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params> const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay) const url = decodeRelay(relay)
const event = deriveEvent(id) const event = deriveEvent(id, [url])
const filters = [{kinds: [COMMENT], "#E": [id]}] const filters = [{kinds: [COMMENT], "#E": [id]}]
const replies = deriveEvents(repository, {filters}) const replies = deriveEventsDesc(deriveEventsById({filters, repository}))
const back = () => history.back() const back = () => history.back()
@@ -73,7 +73,7 @@
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<div> <div>
<MenuSpaceButton {url} /> <SpaceMenuButton {url} />
</div> </div>
{/snippet} {/snippet}
</PageBar> </PageBar>
@@ -95,7 +95,7 @@
</Button> </Button>
</div> </div>
{/if} {/if}
{#each sortBy(e => e.created_at, $replies).slice(0, showAll ? undefined : 4) as reply (reply.id)} {#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)}
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full"> <NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12"> <div class="col-3 ml-12">
<Content showEntire event={reply} {url} /> <Content showEntire event={reply} {url} />

Some files were not shown because too many files have changed in this diff Show More