Compare commits

..

96 Commits

Author SHA1 Message Date
Jon Staab 5931a268cf Bump version 2025-01-02 15:59:01 -08:00
Jon Staab 268028a968 Fix storage access 2025-01-02 15:57:51 -08:00
Jon Staab d6669f42c1 Install sentry cli 2025-01-02 15:22:44 -08:00
Jon Staab 19d69005a1 Modify changelog 2025-01-02 15:20:51 -08:00
Jon Staab 9917970760 Update changelog, bump to 0.2.1 2025-01-02 15:14:28 -08:00
Jon Staab 814c5974c4 Bump welshman, minor version 2025-01-02 15:09:16 -08:00
Jon Staab f5dced433a Fix loading and scrolling 2025-01-02 15:08:16 -08:00
Jon Staab 9e96d5e483 Clean up migrations 2025-01-02 13:39:20 -08:00
Jon Staab 420dfc41f3 Bump welshman 2025-01-02 10:47:34 -08:00
Jon Staab 23ae530cd4 Small fixes/performance improvements 2025-01-02 10:04:28 -08:00
Jon Staab 8dfbc99a34 Fix scroller in room page 2024-12-30 16:49:07 -08:00
Jon Staab 75bca31c14 Tweak connection errors 2024-12-30 15:36:54 -08:00
Jon Staab 0c9109f387 Load messages more aggressively at the top level for all spaces 2024-12-30 15:15:22 -08:00
Jon Staab 7a17dc772f Fix connect call 2024-12-30 09:19:56 -08:00
Jon Staab 7bd98270f8 Temporarily handle old data format for events 2024-12-24 09:26:33 -08:00
Jon Staab c15f57c9a5 Bump version in build.gradle 2024-12-17 10:13:08 -08:00
Jon Staab 0f311c45c0 Add nostr: prefix to editor 2024-12-17 08:42:36 -08:00
Jon Staab 055d539b88 Bump version 2024-12-17 08:42:36 -08:00
hodlbod b8e23c47d4 Merge pull request #89 from deerwhisper2310/master
added missing periods
2024-12-17 08:30:02 -08:00
deerwhisper2310 39c72a61ce added missing periods 2024-12-16 23:07:42 -05:00
Jon Staab 166bd81310 Use userRoomsByUrl 2024-12-16 17:07:52 -08:00
Jon Staab d0565e7c62 Bump welshman 2024-12-16 16:52:08 -08:00
Jon Staab 7ddc1657ad Get rid of subscribePersistent 2024-12-16 16:14:51 -08:00
Jon Staab fe789c461d Infer room/protected tag from parent event in reactions and deletes 2024-12-16 13:33:34 -08:00
Jon Staab cd8d8b548f Add profile detail modal 2024-12-16 12:54:17 -08:00
Jon Staab 3b202b31cb Switch checked from indexedb to localstorage 2024-12-16 11:55:47 -08:00
Jon Staab fd846d41ea Further refine notifications 2024-12-16 11:49:57 -08:00
Jon Staab 3d3ffaf406 Simplify and optimize notifications 2024-12-16 11:26:50 -08:00
Jon Staab 85e5413951 Fix space notifications 2024-12-16 10:30:42 -08:00
Jon Staab 9f3bfd5ac0 Move nip29 check 2024-12-12 14:49:30 -08:00
Jon Staab 9d6531c0d5 Handle join errors prefixed with duplicate: 2024-12-12 14:24:51 -08:00
Jon Staab 77d20966ee Bump welshman 2024-12-12 13:46:32 -08:00
Jon Staab daf5cc84bd Bump welshman 2024-12-11 16:59:16 -08:00
Jon Staab 167cd045f4 Fix squirrely notification badges 2024-12-11 14:11:02 -08:00
Jon Staab c83461688f Small tweaks to room menu 2024-12-11 13:57:46 -08:00
Jon Staab b19881a8a9 Hide loader when no admin posts exist 2024-12-11 13:35:06 -08:00
Jon Staab b6524f4a58 Add join space CTA 2024-12-11 11:25:39 -08:00
Jon Staab 2ee370e78b Fix freshness persistence, optimize pubkey loading 2024-12-11 11:03:22 -08:00
Jon Staab a378ecbad4 Improve loading indicator in channels 2024-12-11 10:31:39 -08:00
Jon Staab 72ced31625 Reduce quote depth 2024-12-10 16:50:38 -08:00
Jon Staab 6f7a1c690f Add e/k tags as well as E/K tags to roots 2024-12-10 16:46:49 -08:00
Jon Staab df42ec9915 Add request utils for complex requests 2024-12-10 16:38:22 -08:00
Jon Staab 19d67783fc Fix legacy message loading 2024-12-10 15:33:36 -08:00
Jon Staab d8c3378e5c Create nip29 group when creating a room 2024-12-10 15:16:54 -08:00
Jon Staab 73c6b9656c Support names for unmanaged groups via kind 10009 2024-12-10 14:14:22 -08:00
Jon Staab 80d44a097a Show lock icon for closed channels 2024-12-10 13:42:26 -08:00
Jon Staab a5dfa02771 Use welshman kinds 2024-12-10 13:07:17 -08:00
Jon Staab 66f3686ef4 Spruce up home page navigation 2024-12-10 13:02:41 -08:00
Jon Staab a65f6f6323 Fix quote relays, add backwards compat for reading legacy messages/threads 2024-12-10 10:49:21 -08:00
Jon Staab 523c54a1f1 Add nip29 join/leave room 2024-12-10 09:44:04 -08:00
Jon Staab 7e3cf94ee8 Account for thunks when figuring out which urls an event is on 2024-12-10 08:59:39 -08:00
Jon Staab 404dc94c34 Display rooms using nip29 meta 2024-12-09 17:06:07 -08:00
Jon Staab ea0e1a6c9a Improve performance a bit 2024-12-09 14:03:59 -08:00
Jon Staab 880093296e Throttle elements on chat page 2024-12-09 12:20:16 -08:00
Jon Staab e17cda1eff Add protected tag 2024-12-09 11:59:42 -08:00
Jon Staab 1e0cb93183 Improve quote rendering 2024-12-05 15:32:27 -08:00
Jon Staab 14cd49caf3 Use new kinds, re work channels 2024-12-05 13:37:15 -08:00
Jon Staab 64916f5d29 Remove chat comments and conversation pane 2024-12-04 15:35:39 -08:00
Jon Staab 7b58cdf855 Tweak login button styles 2024-12-04 15:11:32 -08:00
Jon Staab 2e05eee9e7 Add eject flow 2024-12-04 10:10:41 -08:00
Jon Staab efb0528f76 Speed up initial login 2024-12-03 16:41:25 -08:00
Jon Staab 1ea39c1d56 Add email confirmation and password reset 2024-12-03 15:40:15 -08:00
Jon Staab c2aa829334 Rename LogInPassword 2024-12-03 14:00:15 -08:00
Jon Staab a58fc68235 Add burrow support 2024-12-03 14:00:13 -08:00
Jon Staab 220f26253d Use nip04 for signup 2024-12-03 12:31:31 -08:00
Jon Staab 08fef7aa51 Use new welshman nip46 stuff 2024-12-02 16:21:54 -08:00
Jon Staab b8c77c20cd Merge branch 'master' of github.com:coracle-social/flotilla 2024-12-02 09:52:05 -08:00
Jon Staab aa27a05fa6 Fix weird dotenv error 2024-12-02 09:51:50 -08:00
hodlbod 9a68101a64 Merge pull request #79 from greenart7c3/greenart7c3-patch-1
Fix missing comma after nip44_decrypt
2024-11-27 08:52:33 -08:00
greenart7c3 dd5384f7e4 Fix missing comma after nip44_decrypt 2024-11-27 08:46:31 -03:00
Jon Staab 71d63ed21a Bump welshman 2024-11-26 11:56:23 -08:00
Jon Staab de4e1c8677 Fix thread ellision 2024-11-22 14:46:29 -08:00
Jon Staab e6e1eb8897 Fix menu spacing 2024-11-21 17:21:27 -08:00
Jon Staab 603653574c fix scrolling in sidebar 2024-11-21 14:25:57 -08:00
Jon Staab e83a72b426 Make quotes in channels more minimal 2024-11-21 14:14:46 -08:00
Jon Staab eb5bcd8948 Avoid cutting off emojis in channels 2024-11-21 13:20:19 -08:00
Jon Staab 7c46dfb6bc Add new icon 2024-11-21 11:55:43 -08:00
Jon Staab dcc6f463a7 Make thread replies expandable 2024-11-21 11:52:29 -08:00
Jon Staab 86d082b1ab Re-work thread sorting and loading, fix some display bugs with reaction tooltips, fix thunk status loading indicator 2024-11-21 11:01:34 -08:00
Jon Staab 659403c308 Tweak layout of thread actions 2024-11-20 15:56:24 -08:00
Jon Staab 1c0e680c17 Fix failure to navigate, quote transitions 2024-11-20 08:53:30 -08:00
Jon Staab 05f7d128e4 Add scroller to rooms 2024-11-19 16:15:23 -08:00
Jon Staab dfcb88dcce Fix some spacing issues in content 2024-11-19 14:13:00 -08:00
Jon Staab 5890fb64a5 remove link extension 2024-11-19 14:02:08 -08:00
Jon Staab f52142bc52 Tweak message spacing 2024-11-19 13:42:40 -08:00
Jon Staab f4f60a5333 Listen for new threads, add reply/quote button to channels and chats, better quote handling 2024-11-19 13:24:18 -08:00
Jon Staab 6a646b3240 Avoid attempting to unwrap the same event multiple times in a single page load 2024-11-19 10:25:52 -08:00
Jon Staab e5fd172994 Add step to confirm decrypt before doing it in the background 2024-11-19 10:11:31 -08:00
Jon Staab 7cc2a2f264 Load relay owner notes only from the relay 2024-11-19 09:36:05 -08:00
Jon Staab ad58af8605 Customize nip 46 perms 2024-11-19 09:25:02 -08:00
Jon Staab 5b7985e5d9 Disable LinkExtension 2024-11-19 08:37:52 -08:00
Jon Staab 6ff798f4e8 Fix code blocks 2024-11-19 08:37:09 -08:00
Jon Staab ed738f64c8 Handle failed space auth 2024-11-18 20:31:21 -08:00
Jon Staab cbc4c524c4 Bump welshman 2024-11-18 17:26:50 -08:00
Jon Staab bf599cb190 Fix line height on url preview fail 2024-11-18 16:48:51 -08:00
Jon Staab 06a03f5ab1 Add fallback nav items on mobile 2024-11-18 16:33:20 -08:00
110 changed files with 3152 additions and 2496 deletions
+1
View File
@@ -1,4 +1,5 @@
VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322 VITE_DEFAULT_PUBKEYS=fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
VITE_BURROW_URL=
VITE_PLATFORM_URL=https://flotilla.social VITE_PLATFORM_URL=https://flotilla.social
VITE_PLATFORM_NAME=Flotilla VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png VITE_PLATFORM_LOGO=static/flotilla.png
+1 -1
View File
@@ -24,5 +24,5 @@ static/pwa-192x192.png
static/pwa-512x512.png static/pwa-512x512.png
static/apple-touch-icon-180x180.png static/apple-touch-icon-180x180.png
static/maskable-icon-512x512.png static/maskable-icon-512x512.png
src/assets src/assets/icons/*.webp
manifest.webmanifest manifest.webmanifest
+12
View File
@@ -0,0 +1,12 @@
# Changelog
# 0.2.1
* Improve performance, as well as scrolling and loading
* Integrate @welshman/editor
* Improve NIP 29 compatibility
* Fix incorrect connection errors
* Refine notifications
* Add room menu to space homepage
* Fix storage bugs
* Add join space CTA
+1 -1
View File
@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
versionName "1.0" versionName "0.2.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.
+483 -160
View File
@@ -1,12 +1,12 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "0.1.0", "version": "0.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flotilla", "name": "flotilla",
"version": "0.1.0", "version": "0.2.2",
"dependencies": { "dependencies": {
"@capacitor/android": "^6.1.2", "@capacitor/android": "^6.1.2",
"@capacitor/cli": "^6.1.2", "@capacitor/cli": "^6.1.2",
@@ -28,18 +28,18 @@
"@tiptap/extension-text": "^2.6.6", "@tiptap/extension-text": "^2.6.6",
"@tiptap/suggestion": "^2.6.4", "@tiptap/suggestion": "^2.6.4",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@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.6", "@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.27", "@welshman/app": "~0.0.36",
"@welshman/content": "~0.0.12", "@welshman/content": "~0.0.15",
"@welshman/dvm": "~0.0.10", "@welshman/dvm": "~0.0.13",
"@welshman/feeds": "~0.0.25", "@welshman/editor": "~0.0.4",
"@welshman/lib": "~0.0.26", "@welshman/feeds": "~0.0.30",
"@welshman/net": "~0.0.36", "@welshman/lib": "~0.0.37",
"@welshman/signer": "~0.0.14", "@welshman/net": "~0.0.45",
"@welshman/store": "~0.0.12", "@welshman/signer": "~0.0.19",
"@welshman/util": "~0.0.45", "@welshman/store": "~0.0.15",
"@welshman/util": "~0.0.55",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0", "date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -52,11 +52,11 @@
"nostr-tools": "^2.7.2", "nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"svelte-tiptap": "^1.1.3", "svelte-tiptap": "^1.1.3"
"throttle-debounce": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@sentry/cli": "^2.40.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
@@ -3120,22 +3120,27 @@
} }
}, },
"node_modules/@noble/curves": { "node_modules/@noble/curves": {
"version": "1.5.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz",
"integrity": "sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A==", "integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@noble/hashes": "1.4.0" "@noble/hashes": "1.6.0"
},
"engines": {
"node": "^14.21.3 || >=16"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.4.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", "integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 16" "node": "^14.21.3 || >=16"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
@@ -3650,6 +3655,158 @@
"node": ">=14.18" "node": ">=14.18"
} }
}, },
"node_modules/@sentry/cli": {
"version": "2.40.0",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.40.0.tgz",
"integrity": "sha512-yo+ZfrrpVyu/2Q9r4XI84VeC6xTNzTharSJB2D0BNkreL+c16I1ykG1uc/GmmFnYVBq+HHAaYqXVfSUV14IdHw==",
"dev": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"https-proxy-agent": "^5.0.0",
"node-fetch": "^2.6.7",
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0",
"which": "^2.0.2"
},
"bin": {
"sentry-cli": "bin/sentry-cli"
},
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@sentry/cli-darwin": "2.40.0",
"@sentry/cli-linux-arm": "2.40.0",
"@sentry/cli-linux-arm64": "2.40.0",
"@sentry/cli-linux-i686": "2.40.0",
"@sentry/cli-linux-x64": "2.40.0",
"@sentry/cli-win32-i686": "2.40.0",
"@sentry/cli-win32-x64": "2.40.0"
}
},
"node_modules/@sentry/cli-darwin": {
"version": "2.40.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.40.0.tgz",
"integrity": "sha512-GmPGvPU9tjM1Ps/pkUGQa7rImveo4delb2Dc5l8129i1MyD2ugJ5zjeNhIdBHkaObpuude9rUS7sHC4HTU2Wqw==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-arm": {
"version": "2.40.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.40.0.tgz",
"integrity": "sha512-LUdwh3shYXZThkBvmKFUkQvmsCIQu76ZVqU7NXcEWHRF9gITijnSyHKCBPCbcGkb1SqQ92BW/1cJq84Dy0/DRw==",
"cpu": [
"arm"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"linux",
"freebsd"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-arm64": {
"version": "2.40.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.40.0.tgz",
"integrity": "sha512-b8gDORhkhP/g1CTYVKzBlbYlmC3BqkgEzAXP8ViFxX1NNS7dK9Hr84cVnDGxhSIfCP8TW1d5V3AGeHwQr5EwEg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"linux",
"freebsd"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-i686": {
"version": "2.40.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.40.0.tgz",
"integrity": "sha512-sZo3QykQRpMkrz0Eb07ViyK++C6Iir1j7Rpsj/97y5WDncR8TrpGTn6ceuuVRt4clA09/ZIvwuS7amfeKN6jQw==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"linux",
"freebsd"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-x64": {
"version": "2.40.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.40.0.tgz",
"integrity": "sha512-ctpBFuyk2fP97FkxWTD9olI1BM1cy+rUIfnUqmrjXneTaUi3RFIFBB4koYhh1UT6OCWIRvChRIq40Rd9R3Pw8A==",
"cpu": [
"x64"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"linux",
"freebsd"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-i686": {
"version": "2.40.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.40.0.tgz",
"integrity": "sha512-4SYD40zJS7hVbFzAwXvXcVIoc7xsWa6L1RW1SQlt+Woh5MTPk7FMMSGft8021OSGTljiuqQzx4ecnXMO0K/gOw==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-x64": {
"version": "2.40.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.40.0.tgz",
"integrity": "sha512-QEW2Ra4Wsr4y6AwcxOk2hL0zMlCK+adTSTaptTMmcm52el8XjdMwsNo7d/416HUYNcND0YZGih7D+KERepyQSw==",
"cpu": [
"x64"
],
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/core": { "node_modules/@sentry/core": {
"version": "8.35.0", "version": "8.35.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.35.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.35.0.tgz",
@@ -3784,9 +3941,10 @@
} }
}, },
"node_modules/@tiptap/core": { "node_modules/@tiptap/core": {
"version": "2.7.2", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.7.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.0.tgz",
"integrity": "sha512-rGAH90LPMR5OIG7vuTDRw8WxDYxPXSxuGtu++mxPF+Bv7V2ijPOy3P1oyV1G3KGoS0pPiNugLh+tVLsElcx/9Q==", "integrity": "sha512-0S3AWx6E2QqwdQqb6z0/q6zq2u9lA9oL3BLyAaITGSC9zt8OwjloS2k1zN6wLa9hp2rO0c0vDnWsTPeFaEaMdw==",
"license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
@@ -3814,53 +3972,57 @@
} }
}, },
"node_modules/@tiptap/extension-code": { "node_modules/@tiptap/extension-code": {
"version": "2.6.6", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.6.6.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.11.0.tgz",
"integrity": "sha512-JrEFKsZiLvfvOFhOnnrpA0TzCuJjDeysfbMeuKUZNV4+DhYOL28d39H1++rEtJAX0LcbBU60oC5/PrlU9SpvRQ==", "integrity": "sha512-2roNZxcny1bGjyZ8x6VmGTuKbwfJyTZ1hiqPc/CRTQ1u42yOhbjF4ziA5kfyUoQlzygZrWH9LR5IMYGzPQ1N3w==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.6" "@tiptap/core": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-code-block": { "node_modules/@tiptap/extension-code-block": {
"version": "2.6.6", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.6.6.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.11.0.tgz",
"integrity": "sha512-1YLp/zHMHSkE2xzht8nPR6T4sQJJ3ket798czxWuQEbetFv/l0U/mpiPpYSLObj6oTAoqYZ0kWXZj5eQSpPB8Q==", "integrity": "sha512-8of3qTOLjpveHBrrk8KVliSUVd6R2i2TNrBj0f/21HcFVAy0fP++02p6vI6UPOhwM3+p3CprGdSM48DFCu1rqw==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.6", "@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.6.6" "@tiptap/pm": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-document": { "node_modules/@tiptap/extension-document": {
"version": "2.6.6", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.6.6.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.11.0.tgz",
"integrity": "sha512-6qlH5VWzLHHRVeeciRC6C4ZHpMsAGPNG16EF53z0GeMSaaFD/zU3B239QlmqXmLsAl8bpf8Bn93N0t2ABUvScw==", "integrity": "sha512-9YI0AT3mxyUZD7NHECHyV1uAjQ8KwxOS5ACwvrK1MU8TqY084LmodYNTXPKwpqbr51yvt3qZq1R7UIVu4/22Cg==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.6" "@tiptap/core": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-dropcursor": { "node_modules/@tiptap/extension-dropcursor": {
"version": "2.6.6", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.6.6.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.11.0.tgz",
"integrity": "sha512-O6CeKriA9uyHsg7Ui4z5ZjEWXQxrIL+1zDekffW0wenGC3G4LUsCzAiFS4LSrR9a3u7tnwqGApW10rdkmCGF4w==", "integrity": "sha512-p7tUtlz7KzBa+06+7W2LJ8AEiHG5chdnUIapojZ7SqQCrFRVw70R+orpkzkoictxNNHsun0A9FCUy4rz8L0+nQ==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.6", "@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.6.6" "@tiptap/pm": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-floating-menu": { "node_modules/@tiptap/extension-floating-menu": {
@@ -3881,41 +4043,44 @@
} }
}, },
"node_modules/@tiptap/extension-gapcursor": { "node_modules/@tiptap/extension-gapcursor": {
"version": "2.6.6", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.6.6.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.11.0.tgz",
"integrity": "sha512-O2lQ2t0X0Vsbn3yLWxFFHrXY6C2N9Y6ZF/M7LWzpcDTUZeWuhoNkFE/1yOM0h6ZX1DO2A9hNIrKpi5Ny8yx+QA==", "integrity": "sha512-1TVOthPkUYwTQnQwP0BzuIHVz09epOiXJQ3GqgNZsmTehwcMzz2vGCpx1JXhZ5DoMaREHNLCdraXb1n2FdhDNA==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.6", "@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.6.6" "@tiptap/pm": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-hard-break": { "node_modules/@tiptap/extension-hard-break": {
"version": "2.6.6", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.6.6.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.11.0.tgz",
"integrity": "sha512-bsUuyYBrMDEiudx1dOQSr9MzKv13m0xHWrOK+DYxuIDYJb5g+c9un5cK7Js+et/HEYYSPOoH/iTW6h+4I5YeUg==", "integrity": "sha512-7pMgPNk2FnPT0LcWaWNNxOLK3LQnRSYFgrdBGMXec3sy+y3Lit3hM+EZhbZcHpTIQTbWWs+eskh1waRMIt0ZaQ==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.6" "@tiptap/core": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-history": { "node_modules/@tiptap/extension-history": {
"version": "2.6.6", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.6.6.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.11.0.tgz",
"integrity": "sha512-tPTzAmPGqMX5Bd5H8lzRpmsaMvB9DvI5Dy2za/VQuFtxgXmDiFVgHRkRXIuluSkPTuANu84XBOQ0cBijqY8x4w==", "integrity": "sha512-eEUEDoOtS17AHVEPbGfZ+x2L5A87SiIsppWYTkpfIH/8EnVQmzu+3i1tcT9cWvHC31d9JTG7TDptVuuHr30TJw==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.6", "@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.6.6" "@tiptap/pm": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-image": { "node_modules/@tiptap/extension-image": {
@@ -3949,15 +4114,16 @@
} }
}, },
"node_modules/@tiptap/extension-paragraph": { "node_modules/@tiptap/extension-paragraph": {
"version": "2.6.6", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.6.6.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.11.0.tgz",
"integrity": "sha512-fD/onCr16UQWx+/xEmuFC2MccZZ7J5u4YaENh8LMnAnBXf78iwU7CAcmuc9rfAEO3qiLoYGXgLKiHlh2ZfD4wA==", "integrity": "sha512-xLNC05An3SQq0bVHJtOTLa8As5r6NxDZFpK0NZqO2hTq/fAIRL/9VPeZ8E0tziXULwIvIPp+L0Taw3TvaUkRUg==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.6" "@tiptap/core": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-placeholder": { "node_modules/@tiptap/extension-placeholder": {
@@ -3975,41 +4141,43 @@
} }
}, },
"node_modules/@tiptap/extension-text": { "node_modules/@tiptap/extension-text": {
"version": "2.6.6", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.6.6.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.11.0.tgz",
"integrity": "sha512-e84uILnRzNzcwK1DVQNpXVmBG1Cq3BJipTOIDl1LHifOok7MBjhI/X+/NR0bd3N2t6gmDTWi63+4GuJ5EeDmsg==", "integrity": "sha512-LcyrP+7ZEVx3YaKzjMAeujq+4xRt4mZ3ITGph2CQ4vOKFaMI8bzSR909q18t7Qyyvek0a9VydEU1NHSaq4G5jw==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.6" "@tiptap/core": "^2.7.0"
} }
}, },
"node_modules/@tiptap/pm": { "node_modules/@tiptap/pm": {
"version": "2.7.2", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.7.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.0.tgz",
"integrity": "sha512-RiRPlwpuE6IHDJytE0tglbFlWELOaqeyGRGv25wBTjzV1plnqC5B3U65XY/8kKuuLjdd3NpRfR68DXBafusSBg==", "integrity": "sha512-4RU6bpODkMY+ZshzdRFcuUc5jWlMW82LWXR6UOsHK/X/Mav41ZFS0Cyf+hQM6gxxTB09YFIICmGpEpULb+/CuA==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"prosemirror-changeset": "^2.2.1", "prosemirror-changeset": "^2.2.1",
"prosemirror-collab": "^1.3.1", "prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.0", "prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1", "prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2", "prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1", "prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0", "prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2", "prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.0", "prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4", "prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.22.3", "prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1", "prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.4.0", "prosemirror-tables": "^1.6.1",
"prosemirror-trailing-node": "^3.0.0", "prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.0", "prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.33.10" "prosemirror-view": "^1.37.0"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -4017,16 +4185,17 @@
} }
}, },
"node_modules/@tiptap/suggestion": { "node_modules/@tiptap/suggestion": {
"version": "2.6.4", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.6.4.tgz", "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.11.0.tgz",
"integrity": "sha512-t4GOEcsVSCwTlugHjZdK5Swe6or/tBej5E3ZWYOFHxkNLDod76Q7hvAeBPYrLeDo6m3sPnxrazfdqSeVclk72g==", "integrity": "sha512-f+KcczhzEEy2f7/0N/RSID+Z6NjxCX6ab26NLfWZxdaEm/J+vQ2Pqh/e5Z59vMfKiC0DJXVcO0rdv2LBh23qDw==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^2.6.4", "@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.6.4" "@tiptap/pm": "^2.7.0"
} }
}, },
"node_modules/@trapezedev/gradle-parse": { "node_modules/@trapezedev/gradle-parse": {
@@ -4294,7 +4463,8 @@
"node_modules/@types/events": { "node_modules/@types/events": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==" "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
"license": "MIT"
}, },
"node_modules/@types/fs-extra": { "node_modules/@types/fs-extra": {
"version": "8.1.5", "version": "8.1.5",
@@ -4396,6 +4566,15 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/@types/ws": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
"integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.0.tgz",
@@ -4664,18 +4843,19 @@
} }
}, },
"node_modules/@welshman/app": { "node_modules/@welshman/app": {
"version": "0.0.27", "version": "0.0.36",
"resolved": "https://registry.npmjs.org/@welshman/app/-/app-0.0.27.tgz", "resolved": "https://registry.npmjs.org/@welshman/app/-/app-0.0.36.tgz",
"integrity": "sha512-o8voN+ldio+LjYathHKhTG0Vx8rZLoHksAPLLg4rzgAjqKIgWetQ4XjU/Fjqv/5rNNjBh6u0Jr5vR1PUkyptOw==", "integrity": "sha512-ECUaBiDE896P6LXdE3yN49z0I2MCvjA0lO6FOd2BCRfmnmdbTnC+FLcoPGTS262/uDuJz+rr3utBjq8DylugaQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@welshman/dvm": "~0.0.10", "@types/throttle-debounce": "^5.0.2",
"@welshman/feeds": "~0.0.25", "@welshman/dvm": "~0.0.13",
"@welshman/lib": "~0.0.24", "@welshman/feeds": "~0.0.29",
"@welshman/net": "~0.0.35", "@welshman/lib": "~0.0.37",
"@welshman/signer": "~0.0.14", "@welshman/net": "~0.0.45",
"@welshman/store": "~0.0.12", "@welshman/signer": "~0.0.19",
"@welshman/util": "~0.0.45", "@welshman/store": "~0.0.15",
"@welshman/util": "~0.0.54",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"idb": "^8.0.0", "idb": "^8.0.0",
"svelte": "^4.2.18", "svelte": "^4.2.18",
@@ -4683,94 +4863,158 @@
} }
}, },
"node_modules/@welshman/content": { "node_modules/@welshman/content": {
"version": "0.0.12", "version": "0.0.15",
"resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.0.12.tgz", "resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.0.15.tgz",
"integrity": "sha512-hdrZkHlDKJx8i8FdEJo4NFlBMRJWDkZHBYCBCbx77fcxPN8nJ2yKCl7bmIM51XwEFRrZMOQrmQswvYuOr8h1DQ==", "integrity": "sha512-y0f0iLIaHUqEJJ0ziRWbGw13mg0tOLTKpHQNgIXJ03PD3xGHBaQ5xPWiOI8XeUt35KgrayvQZHsaqfAsOWkwag==",
"license": "MIT",
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "^7.0.2", "@braintree/sanitize-url": "^7.0.2",
"@welshman/lib": "~0.0.34",
"nostr-tools": "^2.7.2" "nostr-tools": "^2.7.2"
},
"engines": {
"node": ">=12.0.0"
} }
}, },
"node_modules/@welshman/dvm": { "node_modules/@welshman/dvm": {
"version": "0.0.10", "version": "0.0.13",
"resolved": "https://registry.npmjs.org/@welshman/dvm/-/dvm-0.0.10.tgz", "resolved": "https://registry.npmjs.org/@welshman/dvm/-/dvm-0.0.13.tgz",
"integrity": "sha512-9MlwSmsFeczt+tirKWBenOaWRy1QzcXj0V4Ibc4O2ZdpGkybR3AI79uYLwAI+TG/pkEzHqY+1i7OXfjhBXqlyw==", "integrity": "sha512-C8y4s7wDJTJ6DVuzQoRLAhMpFD+kBoRHlc7kCTmjzh62VOmDSY+46xKttK/WaEnypPiPbIbBg3hd3+tO2A9KoQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@welshman/lib": "~0.0.21", "@noble/hashes": "^1.6.1",
"@welshman/net": "~0.0.25", "@welshman/lib": "~0.0.34",
"@welshman/util": "~0.0.37", "@welshman/net": "~0.0.43",
"@welshman/util": "~0.0.52",
"nostr-tools": "^2.7.2" "nostr-tools": "^2.7.2"
} }
}, },
"node_modules/@welshman/dvm/node_modules/@noble/hashes": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz",
"integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@welshman/editor": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@welshman/editor/-/editor-0.0.4.tgz",
"integrity": "sha512-tcMwLuBaBtT2JgON5f+Fd4Cg9oM7QMnXW9voGP+RqH1gJt0W6rjjQCtpqEcgdVtHhmaSL1P+tM4ORmOinoCv+A==",
"peerDependencies": {
"@tiptap/core": "^2.9.1",
"@tiptap/extension-code": "^2.9.1",
"@tiptap/extension-code-block": "^2.9.1",
"@tiptap/extension-document": "^2.9.1",
"@tiptap/extension-dropcursor": "^2.9.1",
"@tiptap/extension-gapcursor": "^2.9.1",
"@tiptap/extension-hard-break": "^2.9.1",
"@tiptap/extension-history": "^2.9.1",
"@tiptap/extension-paragraph": "^2.9.1",
"@tiptap/extension-placeholder": "^2.9.1",
"@tiptap/extension-text": "^2.9.1",
"@tiptap/pm": "^2.9.1",
"@tiptap/suggestion": "^2.9.1",
"@welshman/lib": "~0.0.36",
"@welshman/util": "~0.0.53",
"nostr-editor": "github:cesardeazevedo/nostr-editor#a211491c",
"nostr-tools": "^2.8.1",
"svelte": "^4.0.0",
"svelte-tiptap": "^1.0.0"
}
},
"node_modules/@welshman/feeds": { "node_modules/@welshman/feeds": {
"version": "0.0.25", "version": "0.0.30",
"resolved": "https://registry.npmjs.org/@welshman/feeds/-/feeds-0.0.25.tgz", "resolved": "https://registry.npmjs.org/@welshman/feeds/-/feeds-0.0.30.tgz",
"integrity": "sha512-XoSQjOH71n6gFH5Lgm5szSrXTh82a8B608mDakYHdv7XXtGh2Li12kmy6qYFtQtG+pYUx6O+QrWa7UDwmOKdJw==", "integrity": "sha512-Zcex2uJVeYM55zDI1Dhb5I41lYGD4BURWl95nbFaWbbMYDwoAFIS2cPXBsaGNrITzsz8qByvRs2RnplrmZwSzA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@welshman/lib": "~0.0.24", "@welshman/lib": "~0.0.37",
"@welshman/util": "~0.0.45" "@welshman/util": "~0.0.54"
} }
}, },
"node_modules/@welshman/lib": { "node_modules/@welshman/lib": {
"version": "0.0.26", "version": "0.0.37",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.26.tgz", "resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.37.tgz",
"integrity": "sha512-RMOsRBb0YXKfAupx1bmrNxacv13pheYvdc91DxGYbtvraPeP+B5C0RR9cQzLfFkv0neCpMA318fYXeTk1KeXaQ==", "integrity": "sha512-qnEjdGIb/QVIYML0EQgGAhucds00hiX8/4rJ9OcqoRUXPS2cDD47BcgYap+kG/OKSfgSL7EH64voAdoprZtuvg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@scure/base": "^1.1.6", "@scure/base": "^1.1.6",
"@types/events": "^3.0.3", "@types/events": "^3.0.3",
"@types/throttle-debounce": "^5.0.2", "events": "^3.3.0"
"events": "^3.3.0",
"throttle-debounce": "^5.0.0"
} }
}, },
"node_modules/@welshman/net": { "node_modules/@welshman/net": {
"version": "0.0.36", "version": "0.0.45",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.36.tgz", "resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.45.tgz",
"integrity": "sha512-ZRYC9Hl45bI/kfnKd+DSX/RnbDIA4VhEGUpQTGikqCjfmbWxvN8zr3ajvkUMQHhe95VyKyjpjQKPpkJn9g6+MQ==", "integrity": "sha512-sXYmfGdqvrj1ssr5xaSUxmJAFo+ScJtodBpzgya0CTLYorKRGoeQRJsyPWdh5VBVtoldPclzGhfvZ11d8d8Lyw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@welshman/lib": "~0.0.25", "@welshman/lib": "~0.0.37",
"@welshman/util": "~0.0.45", "@welshman/util": "~0.0.54",
"isomorphic-ws": "^5.0.0", "isomorphic-ws": "^5.0.0",
"ws": "^8.16.0" "ws": "^8.16.0"
} }
}, },
"node_modules/@welshman/signer": { "node_modules/@welshman/signer": {
"version": "0.0.14", "version": "0.0.19",
"resolved": "https://registry.npmjs.org/@welshman/signer/-/signer-0.0.14.tgz", "resolved": "https://registry.npmjs.org/@welshman/signer/-/signer-0.0.19.tgz",
"integrity": "sha512-aQkKloUpFwtI42hAV7/QpJ0unNouIIG6OXYwBuhFgFDxA3QuE3iVMO/9HYCEpytZkll7+NJ1uWKysMunuy+OeQ==", "integrity": "sha512-+pKkm5HeaSJB6ET456w0zVnbVAjRzYuDagYUns1BQ6Co22nUSp2CntWFFckwbH2eQ9bjdlC65LjRfIP9qhNSrg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@welshman/lib": "~0.0.24", "@noble/curves": "^1.7.0",
"@welshman/net": "~0.0.35", "@noble/hashes": "^1.6.1",
"@welshman/util": "~0.0.45", "@welshman/lib": "~0.0.37",
"@welshman/net": "~0.0.45",
"@welshman/util": "~0.0.54",
"nostr-tools": "^2.7.2" "nostr-tools": "^2.7.2"
}, },
"engines": {
"node": ">=10.x"
},
"peerDependencies": { "peerDependencies": {
"nostr-signer-capacitor-plugin": "^0.0.3" "nostr-signer-capacitor-plugin": "^0.0.3"
} }
}, },
"node_modules/@welshman/signer/node_modules/@noble/hashes": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz",
"integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@welshman/store": { "node_modules/@welshman/store": {
"version": "0.0.12", "version": "0.0.15",
"resolved": "https://registry.npmjs.org/@welshman/store/-/store-0.0.12.tgz", "resolved": "https://registry.npmjs.org/@welshman/store/-/store-0.0.15.tgz",
"integrity": "sha512-69ONyeKOIG0Ba1tEoRPW4JnRaag7yargRS2WhXtyYsbfvQErivWBWLgM4wJz9Qovd+RxljssUswJfDHnZ2FCNQ==", "integrity": "sha512-xapI9cqmpf6ot90T0Z+gFH2HSAby/N8oyLl7u+JASYbNDS3pkK26SviNlTciMC+VBuJChEr1zX5l8RHuLPtw5Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@welshman/lib": "~0.0.23", "@welshman/lib": "~0.0.34",
"@welshman/util": "~0.0.42", "@welshman/util": "~0.0.52",
"svelte": "^4.2.18" "svelte": "^4.2.18"
} }
}, },
"node_modules/@welshman/util": { "node_modules/@welshman/util": {
"version": "0.0.45", "version": "0.0.55",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.45.tgz", "resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.55.tgz",
"integrity": "sha512-6ktWY/LQsBqVYN+PIfT7Aob48QDf4XjXOI1aDJ7tsewaG4FKuMDy9rfHE7/FjVMGhYrKsugaL2GBVFcS4UfRTg==", "integrity": "sha512-eqb2522Y/9oPaf+qd+qnsqZh4tDT8TZj29G/XvXCsGuFxXBpOzJ2uOuEVclXD4AeFdy0CgMRKe7kZ7741ZRCgg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@welshman/lib": "~0.0.24", "@types/ws": "^8.5.13",
"@welshman/lib": "~0.0.37",
"nostr-tools": "^2.7.2" "nostr-tools": "^2.7.2"
},
"engines": {
"node": ">=10.4.0"
} }
}, },
"node_modules/@xml-tools/parser": { "node_modules/@xml-tools/parser": {
@@ -4832,6 +5076,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/aggregate-error": { "node_modules/aggregate-error": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -7099,6 +7356,7 @@
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": { "engines": {
"node": ">=0.8.x" "node": ">=0.8.x"
} }
@@ -8071,6 +8329,20 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/husky": { "node_modules/husky": {
"version": "9.1.6", "version": "9.1.6",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz",
@@ -8623,6 +8895,7 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"ws": "*" "ws": "*"
} }
@@ -9741,8 +10014,7 @@
}, },
"node_modules/nostr-editor": { "node_modules/nostr-editor": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/nostr-editor/-/nostr-editor-0.0.3.tgz", "resolved": "git+ssh://git@github.com/cesardeazevedo/nostr-editor.git#a211491c7cfeb792ae58ba91d295fe747c151ded",
"integrity": "sha512-ODfwzebBRweaYt8l0pz8EbV4OqbEKZpDAVdoU+j7ubmfjhqIyk1PcQoikEZ8UasqkBcZjEQMAPl776F8nb55fQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"light-bolt11-decoder": "^3.1.1" "light-bolt11-decoder": "^3.1.1"
@@ -9773,9 +10045,10 @@
} }
}, },
"node_modules/nostr-tools": { "node_modules/nostr-tools": {
"version": "2.7.2", "version": "2.10.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.7.2.tgz", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz",
"integrity": "sha512-Bq3Ug0SZFtgtL1+0wCnAe8AJtI7yx/00/a2nUug9SkhfOwlKS92Tef12iCK9FdwXw+oFZWMtRnSwcLayQso+xA==", "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
"license": "Unlicense",
"dependencies": { "dependencies": {
"@noble/ciphers": "^0.5.1", "@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0", "@noble/curves": "1.2.0",
@@ -9785,7 +10058,7 @@
"@scure/bip39": "1.2.1" "@scure/bip39": "1.2.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"nostr-wasm": "v0.1.0" "nostr-wasm": "0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
@@ -10546,6 +10819,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/prompts": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -10587,14 +10870,15 @@
} }
}, },
"node_modules/prosemirror-commands": { "node_modules/prosemirror-commands": {
"version": "1.6.0", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.0.tgz", "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.2.tgz",
"integrity": "sha512-xn1U/g36OqXn2tn5nGmvnnimAj/g1pUx2ypJJIe8WkVX83WyJVC5LTARaxZa2AtQRwntu9Jc5zXs9gL9svp/mg==", "integrity": "sha512-0nDHH++qcf/BuPLYvmqZTUUsPJUCPBUXt0J1ErTcDIS369CTp773itzLGIgIXG4LJXOlwYCr44+Mh4ii6MP1QA==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.0.0", "prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0", "prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0" "prosemirror-transform": "^1.10.2"
} }
}, },
"node_modules/prosemirror-dropcursor": { "node_modules/prosemirror-dropcursor": {
@@ -10653,15 +10937,42 @@
} }
}, },
"node_modules/prosemirror-markdown": { "node_modules/prosemirror-markdown": {
"version": "1.13.0", "version": "1.13.1",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.0.tgz", "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.1.tgz",
"integrity": "sha512-UziddX3ZYSYibgx8042hfGKmukq5Aljp2qoBiJRejD/8MH70siQNz5RB1TrdTPheqLMy4aCe4GYNF10/3lQS5g==", "integrity": "sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"prosemirror-model": "^1.20.0" "prosemirror-model": "^1.20.0"
} }
}, },
"node_modules/prosemirror-markdown/node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT",
"peer": true
},
"node_modules/prosemirror-markdown/node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/prosemirror-markdown/node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT",
"peer": true
},
"node_modules/prosemirror-menu": { "node_modules/prosemirror-menu": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.4.tgz",
@@ -10675,9 +10986,10 @@
} }
}, },
"node_modules/prosemirror-model": { "node_modules/prosemirror-model": {
"version": "1.22.3", "version": "1.24.1",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.22.3.tgz", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.24.1.tgz",
"integrity": "sha512-V4XCysitErI+i0rKFILGt/xClnFJaohe/wrrlT2NSZ+zk8ggQfDH4x2wNK7Gm0Hp4CIoWizvXFP7L9KMaCuI0Q==", "integrity": "sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"orderedmap": "^2.0.0" "orderedmap": "^2.0.0"
@@ -10715,16 +11027,17 @@
} }
}, },
"node_modules/prosemirror-tables": { "node_modules/prosemirror-tables": {
"version": "1.4.0", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.4.0.tgz", "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.2.tgz",
"integrity": "sha512-fxryZZkQG12fSCNuZDrYx6Xvo2rLYZTbKLRd8rglOPgNJGMKIS8uvTt6gGC38m7UCu/ENnXIP9pEz5uDaPc+cA==", "integrity": "sha512-97dKocVLrEVTQjZ4GBLdrrMw7Gv3no8H8yMwf5IRM9OoHrzbWpcH5jJxYgNQIRCtdIqwDctT1HdMHrGTiwp1dQ==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"prosemirror-keymap": "^1.1.2", "prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.8.1", "prosemirror-model": "^1.24.1",
"prosemirror-state": "^1.3.1", "prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.2.1", "prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.13.3" "prosemirror-view": "^1.37.1"
} }
}, },
"node_modules/prosemirror-trailing-node": { "node_modules/prosemirror-trailing-node": {
@@ -10743,18 +11056,20 @@
} }
}, },
"node_modules/prosemirror-transform": { "node_modules/prosemirror-transform": {
"version": "1.10.0", "version": "1.10.2",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.0.tgz", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz",
"integrity": "sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==", "integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.21.0" "prosemirror-model": "^1.21.0"
} }
}, },
"node_modules/prosemirror-view": { "node_modules/prosemirror-view": {
"version": "1.33.11", "version": "1.37.1",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.33.11.tgz", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.37.1.tgz",
"integrity": "sha512-K0z9oMf6EI2ZifS9yW8PUPjEw2o1ZoFAaNzvcuyfcjIzsU6pJMo3tk9r26MyzEsuGHXZwmKPEmrjgFd78biTGA==", "integrity": "sha512-MEAnjOdXU1InxEmhjgmEzQAikaS6lF3hD64MveTPpjOGNTl87iRLA1HupC/DEV6YuK7m4Q9DHFNTjwIVtqz5NA==",
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.20.0", "prosemirror-model": "^1.20.0",
@@ -10762,6 +11077,13 @@
"prosemirror-transform": "^1.1.0" "prosemirror-transform": "^1.1.0"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true,
"license": "MIT"
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
@@ -14068,6 +14390,7 @@
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },
+13 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "flotilla", "name": "flotilla",
"version": "0.1.0", "version": "0.2.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -15,6 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@sentry/cli": "^2.40.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
@@ -56,18 +57,18 @@
"@tiptap/extension-text": "^2.6.6", "@tiptap/extension-text": "^2.6.6",
"@tiptap/suggestion": "^2.6.4", "@tiptap/suggestion": "^2.6.4",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@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.6", "@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.27", "@welshman/app": "~0.0.36",
"@welshman/content": "~0.0.12", "@welshman/content": "~0.0.15",
"@welshman/dvm": "~0.0.10", "@welshman/dvm": "~0.0.13",
"@welshman/feeds": "~0.0.25", "@welshman/editor": "~0.0.4",
"@welshman/lib": "~0.0.26", "@welshman/feeds": "~0.0.30",
"@welshman/net": "~0.0.36", "@welshman/lib": "~0.0.37",
"@welshman/signer": "~0.0.14", "@welshman/net": "~0.0.45",
"@welshman/store": "~0.0.12", "@welshman/signer": "~0.0.19",
"@welshman/util": "~0.0.45", "@welshman/store": "~0.0.15",
"@welshman/util": "~0.0.55",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0", "date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -80,7 +81,6 @@
"nostr-tools": "^2.7.2", "nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"svelte-tiptap": "^1.1.3", "svelte-tiptap": "^1.1.3"
"throttle-debounce": "^5.0.2"
} }
} }
+148 -81
View File
@@ -1,5 +1,5 @@
import {get} from "svelte/store" import {get} from "svelte/store"
import {ctx, uniq, sleep, chunk, equals, choice} from "@welshman/lib" import {ctx, sample, uniq, sleep, chunk, equals} from "@welshman/lib"
import { import {
DELETE, DELETE,
PROFILE, PROFILE,
@@ -8,6 +8,12 @@ import {
FOLLOWS, FOLLOWS,
REACTION, REACTION,
AUTH_JOIN, AUTH_JOIN,
GROUP_JOIN,
GROUP_LEAVE,
GROUP_CREATE,
GROUP_EDIT_META,
GROUPS,
COMMENT,
isSignedEvent, isSignedEvent,
createEvent, createEvent,
displayProfile, displayProfile,
@@ -15,16 +21,16 @@ import {
makeList, makeList,
addToListPublicly, addToListPublicly,
removeFromListByPredicate, removeFromListByPredicate,
getTag,
getListTags, getListTags,
getRelayTags, getRelayTags,
isShareableRelayUrl, isShareableRelayUrl,
getRelayTagValues, getRelayTagValues,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, EventTemplate, List} from "@welshman/util" import type {TrustedEvent, EventTemplate, List} from "@welshman/util"
import type {SubscribeRequestWithHandlers, Subscription} from "@welshman/net" import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {PublishStatus, AuthStatus, SocketStatus, SubscriptionEvent} from "@welshman/net" import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer" import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import type {Nip46Handler} from "@welshman/signer"
import { import {
pubkey, pubkey,
signer, signer,
@@ -46,19 +52,20 @@ import {
nip44EncryptToSelf, nip44EncryptToSelf,
loadRelay, loadRelay,
addSession, addSession,
nip46Perms, clearStorage,
subscribe, dropSession,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk} from "@welshman/app"
import { import {
COMMENT,
tagRoom, tagRoom,
PROTECTED,
userMembership, userMembership,
MEMBERSHIPS,
INDEXER_RELAYS, INDEXER_RELAYS,
NIP46_PERMS,
loadMembership, loadMembership,
loadSettings, loadSettings,
getDefaultPubkeys, getDefaultPubkeys,
getMembershipUrls, userRoomsByUrl,
} from "@app/state" } from "@app/state"
// Utils // Utils
@@ -78,89 +85,92 @@ export const getPubkeyPetname = (pubkey: string) => {
return display return display
} }
export const makeMention = (pubkey: string, hints?: string[]) => [ export const getThunkError = async (thunk: Thunk) => {
"p", const result = await thunk.result
pubkey, const [{status, message}] = Object.values(result) as any
choice(hints || getPubkeyHints(pubkey)),
getPubkeyPetname(pubkey),
]
export const makeIMeta = (url: string, data: Record<string, string>) => [ if (status !== PublishStatus.Success) {
"imeta", return message
`url ${url}`,
...Object.entries(data).map(([k, v]) => [k, v].join(" ")),
]
export const subscribePersistent = (request: SubscribeRequestWithHandlers) => {
let sub: Subscription
let done = false
const start = async () => {
// If the subscription gets closed quickly, don't start flapping
await Promise.all([
sleep(30_000),
new Promise(resolve => {
sub = subscribe(request)
sub.emitter.on(SubscriptionEvent.Complete, resolve)
}),
])
if (!done) {
start()
}
}
start()
return () => {
done = true
sub?.close()
} }
} }
// Log in // Log in
export const loginWithNip46 = async (token: string, handler: Nip46Handler) => { export const loginWithNip46 = async ({
const secret = makeSecret() relays,
const broker = Nip46Broker.get({secret, handler}) signerPubkey,
const result = await broker.connect(token, nip46Perms) clientSecret = makeSecret(),
connectSecret = "",
}: {
relays: string[]
signerPubkey: string
clientSecret?: string
connectSecret?: string
}) => {
const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
const result = await broker.connect(connectSecret, NIP46_PERMS)
if (!result) return false // TODO: remove ack result
if (!["ack", connectSecret].includes(result)) return false
const pubkey = await broker.getPublicKey() const pubkey = await broker.getPublicKey()
if (!pubkey) return false if (!pubkey) return false
addSession({method: "nip46", pubkey, secret, handler}) await loadUserData(pubkey)
const handler = {relays, pubkey: signerPubkey}
addSession({method: "nip46", pubkey, secret: clientSecret, handler})
return true return true
} }
// Log out
export const logout = async () => {
const $pubkey = pubkey.get()
if ($pubkey) {
dropSession($pubkey)
}
await clearStorage()
localStorage.clear()
}
// Loaders // Loaders
export const loadUserData = ( export const loadUserData = (
pubkey: string, pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {}, request: Partial<SubscribeRequestWithHandlers> = {},
) => { ) => {
const promise = Promise.all([ const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request), loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request), loadMembership(pubkey, request),
loadSettings(pubkey, request), loadSettings(pubkey, request),
loadProfile(pubkey, request), loadProfile(pubkey, request),
loadFollows(pubkey, request), loadFollows(pubkey, request),
loadMutes(pubkey, request), loadMutes(pubkey, request),
]),
]) ])
// Load followed profiles slowly in the background without clogging other stuff up // Load followed profiles slowly in the background without clogging other stuff up. Only use a single
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
promise.then(async () => { promise.then(async () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) { for (const pubkeys of chunk(50, getDefaultPubkeys())) {
await sleep(300) const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) { for (const pubkey of pubkeys) {
loadMembership(pubkey) loadMembership(pubkey, {relays})
loadProfile(pubkey) loadProfile(pubkey, {relays})
loadFollows(pubkey) loadFollows(pubkey, {relays})
loadMutes(pubkey) loadMutes(pubkey, {relays})
} }
} }
}) })
@@ -185,10 +195,37 @@ export const broadcastUserData = async (relays: string[]) => {
} }
} }
// NIP 29 stuff
export const nip29 = {
createRoom: (url: string, room: string) => {
const event = createEvent(GROUP_CREATE, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
editMeta: (url: string, room: string, meta: Record<string, string>) => {
const event = createEvent(GROUP_EDIT_META, {
tags: [tagRoom(room, url), ...Object.entries(meta)],
})
return publishThunk({event, relays: [url]})
},
joinRoom: (url: string, room: string) => {
const event = createEvent(GROUP_JOIN, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
leaveRoom: (url: string, room: string) => {
const event = createEvent(GROUP_LEAVE, {tags: [tagRoom(room, url)]})
return publishThunk({event, relays: [url]})
},
}
// List updates // List updates
export const addSpaceMembership = async (url: string) => { export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -196,7 +233,7 @@ export const addSpaceMembership = async (url: string) => {
} }
export const removeSpaceMembership = async (url: string) => { export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url const pred = (t: string[]) => 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([ const relays = uniq([
@@ -208,17 +245,21 @@ export const removeSpaceMembership = async (url: string) => {
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
export const addRoomMembership = async (url: string, room: string) => { export const addRoomMembership = async (url: string, room: string, name: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const event = await addToListPublicly(list, tagRoom(room, url)).reconcile(nip44EncryptToSelf) const newTags = [
["r", url],
["group", room, url, name],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
export const removeRoomMembership = async (url: string, room: string) => { export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS}) const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => equals(tagRoom(room, url), t) const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([ const relays = uniq([
url, url,
@@ -247,7 +288,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
url, url,
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(), ...ctx.app.router.FromUser().getUrls(),
...getMembershipUrls(userMembership.get()), ...userRoomsByUrl.get().keys(),
], ],
}) })
} }
@@ -268,7 +309,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
relays: [ relays: [
...INDEXER_RELAYS, ...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(), ...ctx.app.router.FromUser().getUrls(),
...getMembershipUrls(userMembership.get()), ...userRoomsByUrl.get().keys(),
], ],
}) })
} }
@@ -288,15 +329,19 @@ export const checkRelayAccess = async (url: string, claim = "") => {
const result = await thunk.result const result = await thunk.result
if (result[url].status !== PublishStatus.Success) { if (result[url].status === PublishStatus.Failure) {
const message = const message =
connection.auth.message?.replace(/^.*: /, "") || connection.auth.message?.replace(/^.*: /, "") ||
result[url].message?.replace(/^.*: /, "") || result[url].message?.replace(/^.*: /, "") ||
"join request rejected" "join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict
if (message !== "missing group (`h`) tag") {
return `Failed to join relay (${message})` return `Failed to join relay (${message})`
} }
} }
}
export const checkRelayProfile = async (url: string) => { export const checkRelayProfile = async (url: string) => {
const relay = await loadRelay(url) const relay = await loadRelay(url)
@@ -330,10 +375,15 @@ export const checkRelayAuth = async (url: string, timeout = 3000) => {
} }
export const attemptRelayAccess = async (url: string, claim = "") => { export const attemptRelayAccess = async (url: string, claim = "") => {
const checks = [checkRelayProfile, checkRelayConnection, checkRelayAccess, checkRelayAuth] const checks = [
() => checkRelayProfile(url),
() => checkRelayConnection(url),
() => checkRelayAccess(url, claim),
() => checkRelayAuth(url),
]
for (const check of checks) { for (const check of checks) {
const error = await check(url) const error = await check()
if (error) { if (error) {
return error return error
@@ -365,14 +415,37 @@ export const sendWrapped = async ({
) )
} }
export const makeDelete = ({event}: {event: TrustedEvent}) => {
const tags = [["k", String(event.kind)], ...tagEvent(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
}
return createEvent(DELETE, {tags})
}
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
publishThunk({event: makeDelete({event}), relays})
export type ReactionParams = { export type ReactionParams = {
event: TrustedEvent event: TrustedEvent
content: string content: string
tags?: string[][]
} }
export const makeReaction = ({event, content, tags = []}: ReactionParams) => export const makeReaction = ({event, content}: ReactionParams) => {
createEvent(REACTION, {content, tags: [...tags, ...tagReactionTo(event)]}) const tags = [["k", String(event.kind)], ...tagReactionTo(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(PROTECTED)
tags.push(groupTag)
}
return createEvent(REACTION, {content, tags})
}
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) => export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
publishThunk({event: makeReaction(params), relays}) publishThunk({event: makeReaction(params), relays})
@@ -401,19 +474,13 @@ export const makeComment = ({event, content, tags = []}: ReplyParams) => {
if (seenRoots.size === 0) { if (seenRoots.size === 0) {
tags.push(["K", String(event.kind)]) tags.push(["K", String(event.kind)])
tags.push(["E", event.id]) tags.push(["E", event.id])
} else { }
tags.push(["k", String(event.kind)]) tags.push(["k", String(event.kind)])
tags.push(["e", event.id]) tags.push(["e", event.id])
}
return createEvent(COMMENT, {content, tags}) return createEvent(COMMENT, {content, tags})
} }
export const publishComment = ({relays, ...params}: ReplyParams & {relays: string[]}) => export const publishComment = ({relays, ...params}: ReplyParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays}) publishThunk({event: makeComment(params), relays})
export const makeDelete = ({event}: {event: TrustedEvent}) =>
createEvent(DELETE, {tags: [["k", String(event.kind)], ...tagEvent(event)]})
export const publishDelete = ({relays, event}: {relays: string[]; event: TrustedEvent}) =>
publishThunk({event: makeDelete({event}), relays})
+20 -1
View File
@@ -4,7 +4,26 @@
import Landing from "@app/components/Landing.svelte" import Landing from "@app/components/Landing.svelte"
import Toast from "@app/components/Toast.svelte" import Toast from "@app/components/Toast.svelte"
import PrimaryNav from "@app/components/PrimaryNav.svelte" import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {modals} from "@app/modal" import EmailConfirm from "@app/components/EmailConfirm.svelte"
import PasswordReset from "@app/components/PasswordReset.svelte"
import {BURROW_URL} from "@app/state"
import {modals, pushModal} from "@app/modal"
if (BURROW_URL && !$pubkey) {
if ($page.url.pathname === "/confirm-email") {
pushModal(EmailConfirm, {
email: $page.url.searchParams.get("email"),
confirm_token: $page.url.searchParams.get("confirm_token"),
})
}
if ($page.url.pathname === "/reset-password") {
pushModal(PasswordReset, {
email: $page.url.searchParams.get("email"),
reset_token: $page.url.searchParams.get("reset_token"),
})
}
}
</script> </script>
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
+20 -26
View File
@@ -1,60 +1,54 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Readable} from "svelte/store" import {writable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
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 {getEditorOptions, getEditorTags} from "@lib/editor" import {getEditor} from "@app/editor"
import {getPubkeyHints} from "@app/commands"
export let onSubmit export let onSubmit: any
export let content = "" export let content = ""
export let editor: ReturnType<typeof getEditor> | undefined = undefined
let editor: Readable<Editor> const uploading = writable(false)
let element: HTMLElement
const uploadFiles = () => $editor!.chain().selectFiles().run()
const submit = () => { const submit = () => {
if ($loading) return if ($uploading) return
onSubmit({ onSubmit({
content: $editor.getText({blockSeparator: "\n"}), content: $editor!.getText({blockSeparator: "\n"}).trim(),
tags: getEditorTags($editor), tags: $editor!.storage.nostr.getEditorTags(),
}) })
$editor.chain().clearContent().run() $editor!.chain().clearContent().run()
} }
$: loading = $editor?.storage.fileUpload.loading
onMount(() => { onMount(() => {
editor = createEditor( editor = getEditor({autofocus: !isMobile, aggressive: true, element, submit, uploading})
getEditorOptions({
submit,
getPubkeyHints,
submitOnEnter: true,
autofocus: !isMobile,
}),
)
$editor.commands.setContent(content) $editor!.chain().setContent(content).run()
}) })
</script> </script>
<form <form
class="relative z-feature flex gap-2 p-2" class="relative z-feature flex gap-2 p-2"
on:submit|preventDefault={$loading ? undefined : submit}> on:submit|preventDefault={$uploading ? undefined : submit}>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200" class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$loading} disabled={$uploading}
on:click={$editor.commands.selectFiles}> on:click={uploadFiles}>
{#if $loading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
<Icon icon="gallery-send" /> <Icon icon="gallery-send" />
{/if} {/if}
</Button> </Button>
<div class="chat-editor flex-grow overflow-hidden"> <div class="chat-editor flex-grow overflow-hidden">
<EditorContent editor={$editor} /> <div bind:this={element} />
</div> </div>
</form> </form>
@@ -1,36 +0,0 @@
<script lang="ts">
import {sortBy, append} from "@welshman/lib"
import type {EventContent, TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import {deriveEvents} from "@welshman/store"
import ChannelMessage from "@app/components/ChannelMessage.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import {tagRoom, COMMENT} from "@app/state"
import {publishComment} from "@app/commands"
export let url, room, event: TrustedEvent
const replies = deriveEvents(repository, {
filters: [{kinds: [COMMENT], "#E": [event.id]}],
})
const onSubmit = ({content, tags}: EventContent) =>
publishComment({
event,
content,
tags: append(tagRoom(room, url), tags),
relays: [url],
})
</script>
<div class="col-2">
<div class="overflow-auto pt-3">
<ChannelMessage {url} {room} {event} showPubkey isHead inert />
{#each sortBy(e => e.created_at, $replies) as reply (reply.id)}
<ChannelMessage {url} {room} event={reply} showPubkey inert />
{/each}
</div>
<div class="bottom-0 left-0 right-0">
<ChannelCompose {onSubmit} />
</div>
</div>
+27 -43
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import {readable} from "svelte/store"
import {hash} from "@welshman/lib" import {hash} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import { import {
@@ -12,104 +11,89 @@
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import LongPress from "@lib/components/LongPress.svelte" import LongPress from "@lib/components/LongPress.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import Link from "@lib/components/Link.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte" import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ReplySummary from "@app/components/ReplySummary.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelConversation from "@app/components/ChannelConversation.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte" import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte" import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte" import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
import {colors, tagRoom, deriveEvent, pubkeyLink} from "@app/state" import {colors} from "@app/state"
import {publishDelete, publishReaction} from "@app/commands" import {publishDelete, publishReaction} from "@app/commands"
import {pushDrawer, pushModal} from "@app/modal" import {pushModal} from "@app/modal"
export let url, room export let url, room
export let event: TrustedEvent export let event: TrustedEvent
export let replyTo: any = undefined
export let showPubkey = false export let showPubkey = false
export let isHead = false
export let inert = false export let inert = false
const thunk = $thunks[event.id] const thunk = $thunks[event.id]
const profile = deriveProfile(event.pubkey) const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey) const profileDisplay = deriveProfileDisplay(event.pubkey)
const rootTag = event.tags.find(t => t[0].match(/^e$/i))
const rootId = rootTag?.[1]
const rootHints = [rootTag?.[2]].filter(Boolean) as string[]
const rootEvent = rootId ? deriveEvent(rootId, rootHints) : readable(null)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length] const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const onClick = () => { const reply = () => replyTo(event)
const root = $rootEvent || event
pushDrawer(ChannelConversation, {url, room, event: root})
}
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event}) const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey) const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { if (reaction) {
publishDelete({relays: [url], event: reaction}) publishDelete({relays: [url], event: reaction})
} else { } else {
publishReaction({ publishReaction({event, content, relays: [url]})
event,
content,
relays: [url],
tags: [tagRoom(room, url)],
})
} }
} }
</script> </script>
<LongPress <LongPress
on:click={isMobile || inert ? null : onClick} data-event={event.id}
onLongPress={inert ? null : onLongPress} onLongPress={inert ? null : onLongPress}
class="group relative flex w-full flex-col gap-1 p-2 text-left transition-colors {inert class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
? 'hover:bg-base-300'
: ''}">
<div class="flex w-full gap-3 overflow-auto"> <div class="flex w-full gap-3 overflow-auto">
{#if showPubkey} {#if showPubkey}
<Link external href={pubkeyLink(event.pubkey)} class="flex items-start"> <Button on:click={openProfile} class="flex items-start">
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={10} /> <Avatar src={$profile?.picture} class="border border-solid border-base-content" size={8} />
</Link> </Button>
{:else} {:else}
<div class="w-10 min-w-10 max-w-10" /> <div class="w-8 min-w-8 max-w-8" />
{/if} {/if}
<div class="-mt-1 min-w-0 flex-grow pr-1"> <div class="min-w-0 flex-grow pr-1">
{#if showPubkey} {#if showPubkey}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Link <Button on:click={openProfile} class="text-sm font-bold" style="color: {colorValue}">
external
href={pubkeyLink(event.pubkey)}
class="text-sm font-bold"
style="color: {colorValue}">
{$profileDisplay} {$profileDisplay}
</Link> </Button>
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span> <span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span>
</div> </div>
{/if} {/if}
<div class="text-sm"> <div class="text-sm">
<Content {event} /> <Content {event} quoteProps={{minimal: true, relays: [url]}} />
{#if thunk} {#if thunk}
<ThunkStatus {thunk} class="mt-2" /> <ThunkStatus {thunk} class="mt-2" />
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
<div class="row-2 ml-12"> <div class="row-2 ml-10 mt-1">
{#if !isHead} <ReactionSummary relays={[url]} {event} {onReactionClick} reactionClass="tooltip-right" />
<ReplySummary relays={[url]} {event} on:click={onClick} />
{/if}
<ReactionSummary relays={[url]} {event} {onReactionClick} />
</div> </div>
<button <button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all" class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile} class:group-hover:opacity-100={!isMobile}
on:click|stopPropagation> on:click|stopPropagation>
<ChannelMessageEmojiButton {url} {room} {event} /> <ChannelMessageEmojiButton {url} {room} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" on:click={reply}>
<Icon icon="reply" size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} /> <ChannelMessageMenuButton {url} {event} />
</button> </button>
</LongPress> </LongPress>
@@ -1,19 +1,17 @@
<script lang="ts"> <script lang="ts">
import {noop} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {tagRoom} from "@app/state"
import {publishReaction} from "@app/commands" import {publishReaction} from "@app/commands"
export let url, room, event export let url, room, event
// Tell svelte-check to shut up
noop(room)
const onEmoji = (emoji: NativeEmoji) => const onEmoji = (emoji: NativeEmoji) =>
publishReaction({ publishReaction({event, relays: [url], content: emoji.unicode})
event,
relays: [url],
content: emoji.unicode,
tags: [tagRoom(room, url)],
})
</script> </script>
<EmojiButton {onEmoji} class="btn join-item btn-xs"> <EmojiButton {onEmoji} class="btn join-item btn-xs">
@@ -5,31 +5,20 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte" import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import ChannelConversation from "@app/components/ChannelConversation.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte" import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import {publishReaction} from "@app/commands" import {publishReaction} from "@app/commands"
import {pushModal, pushDrawer} from "@app/modal" import {pushModal} from "@app/modal"
import {tagRoom} from "@app/state"
export let url export let url
export let room
export let event export let event
const onEmoji = (emoji: NativeEmoji) => { const onEmoji = (emoji: NativeEmoji) => {
history.back() history.back()
publishReaction({ publishReaction({event, relays: [url], content: emoji.unicode})
event,
relays: [url],
content: emoji.unicode,
tags: [tagRoom(room, url)],
})
} }
const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) const showEmojiPicker = () => pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true})
const showConversation = () =>
pushDrawer(ChannelConversation, {url, room, event}, {replaceState: true})
const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true}) const showInfo = () => pushModal(EventInfo, {event}, {replaceState: true})
const showDelete = () => pushModal(ConfirmDelete, {url, event}) const showDelete = () => pushModal(ConfirmDelete, {url, event})
@@ -40,10 +29,6 @@
<Icon size={4} icon="smile-circle" /> <Icon size={4} icon="smile-circle" />
Send Reaction Send Reaction
</Button> </Button>
<Button class="btn btn-neutral w-full" on:click={showConversation}>
<Icon size={4} icon="reply" />
View Conversation
</Button>
<Button class="btn btn-neutral" on:click={showInfo}> <Button class="btn btn-neutral" on:click={showInfo}>
<Icon size={4} icon="code-2" /> <Icon size={4} icon="code-2" />
Message Details Message Details
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import {GENERAL, channelsById, makeChannelId} from "@app/state"
export let url
export let room
</script>
{#if room === GENERAL}
general
{:else}
{$channelsById.get(makeChannelId(url, room))?.name || room}
{/if}
+21 -6
View File
@@ -10,7 +10,10 @@
<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 {int, nthNe, MINUTE, sortBy, remove} from "@welshman/lib" import type {Readable} from "svelte/store"
import type {Editor} from "svelte-tiptap"
import {nip19} from "nostr-tools"
import {int, nthNe, MINUTE, sortBy, remove, ctx} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util" import {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import { import {
@@ -29,10 +32,11 @@
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
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 ProfileList from "@app/components/ProfileList.svelte" import ProfileList from "@app/components/ProfileList.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte" import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChannelCompose.svelte" import ChatCompose from "@app/components/ChannelCompose.svelte"
import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME, pubkeyLink} from "@app/state" import {userSettingValues, deriveChat, splitChatId, PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {sendWrapped} from "@app/commands" import {sendWrapped} from "@app/commands"
@@ -52,6 +56,15 @@
const showMembers = () => const showMembers = () =>
pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`}) pushModal(ProfileList, {pubkeys: others, title: `People in this conversation`})
const replyTo = (event: TrustedEvent) => {
const relays = ctx.app.router.Event(event).getUrls()
const nevent = nip19.neventEncode({...event, relays})
$editor.commands.insertNEvent({nevent})
$editor.commands.insertContent("\n")
$editor.commands.focus()
}
const onSubmit = async ({content, ...params}: EventContent) => { const onSubmit = async ({content, ...params}: EventContent) => {
// Remove p tags since they result in forking the conversation // Remove p tags since they result in forking the conversation
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)] const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
@@ -64,6 +77,7 @@
} }
let loading = true let loading = true
let editor: Readable<Editor>
let elements: Element[] = [] let elements: Element[] = []
$: { $: {
@@ -112,10 +126,11 @@
<div slot="title" class="flex flex-col gap-1 sm:flex-row sm:gap-2"> <div slot="title" class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 1} {#if others.length === 1}
{@const pubkey = others[0]} {@const pubkey = others[0]}
<Link external href={pubkeyLink(pubkey)} class="row-2"> {@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<Button on:click={onClick} class="row-2">
<ProfileCircle {pubkey} size={5} /> <ProfileCircle {pubkey} size={5} />
<ProfileName {pubkey} /> <ProfileName {pubkey} />
</Link> </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} />
@@ -170,7 +185,7 @@
{#if type === "date"} {#if type === "date"}
<Divider>{value}</Divider> <Divider>{value}</Divider>
{:else} {:else}
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} /> <ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} {replyTo} />
{/if} {/if}
{/each} {/each}
<p <p
@@ -185,5 +200,5 @@
<slot name="info" /> <slot name="info" />
</p> </p>
</div> </div>
<ChatCompose {onSubmit} /> <ChatCompose bind:editor {onSubmit} />
</div> </div>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {WRAP} from "@welshman/util"
import {repository} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {canDecrypt, PLATFORM_NAME, ensureUnwrapped} from "@app/state"
import {clearModals} from "@app/modal"
export let next
let loading = false
const enableChat = async () => {
canDecrypt.set(true)
for (const event of repository.query([{kinds: [WRAP]}])) {
ensureUnwrapped(event)
}
clearModals()
goto(next)
}
const submit = async () => {
loading = true
try {
await enableChat()
} finally {
loading = false
}
}
const back = () => history.back()
</script>
<form class="column gap-4" on:submit|preventDefault={submit}>
<ModalHeader>
<div slot="title">Enable Messages</div>
<div slot="info">Do you want to enable notes and direct messages?</div>
</ModalHeader>
<p>
By default, notes and direct messages are disabled, since loading them requires
{PLATFORM_NAME} to download and decrypt a lot of data.
</p>
<p>
If you'd like to enable them, please make sure your signer is set up to to auto-approve requests
to decrypt data.
</p>
<ModalFooter>
<Button class="btn btn-link" on:click={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Enable Messages</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+3 -4
View File
@@ -1,7 +1,7 @@
<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, assoc} from "@welshman/lib" import {remove} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadInboxRelaySelections} from "@welshman/app" import {pubkey, loadInboxRelaySelections} from "@welshman/app"
import {fade} from "@lib/transition" import {fade} from "@lib/transition"
@@ -10,7 +10,7 @@
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 {makeChatPath} from "@app/routes" import {makeChatPath} from "@app/routes"
import {CHAT_FILTERS, deriveNotification} from "@app/notifications" import {notifications} from "@app/notifications"
export let id: string export let id: string
export let pubkeys: string[] export let pubkeys: string[]
@@ -19,7 +19,6 @@
const others = remove($pubkey!, pubkeys) const others = remove($pubkey!, pubkeys)
const active = $page.params.chat === id const active = $page.params.chat === id
const path = makeChatPath(pubkeys) const path = makeChatPath(pubkeys)
const notification = deriveNotification(path, CHAT_FILTERS.map(assoc("authors", pubkeys)))
onMount(() => { onMount(() => {
for (const pk of others) { for (const pk of others) {
@@ -47,7 +46,7 @@
</p> </p>
{/if} {/if}
</div> </div>
{#if !active && $notification} {#if !active && $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary" transition:fade /> <div class="h-2 w-2 rounded-full bg-primary" transition:fade />
{/if} {/if}
</div> </div>
+22 -17
View File
@@ -11,25 +11,27 @@
} from "@welshman/app" } from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import LongPress from "@lib/components/LongPress.svelte" import LongPress from "@lib/components/LongPress.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ReplySummary from "@app/components/ReplySummary.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte" import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte" import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte" import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
import {colors, pubkeyLink} from "@app/state" import {colors} from "@app/state"
import {makeDelete, makeReaction, sendWrapped} from "@app/commands" import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
export let event: TrustedEvent export let event: TrustedEvent
export let replyTo: any = undefined
export let pubkeys: string[] export let pubkeys: string[]
export let showPubkey = false export let showPubkey = false
const thunk = $thunks[event.id] const thunk = $thunks[event.id]
const isOwn = event.pubkey === $pubkey
const profile = deriveProfile(event.pubkey) const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey) const profileDisplay = deriveProfileDisplay(event.pubkey)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length] const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
@@ -41,6 +43,8 @@
await sendWrapped({template, pubkeys}) await sendWrapped({template, pubkeys})
} }
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys}) const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys})
const togglePopover = () => { const togglePopover = () => {
@@ -59,14 +63,15 @@
<ThunkStatus {thunk} class="mt-1" /> <ThunkStatus {thunk} class="mt-1" />
{/if} {/if}
<div <div
data-event={event.id}
class="group chat flex items-center justify-end gap-1 px-2" class="group chat flex items-center justify-end gap-1 px-2"
class:chat-start={event.pubkey !== $pubkey} class:chat-start={!isOwn}
class:flex-row-reverse={event.pubkey !== $pubkey} class:flex-row-reverse={!isOwn}
class:chat-end={event.pubkey === $pubkey}> class:chat-end={isOwn}>
<Tippy <Tippy
bind:popover bind:popover
component={ChatMessageMenu} component={ChatMessageMenu}
props={{event, pubkeys, popover}} props={{event, pubkeys, popover, replyTo}}
params={{ params={{
interactive: true, interactive: true,
trigger: "manual", trigger: "manual",
@@ -85,27 +90,28 @@
<Icon icon="menu-dots" size={4} /> <Icon icon="menu-dots" size={4} />
</button> </button>
</Tippy> </Tippy>
<div class="flex min-w-0 flex-col" class:items-end={event.pubkey === $pubkey}> <div class="flex min-w-0 flex-col" class:items-end={isOwn}>
<LongPress <LongPress
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl" class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onLongPress={showMobileMenu}> onLongPress={showMobileMenu}>
{#if showPubkey && event.pubkey !== $pubkey} {#if showPubkey}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Link external href={pubkeyLink(event.pubkey)} class="flex items-center gap-1"> {#if !isOwn}
<Button on:click={openProfile} class="flex items-center gap-1">
<Avatar <Avatar
src={$profile?.picture} src={$profile?.picture}
class="border border-solid border-base-content" class="border border-solid border-base-content"
size={4} /> size={4} />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Link <Button
external on:click={openProfile}
href={pubkeyLink(event.pubkey)}
class="text-sm font-bold" class="text-sm font-bold"
style="color: {colorValue}"> style="color: {colorValue}">
{$profileDisplay} {$profileDisplay}
</Link> </Button>
</div> </div>
</Link> </Button>
{/if}
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span> <span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span>
</div> </div>
{/if} {/if}
@@ -114,8 +120,7 @@
</div> </div>
</LongPress> </LongPress>
<div class="row-2 z-feature -mt-1 ml-4"> <div class="row-2 z-feature -mt-1 ml-4">
<ReplySummary {event} /> <ReactionSummary {event} {onReactionClick} noTooltip />
<ReactionSummary {event} {onReactionClick} />
</div> </div>
</div> </div>
</div> </div>
@@ -8,6 +8,9 @@
export let event export let event
export let pubkeys export let pubkeys
export let popover export let popover
export let replyTo
const reply = () => replyTo(event)
const showInfo = () => { const showInfo = () => {
popover.hide() popover.hide()
@@ -17,6 +20,11 @@
<div class="join border border-solid border-neutral text-xs"> <div class="join border border-solid border-neutral text-xs">
<ChatMessageEmojiButton {event} {pubkeys} /> <ChatMessageEmojiButton {event} {pubkeys} />
{#if replyTo}
<Button class="btn join-item btn-xs" on:click={reply}>
<Icon size={4} icon="reply" />
</Button>
{/if}
<Button class="btn join-item btn-xs" on:click={showInfo}> <Button class="btn join-item btn-xs" on:click={showInfo}>
<Icon size={4} icon="code-2" /> <Icon size={4} icon="code-2" />
</Button> </Button>
+42 -16
View File
@@ -4,7 +4,7 @@
import { import {
parse, parse,
truncate, truncate,
render as renderParsed, renderAsHtml,
isText, isText,
isTopic, isTopic,
isCode, isCode,
@@ -36,6 +36,7 @@
export let showEntire = false export let showEntire = false
export let hideMedia = false export let hideMedia = false
export let expandMode = "block" export let expandMode = "block"
export let quoteProps: Record<string, any> = {}
export let depth = 0 export let depth = 0
const fullContent = parse(event) const fullContent = parse(event)
@@ -44,18 +45,38 @@
showEntire = true showEntire = true
} }
const isBoundary = (i: number) => { const isBlock = (i: number) => {
const parsed = fullContent[i] const parsed = fullContent[i]
if (!parsed || isNewline(parsed)) return true if (!parsed || hideMedia) return false
if (isText(parsed)) return parsed.value.match(/^\s+$/)
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
return true
}
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i) && depth < 1) {
return true
}
return false return false
} }
const isStartAndEnd = (i: number) => Boolean(isBoundary(i - 1) && isBoundary(i + 1)) const isBoundary = (i: number) => {
const parsed = fullContent[i]
const isStartOrEnd = (i: number) => Boolean(isBoundary(i - 1) || isBoundary(i + 1)) if (!parsed || isNewline(parsed)) return true
if (isText(parsed)) return Boolean(parsed.value.match(/^\s+$/))
return false
}
const isStart = (i: number) => isBoundary(i - 1)
const isEnd = (i: number) => isBoundary(i + 1)
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
const isStartOrEnd = (i: number) => isStart(i) || isEnd(i)
const ignoreWarning = () => { const ignoreWarning = () => {
warning = null warning = null
@@ -92,15 +113,17 @@
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}> style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
{#each shortContent as parsed, i} {#each shortContent as parsed, i}
{#if isNewline(parsed)} {#if isNewline(parsed)}
<ContentNewline value={parsed.value} /> <ContentNewline value={parsed.value.slice(isBlock(i - 1) ? 1 : 0)} />
{:else if isTopic(parsed)} {:else if isTopic(parsed)}
<ContentTopic value={parsed.value} /> <ContentTopic value={parsed.value} />
{:else if isCode(parsed)} {:else if isCode(parsed)}
<ContentCode value={parsed.value} isBlock={isStartAndEnd(i)} /> <ContentCode
value={parsed.value}
isBlock={isStartAndEnd(i) || parsed.value.includes("\n")} />
{:else if isCashu(parsed) || isInvoice(parsed)} {:else if isCashu(parsed) || isInvoice(parsed)}
<ContentToken value={parsed.value} /> <ContentToken value={parsed.value} />
{:else if isLink(parsed)} {:else if isLink(parsed)}
{#if isStartOrEnd(i) && !hideMedia && $userSettingValues.show_media} {#if isBlock(i)}
<ContentLinkBlock value={parsed.value} /> <ContentLinkBlock value={parsed.value} />
{:else} {:else}
<ContentLinkInline value={parsed.value} /> <ContentLinkInline value={parsed.value} />
@@ -108,10 +131,10 @@
{:else if isProfile(parsed)} {:else if isProfile(parsed)}
<ContentMention value={parsed.value} /> <ContentMention value={parsed.value} />
{:else if isEvent(parsed) || isAddress(parsed)} {:else if isEvent(parsed) || isAddress(parsed)}
{#if isStartOrEnd(i) && depth < 2 && !hideMedia} {#if isBlock(i)}
<ContentQuote value={parsed.value} {depth} {event}> <ContentQuote {...quoteProps} value={parsed.value} {depth} {event}>
<div slot="note-content" let:event> <div slot="note-content" let:event>
<svelte:self {hideMedia} {event} depth={depth + 1} /> <svelte:self {quoteProps} {hideMedia} {event} depth={depth + 1} />
</div> </div>
</ContentQuote> </ContentQuote>
{:else} {:else}
@@ -123,16 +146,19 @@
</Link> </Link>
{/if} {/if}
{:else if isEllipsis(parsed) && expandInline} {:else if isEllipsis(parsed) && expandInline}
{@html renderParsed(parsed)} {@html renderAsHtml(parsed)}
<button type="button" class="text-sm underline"> Read more </button> <button type="button" class="text-sm underline"> Read more </button>
{:else} {:else}
{@html renderParsed(parsed)} {@html renderAsHtml(parsed)}
{/if} {/if}
{/each} {/each}
</div> </div>
{#if expandBlock} {#if expandBlock}
<div class="relative z-feature -mt-6 flex justify-center bg-gradient-to-t from-base-100 py-2"> <div class="relative z-feature -mt-6 flex justify-center py-2">
<button type="button" class="btn" on:click|stopPropagation|preventDefault={expand}> <button
type="button"
class="btn btn-neutral"
on:click|stopPropagation|preventDefault={expand}>
See more See more
</button> </button>
</div> </div>
+4 -2
View File
@@ -3,6 +3,8 @@
export let isBlock export let isBlock
</script> </script>
<code class="link-content w-full" class:block={isBlock}> <code
{value} class="w-full overflow-auto whitespace-pre rounded bg-neutral px-1 text-neutral-content"
class:block={isBlock}>
{value.trim()}
</code> </code>
+2 -2
View File
@@ -22,7 +22,7 @@
const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true}) const expand = () => pushModal(ContentLinkDetail, {url}, {fullscreen: true})
</script> </script>
<Link external href={url} class="my-2 flex"> <Link external href={url} class="my-2 inline-block">
<div class="overflow-hidden rounded-box leading-[0]"> <div class="overflow-hidden rounded-box leading-[0]">
{#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">
@@ -54,7 +54,7 @@
{/if} {/if}
</div> </div>
{:catch} {:catch}
<p class="bg-alt p-12 text-center"> <p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url} Unable to load a preview for {url}
</p> </p>
{/await} {/await}
+7 -4
View File
@@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import {displayProfile} from "@welshman/util" import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app" import {deriveProfile} from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte"
import {pubkeyLink} from "@app/state" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
export let value export let value
const profile = deriveProfile(value.pubkey) const profile = deriveProfile(value.pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey})
</script> </script>
<Link external href={pubkeyLink(value.pubkey)} class="link-content"> <Button on:click={openProfile} class="link-content">
@{displayProfile($profile)} @{displayProfile($profile)}
</Link> </Button>
+83 -24
View File
@@ -1,46 +1,105 @@
<script lang="ts"> <script lang="ts">
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {goto} from "$app/navigation"
import {ctx, nthEq} from "@welshman/lib" import {ctx, nthEq} from "@welshman/lib"
import {Address} from "@welshman/util" import {tracker, repository} from "@welshman/app"
import type {TrustedEvent} from "@welshman/util" import {Address, DIRECT_MESSAGE, MESSAGE, THREAD} from "@welshman/util"
import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import {deriveEvent, entityLink, MESSAGE, THREAD} from "@app/state" import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath} from "@app/routes" import {makeThreadPath, makeRoomPath} from "@app/routes"
export let value export let value
export let event export let event
export let depth = 0 export let depth = 0
export let relays: string[] = []
export let minimal = false
const {id, identifier, kind, pubkey, relays: relayHints = []} = value const {id, identifier, kind, pubkey, relays: relayHints = []} = value
const addr = new Address(kind, pubkey, identifier) const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
const idOrAddress = id || addr.toString() const mergedRelays = [
const relays = ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls() ...relays,
const quote = deriveEvent(idOrAddress, relays) ...ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls(),
const entity = id ? nip19.neventEncode({id, relays}) : addr.toNaddr() ]
const quote = deriveEvent(idOrAddress, mergedRelays)
const entity = id
? nip19.neventEncode({id, relays: mergedRelays})
: new Address(kind, pubkey, identifier, mergedRelays).toNaddr()
const getLocalHref = (e: TrustedEvent) => { const scrollToEvent = (id: string) => {
const url = e.tags.find(nthEq(0, "~"))?.[2] const element = document.querySelector(`[data-event="${id}"]`) as any
if (!url) return if (element) {
if ([MESSAGE, THREAD].includes(e.kind)) return makeThreadPath(url, e.id) element.scrollIntoView({behavior: "smooth"})
element.style =
"filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
const kind = e.tags.find(nthEq(0, "K"))?.[1] setTimeout(() => {
const id = e.tags.find(nthEq(0, "E"))?.[1] element.style = "transition-property: all; transition-duration: 300ms;"
}, 800)
if (!id || !kind) return setTimeout(() => {
if ([MESSAGE, THREAD].includes(parseInt(kind))) return makeThreadPath(url, id) element.style = ""
}, 800 + 400)
} }
// If we found this event on a relay that the user is a member of, redirect internally return Boolean(element)
$: localHref = $quote ? getLocalHref($quote) : null }
$: href = localHref || entityLink(entity)
const openMessage = (url: string, room: string, id: string) => {
const event = repository.getEvent(id)
if (event) {
goto(makeRoomPath(url, room))
// TODO: if the event doesn't immediately load, this won't work. Scroll up until it's found
setTimeout(() => scrollToEvent(id), 300)
}
return Boolean(event)
}
const onClick = (e: Event) => {
if ($quote) {
if ($quote.kind === DIRECT_MESSAGE) {
return scrollToEvent($quote.id)
}
const [url] = tracker.getRelays($quote.id)
const room = $quote.tags.find(nthEq(0, ROOM))?.[1]
if (url && room) {
if ($quote.kind === THREAD) {
return goto(makeThreadPath(url, $quote.id))
}
if ($quote.kind === MESSAGE) {
return scrollToEvent($quote.id) || openMessage(url, room, $quote.id)
}
const kind = $quote.tags.find(nthEq(0, "K"))?.[1]
const id = $quote.tags.find(nthEq(0, "E"))?.[1]
if (id && kind) {
if (parseInt(kind) === THREAD) {
return goto(makeThreadPath(url, id))
}
if (parseInt(kind) === MESSAGE) {
return scrollToEvent(id) || openMessage(url, room, id)
}
}
}
}
window.open(entityLink(entity))
}
</script> </script>
<Link external={!localHref} {href} class="my-2 block max-w-full text-left"> <Button class="my-2 block max-w-full text-left" on:click={onClick}>
{#if $quote} {#if $quote}
<NoteCard event={$quote} class="bg-alt rounded-box p-4"> <NoteCard {minimal} event={$quote} class="bg-alt rounded-box p-4">
<slot name="note-content" event={$quote} {depth} /> <slot name="note-content" event={$quote} {depth} />
</NoteCard> </NoteCard>
{:else} {:else}
@@ -48,4 +107,4 @@
<Spinner loading>Loading event...</Spinner> <Spinner loading>Loading event...</Spinner>
</div> </div>
{/if} {/if}
</Link> </Button>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import {onMount} from "svelte"
import {postJson, sleep} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/modal"
import {BURROW_URL} from "@app/state"
export let email
export let confirm_token
const login = () => {
pushModal(LogInPassword, {email}, {path: "/"})
}
let error: string
let loading = true
onMount(async () => {
const [res] = await Promise.all([
postJson(BURROW_URL + "/user/confirm-email", {email, confirm_token}),
sleep(2000),
])
error = res.error
loading = false
})
</script>
<div class="column gap-4">
<h1 class="heading">
{#if loading}
Just a second...
{:else if error}
Oops!
{:else}
Success!
{/if}
</h1>
<p class="m-auto max-w-sm text-center">
<Spinner {loading}>
{#if loading}
Hang tight, we're checking your confirmation link.
{:else if error}
Looks like something went wrong. {error}
{:else}
You're all set - click below to log in.
{/if}
</Spinner>
</p>
<Button class="btn btn-primary" on:click={login} disabled={loading}>Continue to Login</Button>
</div>
+17 -18
View File
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Readable} from "svelte/store" import {writable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {randomId} from "@welshman/lib" import {randomId} from "@welshman/lib"
import {createEvent, EVENT_DATE, EVENT_TIME} from "@welshman/util" import {createEvent, EVENT_TIME} from "@welshman/util"
import {publishThunk, dateToSeconds} from "@welshman/app" import {publishThunk, dateToSeconds} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
@@ -11,16 +10,18 @@
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 DateTimeInput from "@lib/components/DateTimeInput.svelte" import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import {getPubkeyHints} from "@app/commands" import {PROTECTED} from "@app/state"
import {getEditorOptions, getEditorTags} from "@lib/editor" import {getEditor} from "@app/editor"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
export let url export let url
const uploading = writable(false)
const back = () => history.back() const back = () => history.back()
const submit = () => { const submit = () => {
if ($loading) return if ($uploading) return
if (!title) { if (!title) {
return pushToast({ return pushToast({
@@ -36,16 +37,16 @@
}) })
} }
const kind = isAllDay ? EVENT_DATE : EVENT_TIME const event = createEvent(EVENT_TIME, {
const event = createEvent(kind, { content: $editor.getText({blockSeparator: "\n"}).trim(),
content: $editor.getText({blockSeparator: "\n"}),
tags: [ tags: [
["d", randomId()], ["d", randomId()],
["title", title], ["title", title],
["location", location], ["location", location],
["start", dateToSeconds(start).toString()], ["start", dateToSeconds(start).toString()],
["end", dateToSeconds(end).toString()], ["end", dateToSeconds(end).toString()],
...getEditorTags($editor), ...$editor.storage.nostr.getEditorTags(),
PROTECTED,
], ],
}) })
@@ -53,17 +54,15 @@
history.back() history.back()
} }
let editor: Readable<Editor> let element: HTMLElement
const isAllDay = false let editor: ReturnType<typeof getEditor>
let title = "" let title = ""
let location = "" let location = ""
let start: Date let start: Date
let end: Date let end: Date
$: loading = $editor?.storage.fileUpload.loading
onMount(() => { onMount(() => {
editor = createEditor(getEditorOptions({submit, getPubkeyHints})) editor = getEditor({submit, element, uploading})
}) })
</script> </script>
@@ -84,13 +83,13 @@
slot="input" slot="input"
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100"> class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
<div class="input-editor flex-grow overflow-hidden"> <div class="input-editor flex-grow overflow-hidden">
<EditorContent editor={$editor} /> <div bind:this={element} />
</div> </div>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="center btn tooltip" class="center btn tooltip"
on:click={$editor.commands.selectFiles}> on:click={() => $editor.chain().selectFiles().run()}>
{#if $loading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
<Icon icon="gallery-send" /> <Icon icon="gallery-send" />
+7 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
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"
@@ -8,10 +9,11 @@
export let event export let event
const note1 = nip19.noteEncode(event.id) const relays = ctx.app.router.Event(event).getUrls()
const nevent1 = nip19.neventEncode({...event, relays})
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 copyId = () => clip(note1) const copyLink = () => clip(nevent1)
const copyPubkey = () => clip(npub1) const copyPubkey = () => clip(npub1)
const copyJson = () => clip(json) const copyJson = () => clip(json)
</script> </script>
@@ -22,11 +24,11 @@
<div slot="info">The full details of this event are shown below.</div> <div slot="info">The full details of this event are shown below.</div>
</ModalHeader> </ModalHeader>
<FieldInline> <FieldInline>
<p slot="label">Event ID</p> <p slot="label">Event Link</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input"> <label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="file" /> <Icon icon="file" />
<input type="text" class="ellipsize min-w-0 grow" value={note1} /> <input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
<Button on:click={copyId} class="flex items-center"> <Button on:click={copyLink} class="flex items-center">
<Icon icon="copy" /> <Icon icon="copy" />
</Button> </Button>
</label> </label>
+4 -6
View File
@@ -1,17 +1,13 @@
<script lang="ts"> <script lang="ts">
import {fromPairs} from "@welshman/lib" import {fromPairs} from "@welshman/lib"
import {secondsToDate, getLocale, formatTimestamp, formatTimestampAsDate} from "@welshman/app" import {formatTimestamp, formatTimestampAsDate, formatTimestampAsTime} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
export let event export let event
const timeFmt = new Intl.DateTimeFormat(getLocale(), {timeStyle: "short"})
$: meta = fromPairs(event.tags) as Record<string, string> $: meta = fromPairs(event.tags) as Record<string, string>
$: end = parseInt(meta.end) $: end = parseInt(meta.end)
$: start = parseInt(meta.start) $: start = parseInt(meta.start)
$: startDate = secondsToDate(start)
$: endDate = secondsToDate(end)
$: startDateDisplay = formatTimestampAsDate(start) $: startDateDisplay = formatTimestampAsDate(start)
$: endDateDisplay = formatTimestampAsDate(end) $: endDateDisplay = formatTimestampAsDate(end)
$: isSingleDay = startDateDisplay === endDateDisplay $: isSingleDay = startDateDisplay === endDateDisplay
@@ -21,6 +17,8 @@
<span>{meta.title || meta.name}</span> <span>{meta.title || meta.name}</span>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} /> <Icon icon="clock-circle" size={4} />
{timeFmt.format(startDate)}{isSingleDay ? timeFmt.format(endDate) : formatTimestamp(end)} {formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div> </div>
</div> </div>
+31 -10
View File
@@ -1,8 +1,17 @@
<script lang="ts"> <script lang="ts">
import {session} from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.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 ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileEject from "@app/components/ProfileEject.svelte"
import {PLATFORM_NAME} from "@app/state" import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
const back = () => history.back()
const startEject = () => pushModal(ProfileEject)
</script> </script>
<div class="column gap-4"> <div class="column gap-4">
@@ -10,21 +19,33 @@
<div slot="title">What is a private key?</div> <div slot="title">What is a private key?</div>
</ModalHeader> </ModalHeader>
<p> <p>
Most software keeps track of users by giving them a username and password. This gives the Most online services keep track of users by giving them a username and password. This gives the
service service <strong>total control</strong> over their users, allowing them to ban them at any time, or
<strong>total control</strong> over their users, allowing them to ban them at any time, or sell their sell their activity.
activity.
</p> </p>
<p> <p>
On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own On <Link external href="https://nostr.com/">Nostr</Link>, <strong>you</strong> control your own
identity and social data, through the magic of crytography. The basic idea is that you have a identity and social data, through the magic of crytography. The basic idea is that you have a
<strong>public key</strong>, which acts as your user id, and a <strong>private key</strong> which <strong>public key</strong>, which acts as your user id, and a
allows you to authenticate any message you send. <strong>private key</strong> which allows you to prove your identity.
</p> </p>
{#if $session?.email}
<p> <p>
It's very important to keep private keys safe, but this can sometimes be confusing for It's very important to keep private keys safe, but this can sometimes be tricky, which is why {PLATFORM_NAME}
newcomers. This is why {PLATFORM_NAME} supports <strong>remote signer</strong> login. These services supports a traditional account-based login for new users.
can store your keys securely for you, giving you access using a username and password.
</p> </p>
<Button class="btn btn-primary" on:click={() => history.back()}>Got it</Button> <p>If you'd like to switch to self-custody, please click below to get started.</p>
<ModalFooter>
<Button class="btn btn-link" on:click={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button class="btn btn-primary" on:click={startEject}>
<Icon icon="check-circle" />
I want to hold my own keys
</Button>
</ModalFooter>
{:else}
<Button class="btn btn-primary" on:click={back}>Got it</Button>
{/if}
</div> </div>
+40 -19
View File
@@ -9,8 +9,9 @@
import SignUp from "@app/components/SignUp.svelte" import SignUp from "@app/components/SignUp.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte" import InfoNostr from "@app/components/InfoNostr.svelte"
import LogInBunker from "@app/components/LogInBunker.svelte" import LogInBunker from "@app/components/LogInBunker.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal, clearModals} from "@app/modal" import {pushModal, clearModals} from "@app/modal"
import {PLATFORM_NAME} from "@app/state" import {PLATFORM_NAME, BURROW_URL} from "@app/state"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {loadUserData} from "@app/commands" import {loadUserData} from "@app/commands"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
@@ -18,28 +19,27 @@
const signUp = () => pushModal(SignUp) const signUp = () => pushModal(SignUp)
const withLoading = const withLoading =
(cb: (...args: any[]) => any) => (s: string, cb: (...args: any[]) => any) =>
async (...args: any[]) => { async (...args: any[]) => {
loading = true loading = s
try { try {
await cb(...args) await cb(...args)
} finally { } finally {
loading = false loading = undefined
} }
} }
const onSuccess = async (session: Session, relays: string[] = []) => { const onSuccess = async (session: Session, relays: string[] = []) => {
addSession(session)
await loadUserData(session.pubkey, {relays}) await loadUserData(session.pubkey, {relays})
addSession(session)
pushToast({message: "Successfully logged in!"}) pushToast({message: "Successfully logged in!"})
setChecked("*") setChecked("*")
clearModals() clearModals()
} }
const loginWithNip07 = withLoading(async () => { const loginWithNip07 = withLoading("nip07", async () => {
const pubkey = await getNip07()?.getPublicKey() const pubkey = await getNip07()?.getPublicKey()
if (pubkey) { if (pubkey) {
@@ -52,7 +52,7 @@
} }
}) })
const loginWithSigner = withLoading(async (app: any) => { const loginWithNip55 = withLoading("nip55", async (app: any) => {
const signer = new Nip55Signer(app.packageName) const signer = new Nip55Signer(app.packageName)
const pubkey = await signer.getPubkey() const pubkey = await signer.getPubkey()
@@ -66,19 +66,18 @@
} }
}) })
const loginWithPassword = () => pushModal(LogInPassword)
const loginWithBunker = () => pushModal(LogInBunker) const loginWithBunker = () => pushModal(LogInBunker)
let loading = false
let signers: any[] = [] let signers: any[] = []
let hasNativeSigner = Boolean(getNip07()) let loading: string | undefined
$: hasSigner = getNip07() || signers.length > 0
onMount(async () => { onMount(async () => {
if (Capacitor.isNativePlatform()) { if (Capacitor.isNativePlatform()) {
signers = await getNip55() signers = await getNip55()
if (signers.length > 0) {
hasNativeSigner = true
}
} }
}) })
</script> </script>
@@ -92,7 +91,7 @@
</p> </p>
{#if getNip07()} {#if getNip07()}
<Button disabled={loading} on:click={loginWithNip07} class="btn btn-primary"> <Button disabled={loading} on:click={loginWithNip07} class="btn btn-primary">
{#if loading} {#if loading === "nip07"}
<span class="loading loading-spinner mr-3" /> <span class="loading loading-spinner mr-3" />
{:else} {:else}
<Icon icon="widget" /> <Icon icon="widget" />
@@ -101,8 +100,8 @@
</Button> </Button>
{/if} {/if}
{#each signers as app} {#each signers as app}
<Button disabled={loading} class="btn btn-primary" on:click={() => loginWithSigner(app)}> <Button disabled={loading} class="btn btn-primary" on:click={() => loginWithNip55(app)}>
{#if loading} {#if loading === "nip55"}
<span class="loading loading-spinner mr-3" /> <span class="loading loading-spinner mr-3" />
{:else} {:else}
<img src={app.iconUrl} alt={app.name} width="20" height="20" /> <img src={app.iconUrl} alt={app.name} width="20" height="20" />
@@ -110,21 +109,43 @@
Log in with {app.name} Log in with {app.name}
</Button> </Button>
{/each} {/each}
{#if BURROW_URL && !hasSigner}
<Button disabled={loading} on:click={loginWithPassword} class="btn btn-primary">
{#if loading === "password"}
<span class="loading loading-spinner mr-3" />
{:else}
<Icon icon="key" />
{/if}
Log in with Password
</Button>
{/if}
<Button <Button
disabled={loading} disabled={loading}
on:click={loginWithBunker} on:click={loginWithBunker}
class="btn {hasNativeSigner ? 'btn-neutral' : 'btn-primary'}"> class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="cpu" /> <Icon icon="cpu" />
Log in with Remote Signer Log in with Remote Signer
</Button> </Button>
{#if BURROW_URL && hasSigner}
<Button disabled={loading} on:click={loginWithPassword} class="btn">
{#if loading === "password"}
<span class="loading loading-spinner mr-3" />
{:else}
<Icon icon="key" />
{/if}
Log in with Password
</Button>
{/if}
{#if !hasSigner || !BURROW_URL}
<Link <Link
external external
disabled={loading} disabled={loading}
href="https://nostrapps.com#signers" href="https://nostrapps.com#signers"
class="btn {hasNativeSigner ? '' : 'btn-neutral'}"> class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
<Icon icon="compass" /> <Icon icon="compass" />
Browse Signer Apps Browse Signer Apps
</Link> </Link>
{/if}
<div class="text-sm"> <div class="text-sm">
Need an account? Need an account?
<Button class="link" on:click={signUp}>Register instead</Button> <Button class="link" on:click={signUp}>Register instead</Button>
+57 -35
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import {Nip46Broker} from "@welshman/signer" import {Nip46Broker, makeSecret} from "@welshman/signer"
import {nip46Perms, addSession} from "@welshman/app" import {addSession} from "@welshman/app"
import {slideAndFade} from "@lib/transition" import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -15,29 +15,24 @@
import {pushModal, clearModals} from "@app/modal" import {pushModal, clearModals} from "@app/modal"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state" import {NIP46_PERMS, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO, SIGNER_RELAYS} from "@app/state"
const back = () => history.back() const clientSecret = makeSecret()
const abortController = new AbortController() const abortController = new AbortController()
const init = Nip46Broker.initiate({ const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
perms: nip46Perms,
url: PLATFORM_URL, const back = () => history.back()
name: PLATFORM_NAME,
relays: SIGNER_RELAYS,
image: PLATFORM_LOGO,
abortController,
})
const onSubmit = async () => { const onSubmit = async () => {
const {pubkey, token, relays} = Nip46Broker.parseBunkerLink(bunker) const {signerPubkey, connectSecret, relays} = broker.parseBunkerUrl(input)
if (loading) { if (loading) {
return return
} }
if (!pubkey || relays.length === 0) { if (!signerPubkey || relays.length === 0) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.", message: "Sorry, it looks like that's an invalid bunker link.",
@@ -47,16 +42,16 @@
loading = true loading = true
try { try {
if (!(await loginWithNip46(token, {pubkey, relays}))) { const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays})
if (success) {
abortController.abort()
} else {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Something went wrong, please try again!", message: "Something went wrong, please try again!",
}) })
} }
abortController.abort()
await loadUserData(pubkey)
} finally { } finally {
loading = false loading = false
} }
@@ -64,21 +59,48 @@
clearModals() clearModals()
} }
let bunker = "" let url = ""
let input = ""
let loading = false let loading = false
init.result.then(async pubkey => { onMount(async () => {
if (pubkey) { url = await broker.makeNostrconnectUrl({
loading = true perms: NIP46_PERMS,
url: PLATFORM_URL,
addSession({ name: PLATFORM_NAME,
pubkey, image: PLATFORM_LOGO,
method: "nip46",
secret: init.clientSecret,
handler: {pubkey, relays: SIGNER_RELAYS},
}) })
await loadUserData(pubkey) let response
try {
response = await broker.waitForNostrconnect(url, abortController)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
loading = true
const userPubkey = await broker.getPublicKey()
await loadUserData(userPubkey)
addSession({
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {
pubkey: response.event.pubkey,
relays: SIGNER_RELAYS,
},
})
setChecked("*") setChecked("*")
clearModals() clearModals()
@@ -97,16 +119,16 @@
Connect your signer by scanning the QR code below or pasting a bunker link. Connect your signer by scanning the QR code below or pasting a bunker link.
</div> </div>
</ModalHeader> </ModalHeader>
{#if !loading} {#if !loading && url}
<div class="w-xs m-auto" out:slideAndFade> <div class="w-xs m-auto" out:slideAndFade>
<QRCode code={init.nostrconnect} /> <QRCode code={url} />
</div> </div>
{/if} {/if}
<Field> <Field>
<p slot="label">Bunker Link*</p> <p slot="label">Bunker Link*</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input"> <label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="cpu" /> <Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" /> <input disabled={loading} bind:value={input} class="grow" placeholder="bunker://" />
</label> </label>
<p slot="info"> <p slot="info">
A login link provided by a nostr signing app. A login link provided by a nostr signing app.
@@ -118,7 +140,7 @@
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !bunker}> <Button type="submit" class="btn btn-primary" disabled={loading || !input}>
<Spinner {loading}>Next</Spinner> <Spinner {loading}>Next</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {postJson, stripProtocol} from "@welshman/lib"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {normalizeRelayUrl} from "@welshman/util"
import {addSession} from "@welshman/app"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
import {loadUserData} from "@app/commands"
import {clearModals, pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {NIP46_PERMS, BURROW_URL, PLATFORM_URL, PLATFORM_NAME, PLATFORM_LOGO} from "@app/state"
export let email = ""
const clientSecret = makeSecret()
const startReset = () => pushModal(PasswordResetRequest, {email})
const abortController = new AbortController()
const relays = BURROW_URL.startsWith("http://")
? [normalizeRelayUrl("ws://" + stripProtocol(BURROW_URL))]
: [normalizeRelayUrl(BURROW_URL)]
const broker = Nip46Broker.get({clientSecret, relays})
const back = () => history.back()
const onSubmit = async () => {
loading = true
try {
const res = await postJson(BURROW_URL + "/session", {email, password, nostrconnect: url})
if (res.error) {
pushToast({message: res.error, theme: "error"})
loading = false
}
} catch (e) {
pushToast({message: "Something went wrong, please try again!", theme: "error"})
loading = false
}
}
let url = ""
let password = ""
let loading = false
onMount(async () => {
url = await broker.makeNostrconnectUrl({
perms: NIP46_PERMS,
url: PLATFORM_URL,
name: PLATFORM_NAME,
image: PLATFORM_LOGO,
})
let response
try {
response = await broker.waitForNostrconnect(url, abortController)
} catch (errorResponse: any) {
if (errorResponse?.error) {
pushToast({
theme: "error",
message: `Received error from signer: ${errorResponse.error}`,
})
} else if (errorResponse) {
console.error(errorResponse)
}
}
if (response) {
loading = true
const userPubkey = await broker.getPublicKey()
await loadUserData(userPubkey)
addSession({
email,
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {pubkey: response.event.pubkey, relays},
})
setChecked("*")
clearModals()
}
})
onDestroy(() => {
abortController.abort()
})
</script>
<form class="column gap-4" on:submit|preventDefault={onSubmit}>
<ModalHeader>
<div slot="title">Log In</div>
<div slot="info">Log in using your email and password</div>
</ModalHeader>
<FieldInline>
<p slot="label">Email</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="user-rounded" />
<input bind:value={email} />
</label>
</FieldInline>
<FieldInline>
<p slot="label">Password</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="key" />
<input bind:value={password} type="password" />
</label>
</FieldInline>
<p class="text-sm">
Your email and password only work to log in to {PLATFORM_NAME}. To use your key on other nostr
applications, visit your settings page. <Button class="link" on:click={startReset}
>Forgot your password?</Button>
</p>
<ModalFooter>
<Button class="btn btn-link" on:click={back} disabled={loading}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading || !email || !password}>
<Spinner {loading}>Next</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+5 -7
View File
@@ -1,30 +1,28 @@
<script lang="ts"> <script lang="ts">
import {clearStorage} from "@welshman/app"
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 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 {logout} from "@app/commands"
const back = () => history.back() const back = () => history.back()
const logout = async () => { const doLogout = async () => {
loading = true loading = true
try { try {
await clearStorage() await logout()
localStorage.clear() window.location.href = "/"
} catch (e) { } catch (e) {
loading = false loading = false
} }
window.location.reload()
} }
let loading = false let loading = false
</script> </script>
<form class="column gap-4" on:submit|preventDefault={logout}> <form class="column gap-4" on:submit|preventDefault={doLogout}>
<ModalHeader> <ModalHeader>
<div slot="title">Are you sure you want<br />to log out?</div> <div slot="title">Are you sure you want<br />to log out?</div>
</ModalHeader> </ModalHeader>
+1 -1
View File
@@ -39,7 +39,7 @@
<div slot="info">Learn about {PLATFORM_NAME} and support the developer</div> <div slot="info">Learn about {PLATFORM_NAME} and support the developer</div>
</CardButton> </CardButton>
</Link> </Link>
<Button on:click={logout} class="btn btn-error"> <Button on:click={logout} class="btn btn-neutral">
<Icon icon="exit" /> Log Out <Icon icon="exit" /> Log Out
</Button> </Button>
</div> </div>
+21 -24
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl, GROUP_META} from "@welshman/util"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
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"
@@ -15,22 +15,22 @@
import RoomCreate from "@app/components/RoomCreate.svelte" import RoomCreate from "@app/components/RoomCreate.svelte"
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte" import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
import { import {
getMembershipRoomsByUrl, userRoomsByUrl,
getMembershipUrls,
hasMembershipUrl, hasMembershipUrl,
userMembership,
memberships, memberships,
roomsByUrl, deriveUserRooms,
GENERAL, deriveOtherRooms,
} from "@app/state" } from "@app/state"
import {deriveNotification, THREAD_FILTERS} from "@app/notifications" import {notifications} from "@app/notifications"
import {pullConservatively} from "@app/requests"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
export let url export let url
const threadsPath = makeSpacePath(url, "threads") const threadsPath = makeSpacePath(url, "threads")
const threadsNotification = deriveNotification(threadsPath, THREAD_FILTERS, url) const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const openMenu = () => { const openMenu = () => {
showMenu = true showMenu = true
@@ -59,17 +59,16 @@
let replaceState = false let replaceState = false
let element: Element let element: Element
$: rooms = getMembershipRoomsByUrl(url, $userMembership)
$: otherRooms = ($roomsByUrl.get(url) || []).filter(room => !rooms.concat(GENERAL).includes(room))
$: members = $memberships.filter(l => hasMembershipUrl(l, url)).map(l => l.event.pubkey) $: members = $memberships.filter(l => hasMembershipUrl(l, url)).map(l => l.event.pubkey)
onMount(async () => { onMount(async () => {
replaceState = Boolean(element.closest(".drawer")) replaceState = Boolean(element.closest(".drawer"))
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
}) })
</script> </script>
<div bind:this={element}> <div bind:this={element}>
<SecondaryNavSection> <SecondaryNavSection class="max-h-screen">
<div> <div>
<SecondaryNavItem class="w-full !justify-between" on:click={openMenu}> <SecondaryNavItem class="w-full !justify-between" on:click={openMenu}>
<strong>{displayRelayUrl(url)}</strong> <strong>{displayRelayUrl(url)}</strong>
@@ -93,13 +92,13 @@
</Button> </Button>
</li> </li>
<li> <li>
{#if getMembershipUrls($userMembership).includes(url)} {#if $userRoomsByUrl.has(url)}
<Button on:click={leaveSpace} class="text-error"> <Button on:click={leaveSpace} class="text-error">
<Icon icon="exit" /> <Icon icon="exit" />
Leave Space Leave Space
</Button> </Button>
{:else} {:else}
<Button on:click={joinSpace}> <Button on:click={joinSpace} class="bg-primary text-primary-content">
<Icon icon="login-2" /> <Icon icon="login-2" />
Join Space Join Space
</Button> </Button>
@@ -109,37 +108,35 @@
</Popover> </Popover>
{/if} {/if}
</div> </div>
<div class="flex min-h-0 flex-col gap-1 overflow-auto">
<SecondaryNavItem href={makeSpacePath(url)}> <SecondaryNavItem href={makeSpacePath(url)}>
<Icon icon="home-smile" /> Home <Icon icon="home-smile" /> Home
</SecondaryNavItem> </SecondaryNavItem>
<SecondaryNavItem href={threadsPath} notification={$threadsNotification}> <SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
<Icon icon="notes-minimalistic" /> Threads <Icon icon="notes-minimalistic" /> Threads
</SecondaryNavItem> </SecondaryNavItem>
<div class="h-2" /> <div class="h-2" />
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader> <SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
<MenuSpaceRoomItem {url} room={GENERAL} /> {#each $userRooms as room, i (room)}
{#each rooms as room, i (room)} <MenuSpaceRoomItem notify {url} {room} />
<MenuSpaceRoomItem {url} {room} />
{/each} {/each}
{#if otherRooms.length > 0} {#if $otherRooms.length > 0}
<div class="h-2" /> <div class="h-2" />
<SecondaryNavHeader> <SecondaryNavHeader>
{#if rooms.length > 0} {#if $userRooms.length > 0}
Other Rooms Other Rooms
{:else} {:else}
Rooms Rooms
{/if} {/if}
</SecondaryNavHeader> </SecondaryNavHeader>
{/if} {/if}
{#each otherRooms as room, i (room)} {#each $otherRooms as room, i (room)}
<SecondaryNavItem href={makeSpacePath(url, room)}> <MenuSpaceRoomItem {url} {room} />
<Icon icon="hashtag" />
{room}
</SecondaryNavItem>
{/each} {/each}
<SecondaryNavItem on:click={addRoom}> <SecondaryNavItem on:click={addRoom}>
<Icon icon="add-circle" /> <Icon icon="add-circle" />
Create room Create room
</SecondaryNavItem> </SecondaryNavItem>
</div>
</SecondaryNavSection> </SecondaryNavSection>
</div> </div>
+5 -38
View File
@@ -1,54 +1,21 @@
<script lang="ts"> <script lang="ts">
import {page} from "$app/stores"
import {derived} from "svelte/store"
import {max} from "@welshman/lib"
import {matchFilter} from "@welshman/util"
import {pubkey} from "@welshman/app"
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 MenuSpace from "@app/components/MenuSpace.svelte"
import {checked, getNotification, deriveNotification, THREAD_FILTERS} from "@app/notifications" import {notifications} from "@app/notifications"
import { import {makeSpacePath} from "@app/routes"
userMembership,
getMembershipRoomsByUrl,
deriveEventsForUrl,
MESSAGE,
GENERAL,
} from "@app/state"
import {makeRoomPath, makeSpacePath} from "@app/routes"
import {pushDrawer} from "@app/modal" import {pushDrawer} from "@app/modal"
export let url export let url
const path = makeSpacePath(url)
const openMenu = () => pushDrawer(MenuSpace, {url}) const openMenu = () => pushDrawer(MenuSpace, {url})
const events = deriveEventsForUrl(url, [{kinds: [MESSAGE]}])
const threadsPath = makeSpacePath(url, "threads")
const threadsNotification = deriveNotification(threadsPath, THREAD_FILTERS, url)
const notification = derived(
[page, events, checked, userMembership],
([$page, $events, $checked, $userMembership]) =>
getMembershipRoomsByUrl(url, $userMembership)
.concat(GENERAL)
.some(room => {
const path = makeRoomPath(url, room)
if ($page.url.pathname === path) return false
const lastChecked = max([$checked["*"], $checked[path]])
const roomEvents = $events.filter(e => matchFilter({"#~": [room]}, e))
return getNotification($pubkey, lastChecked, roomEvents)
}),
)
</script> </script>
<Button on:click={openMenu} class="btn btn-neutral btn-sm relative md:hidden"> <Button on:click={openMenu} class="btn btn-neutral btn-sm relative md:hidden">
<Icon icon="menu-dots" /> <Icon icon="menu-dots" />
{#if $threadsNotification || $notification} {#if $notifications.has(path)}
<div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-primary" /> <div class="absolute right-0 top-0 -mr-1 -mt-1 h-2 w-2 rounded-full bg-primary" />
{/if} {/if}
</Button> </Button>
+15 -6
View File
@@ -1,17 +1,26 @@
<script lang="ts"> <script lang="ts">
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import {makeSpacePath} from "@app/routes" import ChannelName from "@app/components/ChannelName.svelte"
import {deriveNotification, getRoomFilters} from "@app/notifications" import {makeRoomPath} from "@app/routes"
import {deriveChannel, channelIsLocked} from "@app/state"
import {notifications} from "@app/notifications"
export let url export let url
export let room export let room
export let notify = false
const path = makeSpacePath(url, room) const path = makeRoomPath(url, room)
const notification = deriveNotification(path, getRoomFilters(room), url) const channel = deriveChannel(url, room)
</script> </script>
<SecondaryNavItem href={path} notification={$notification}> <SecondaryNavItem href={path} notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" /> <Icon icon="hashtag" />
{room} {/if}
<div class="min-w-0 overflow-hidden text-ellipsis">
<ChannelName {url} {room} />
</div>
</SecondaryNavItem> </SecondaryNavItem>
+3 -3
View File
@@ -5,7 +5,7 @@
import CardButton from "@lib/components/CardButton.svelte" import CardButton from "@lib/components/CardButton.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 {userMembership, getMembershipUrls, PLATFORM_RELAY} from "@app/state" import {userRoomsByUrl, PLATFORM_RELAY} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const addSpace = () => pushModal(SpaceAdd) const addSpace = () => pushModal(SpaceAdd)
@@ -15,8 +15,8 @@
{#if PLATFORM_RELAY} {#if PLATFORM_RELAY}
<MenuSpacesItem url={PLATFORM_RELAY} /> <MenuSpacesItem url={PLATFORM_RELAY} />
<Divider /> <Divider />
{:else if getMembershipUrls($userMembership).length > 0} {:else if $userRoomsByUrl.size > 0}
{#each getMembershipUrls($userMembership) as url (url)} {#each $userRoomsByUrl.keys() as url (url)}
<MenuSpacesItem {url} /> <MenuSpacesItem {url} />
{/each} {/each}
<Divider /> <Divider />
+2 -2
View File
@@ -5,7 +5,7 @@
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte" import RelayDescription from "@app/components/RelayDescription.svelte"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {spacesNotifications} from "@app/notifications" import {notifications} from "@app/notifications"
export let url export let url
@@ -17,7 +17,7 @@
<div slot="icon"><SpaceAvatar {url} /></div> <div slot="icon"><SpaceAvatar {url} /></div>
<div slot="title" class="flex gap-1"> <div slot="title" class="flex gap-1">
<RelayName {url} /> <RelayName {url} />
{#if $spacesNotifications.includes(url)} {#if $notifications.has(path)}
<div class="relative top-1 h-2 w-2 rounded-full bg-primary" /> <div class="relative top-1 h-2 w-2 rounded-full bg-primary" />
{/if} {/if}
</div> </div>
+11 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib" import {ctx} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util" import {getListTags, getPubkeyTagValues} from "@welshman/util"
@@ -7,9 +8,11 @@
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 {entityLink} from "@app/state" import {entityLink} from "@app/state"
export let event export let event
export let minimal = false
export let hideProfile = false export let hideProfile = false
const relays = ctx.app.router.Event(event).getUrls() const relays = ctx.app.router.Event(event).getUrls()
@@ -34,9 +37,16 @@
{:else} {:else}
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
{#if !hideProfile} {#if !hideProfile}
{#if minimal}
@<ProfileName pubkey={event.pubkey} />
{:else}
<Profile pubkey={event.pubkey} /> <Profile pubkey={event.pubkey} />
{/if} {/if}
<Link external href={entityLink(nevent)} class="text-sm opacity-75"> {/if}
<Link
external
href={entityLink(nevent)}
class={cx("text-sm opacity-75", {"text-xs": minimal})}>
{formatTimestamp(event.created_at)} {formatTimestamp(event.created_at)}
</Link> </Link>
</div> </div>
+2 -2
View File
@@ -23,13 +23,13 @@
} }
const onEmoji = (emoji: NativeEmoji) => const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode}) publishReaction({event, content: emoji.unicode, relays: [url]})
</script> </script>
<NoteCard {event} class="card2 bg-alt"> <NoteCard {event} class="card2 bg-alt">
<Content {event} expandMode="inline" /> <Content {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary relays={[url]} {event} {onReactionClick}> <ReactionSummary relays={[url]} {event} {onReactionClick} reactionClass="tooltip-right">
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box"> <EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
<Icon icon="smile-circle" size={4} /> <Icon icon="smile-circle" size={4} />
</EmojiButton> </EmojiButton>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import {postJson, sleep} from "@welshman/lib"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {BURROW_URL} from "@app/state"
export let email
export let reset_token
const onSubmit = async () => {
loading = true
try {
const [res] = await Promise.all([
postJson(BURROW_URL + "/user/confirm-reset", {email, password, reset_token}),
sleep(1000),
])
if (res.error) {
pushToast({message: res.error, theme: "error"})
} else {
pushToast({message: "Password reset successfully!"})
pushModal(LogInPassword, {email}, {path: "/"})
}
} finally {
loading = false
}
}
let loading = false
let password = ""
</script>
<form class="column gap-4" on:submit|preventDefault={onSubmit}>
<ModalHeader>
<div slot="title">Reset your password</div>
</ModalHeader>
<FieldInline disabled={loading}>
<p slot="label">Email Address</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="user-rounded" />
<input readonly value={email} class="grow" />
</label>
</FieldInline>
<FieldInline disabled={loading}>
<p slot="label">New Password</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="key" />
<input bind:value={password} class="grow" type="password" />
</label>
</FieldInline>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Reset password</Spinner>
</Button>
</form>
@@ -0,0 +1,62 @@
<script lang="ts">
import {postJson, sleep} from "@welshman/lib"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {BURROW_URL} from "@app/state"
export let email: string
const back = () => history.back()
const onSubmit = async () => {
loading = true
try {
const [res] = await Promise.all([
postJson(BURROW_URL + "/user/request-reset", {email}),
sleep(1000),
])
if (res.error) {
pushToast({message: res.error, theme: "error"})
} else {
pushToast({message: `Password reset email has been sent!`})
pushModal(LogInPassword, {email}, {path: "/"})
}
} finally {
loading = false
}
}
let loading = false
</script>
<form class="column gap-4" on:submit|preventDefault={onSubmit}>
<ModalHeader>
<div slot="title">Reset your password</div>
</ModalHeader>
<FieldInline disabled={loading}>
<p slot="label">Email Address</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="user-rounded" />
<input bind:value={email} class="grow" />
</label>
<p slot="info">You'll be sent an email with a password reset link.</p>
</FieldInline>
<ModalFooter>
<Button class="btn btn-link" on:click={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Request password reset link</Spinner>
</Button>
</ModalFooter>
</form>
+27 -16
View File
@@ -1,24 +1,35 @@
<script lang="ts"> <script lang="ts">
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {userProfile} from "@welshman/app" import {userProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import MenuSpaces from "@app/components/MenuSpaces.svelte" import MenuSpaces from "@app/components/MenuSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte" import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userMembership, getMembershipUrls, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state" import {userRoomsByUrl, canDecrypt, PLATFORM_RELAY, PLATFORM_LOGO} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {deriveNotification, inactiveSpacesNotifications, CHAT_FILTERS} from "@app/notifications" import {makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
const chatNotification = deriveNotification("/chat", CHAT_FILTERS)
const addSpace = () => pushModal(SpaceAdd) const addSpace = () => pushModal(SpaceAdd)
const showSpacesMenu = () => const showSpacesMenu = () => (spacePaths.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
getMembershipUrls($userMembership).length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)
const showSettingsMenu = () => pushModal(MenuSettings) const showSettingsMenu = () => pushModal(MenuSettings)
const openNotes = () => ($canDecrypt ? goto("/notes") : pushModal(ChatEnable, {next: "/notes"}))
const openChat = () => ($canDecrypt ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
$: spaceUrls = Array.from($userRoomsByUrl.keys())
$: spacePaths = spaceUrls.map(url => makeSpacePath(url))
$: anySpaceNotifications = spacePaths.some(
path => !$page.url.pathname.startsWith(path) && $notifications.has(path),
)
</script> </script>
<div class="relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block"> <div class="relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
@@ -31,7 +42,7 @@
<Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" /> <Avatar src={PLATFORM_LOGO} class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<Divider /> <Divider />
{#each getMembershipUrls($userMembership) as url (url)} {#each spaceUrls as url (url)}
<PrimaryNavItemSpace {url} /> <PrimaryNavItemSpace {url} />
{/each} {/each}
<PrimaryNavItem title="Add Space" on:click={addSpace} class="tooltip-right"> <PrimaryNavItem title="Add Space" on:click={addSpace} class="tooltip-right">
@@ -47,14 +58,14 @@
class="tooltip-right"> class="tooltip-right">
<Avatar src={$userProfile?.picture} class="!h-10 !w-10" /> <Avatar src={$userProfile?.picture} class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem title="Notes" href="/notes" class="tooltip-right"> <PrimaryNavItem title="Notes" on:click={openNotes} class="tooltip-right">
<Avatar icon="notes-minimalistic" class="!h-10 !w-10" /> <Avatar icon="notes-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem <PrimaryNavItem
title="Messages" title="Messages"
href="/chat" on:click={openChat}
class="tooltip-right" class="tooltip-right"
notification={$chatNotification}> notification={$notifications.has("/chat")}>
<Avatar icon="letter" class="!h-10 !w-10" /> <Avatar icon="letter" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem title="Search" href="/people" class="tooltip-right"> <PrimaryNavItem title="Search" href="/people" class="tooltip-right">
@@ -73,16 +84,16 @@
<PrimaryNavItem title="Search" href="/people"> <PrimaryNavItem title="Search" href="/people">
<Avatar icon="magnifer" class="!h-10 !w-10" /> <Avatar icon="magnifer" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem title="Notes" href="/notes"> <PrimaryNavItem title="Notes" on:click={openNotes}>
<Avatar icon="notes-minimalistic" class="!h-10 !w-10" /> <Avatar icon="notes-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem title="Messages" href="/chat" notification={$chatNotification}> <PrimaryNavItem
title="Messages"
on:click={openChat}
notification={$notifications.has("/chat")}>
<Avatar icon="letter" class="!h-10 !w-10" /> <Avatar icon="letter" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
<PrimaryNavItem <PrimaryNavItem title="Spaces" on:click={showSpacesMenu} notification={anySpaceNotifications}>
title="Spaces"
on:click={showSpacesMenu}
notification={$inactiveSpacesNotifications.length > 0}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" /> <Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem> </PrimaryNavItem>
</div> </div>
@@ -3,18 +3,17 @@
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import SpaceAvatar from "@app/components/SpaceAvatar.svelte" import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {deriveNotification, SPACE_FILTERS} from "@app/notifications" import {notifications} from "@app/notifications"
export let url export let url
const path = makeSpacePath(url) const path = makeSpacePath(url)
const notification = deriveNotification(path, SPACE_FILTERS, url)
</script> </script>
<PrimaryNavItem <PrimaryNavItem
title={displayRelayUrl(url)} title={displayRelayUrl(url)}
href={path} href={path}
class="tooltip-right" class="tooltip-right"
notification={$notification}> notification={$notifications.has(path)}>
<SpaceAvatar {url} /> <SpaceAvatar {url} />
</PrimaryNavItem> </PrimaryNavItem>
+9 -6
View File
@@ -9,10 +9,11 @@
displayHandle, displayHandle,
deriveProfileDisplay, deriveProfileDisplay,
} from "@welshman/app" } from "@welshman/app"
import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import WotScore from "@lib/components/WotScore.svelte" import WotScore from "@lib/components/WotScore.svelte"
import {pubkeyLink} from "@app/state" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
export let pubkey export let pubkey
@@ -21,19 +22,21 @@
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey) const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey})
$: following = $: following =
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey) pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey)
</script> </script>
<div class="flex max-w-full gap-3"> <div class="flex max-w-full gap-3">
<Link external href={pubkeyLink(pubkey)} class="py-1"> <Button on:click={openProfile} class="py-1">
<Avatar src={$profile?.picture} size={10} /> <Avatar src={$profile?.picture} size={10} />
</Link> </Button>
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Link external href={pubkeyLink(pubkey)} class="text-bold overflow-hidden text-ellipsis"> <Button on:click={openProfile} class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay} {$profileDisplay}
</Link> </Button>
<WotScore score={$score} active={following} /> <WotScore score={$score} active={following} />
</div> </div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75"> <div class="overflow-hidden text-ellipsis text-sm opacity-75">
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey,
displayHandle,
deriveProfileDisplay,
} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import WotScore from "@lib/components/WotScore.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import {canDecrypt, pubkeyLink} from "@app/state"
import {pushModal} from "@app/modal"
import {makeChatPath} from "@app/routes"
export let pubkey
const profile = deriveProfile(pubkey)
const profileDisplay = deriveProfileDisplay(pubkey)
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const back = () => history.back()
const chatPath = makeChatPath([pubkey])
const openChat = () => ($canDecrypt ? goto(chatPath) : pushModal(ChatEnable, {next: chatPath}))
$: following =
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey)
</script>
<div class="column gap-4">
<div class="flex max-w-full gap-3">
<span class="py-1">
<Avatar src={$profile?.picture} size={10} />
</span>
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
<span class="text-bold overflow-hidden text-ellipsis">
{$profileDisplay}
</span>
<WotScore score={$score} active={following} />
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{$handle ? displayHandle($handle) : displayPubkey(pubkey)}
</div>
</div>
</div>
<ProfileInfo {pubkey} />
<ModalFooter>
<Button on:click={back} class="btn btn-link">
<Icon icon="alt-arrow-left" />
Go back
</Button>
<div class="flex gap-2">
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
<Icon icon="user-circle" />
See Complete Profile
</Link>
<Button on:click={openChat} class="btn btn-primary">
<Icon icon="letter" />
Open Chat
</Button>
</div>
</ModalFooter>
</div>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import {postJson} from "@welshman/lib"
import {session} from "@welshman/app"
import {slideAndFade} from "@lib/transition"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Field from "@lib/components/Field.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {PLATFORM_NAME, BURROW_URL} from "@app/state"
import {pushToast} from "@app/toast"
import {logout} from "@app/commands"
const email = $session?.email
const back = () => history.back()
const confirm = async () => {
loading = true
try {
const payload = {email, password, eject: true}
const res = await postJson(BURROW_URL + "/user", payload, {method: "delete"})
if (res.error) {
return pushToast({message: res.error, theme: "error"})
}
success = true
pushToast({message: "Success! Please check your inbox and continue when you're ready."})
await logout()
} finally {
loading = false
}
}
const reload = () => {
loading = true
window.location.href = "/"
}
let password = ""
let success = false
let loading = false
</script>
<div class="column gap-4">
<ModalHeader>
<div slot="title">Export your keys</div>
</ModalHeader>
<p>Here's what the process looks like:</p>
<ul class="flex list-inside list-decimal flex-col gap-4">
<li>When you're ready, enter your account password below to continue.</li>
<li>
{PLATFORM_NAME} will send an email to "{email}" with your encrypted private key in it.
</li>
<li>
Store your "ncryptsec" in a password manager like
<Link class="link" external href="https://bitwarden.com/">Bitwarden</Link>. This is the key to
your social identity; keep it safe and secret.
</li>
<li>
Choose a <Link class="link" href="https://nostrapps.com/#signers">signer app</Link> and import
your private key into it. Don't forget your account password; you'll need it to decrypt your key.
</li>
</ul>
<p>
Once you export your key, you'll be <strong>logged out</strong> and won't be able to log in using
your email and password any more. Going forward, you'll need to use your signer app instead.
</p>
{#if !success}
<div out:slideAndFade>
<Field>
<p slot="label">To confirm, please enter your password below:</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="key" />
<input type="password" disabled={loading} bind:value={password} class="grow" />
</label>
</Field>
</div>
{/if}
<ModalFooter>
<Button class="btn btn-link" disabled={loading || success} on:click={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
{#if success}
<Button class="btn btn-primary" disabled={loading} on:click={reload}>
<Icon icon="check-circle" />
<Spinner {loading}>Refresh the page</Spinner>
</Button>
{:else}
<Button class="btn btn-error" disabled={loading} on:click={confirm}>
<Icon icon="check-circle" />
<Spinner {loading}>I understand, send me my private key</Spinner>
</Button>
{/if}
</ModalFooter>
</div>
+12 -3
View File
@@ -1,20 +1,26 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {sortBy, uniqBy} from "@welshman/lib" import {sortBy, uniqBy} from "@welshman/lib"
import {feedFromFilter} from "@welshman/feeds" import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {NOTE, getAncestorTags} from "@welshman/util" import {NOTE, getAncestorTags} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {createFeedController} from "@welshman/app" import {createFeedController} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import NoteItem from "@app/components/NoteItem.svelte" import NoteItem from "@app/components/NoteItem.svelte"
export let url export let url
export let pubkey export let pubkey
export let events: TrustedEvent[] = []
export let hideLoading = false
const ctrl = createFeedController({ const ctrl = createFeedController({
useWindowing: true, useWindowing: true,
feed: feedFromFilter({kinds: [NOTE], authors: [pubkey]}), feed: makeIntersectionFeed(
makeRelayFeed(url),
feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
),
onEvent: (event: TrustedEvent) => { onEvent: (event: TrustedEvent) => {
if (getAncestorTags(event.tags).replies.length === 0) { if (getAncestorTags(event.tags).replies.length === 0) {
buffer.push(event) buffer.push(event)
@@ -24,7 +30,6 @@
let element: Element let element: Element
let buffer: TrustedEvent[] = [] let buffer: TrustedEvent[] = []
let events: TrustedEvent[] = []
onMount(() => { onMount(() => {
const scroller = createScroller({ const scroller = createScroller({
@@ -51,10 +56,14 @@
<div class="col-4" bind:this={element}> <div class="col-4" bind:this={element}>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each events as event (event.id)} {#each events as event (event.id)}
<div in:fly>
<NoteItem {url} {event} /> <NoteItem {url} {event} />
</div>
{/each} {/each}
{#if !hideLoading}
<p class="center my-12 flex"> <p class="center my-12 flex">
<Spinner loading /> <Spinner loading />
</p> </p>
{/if}
</div> </div>
</div> </div>
+8 -7
View File
@@ -3,14 +3,14 @@
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import {append, remove, uniq} from "@welshman/lib" import {append, remove, uniq} from "@welshman/lib"
import {profileSearch} from "@welshman/app" import {profileSearch} from "@welshman/app"
import {Suggestions} from "@welshman/editor"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Suggestions from "@lib/editor/Suggestions.svelte" import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import SuggestionProfile from "@lib/editor/SuggestionProfile.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import {pubkeyLink} from "@app/state" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
export let value: string[] export let value: string[]
export let autofocus = false export let autofocus = false
@@ -48,13 +48,14 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div> <div>
{#each value as pubkey (pubkey)} {#each value as pubkey (pubkey)}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<div class="flex-inline badge badge-neutral mr-1 gap-1"> <div class="flex-inline badge badge-neutral mr-1 gap-1">
<Button class="flex items-center" on:click={() => removePubkey(pubkey)}> <Button class="flex items-center" on:click={() => removePubkey(pubkey)}>
<Icon icon="close-circle" size={4} class="-ml-1 mt-px" /> <Icon icon="close-circle" size={4} class="-ml-1 mt-px" />
</Button> </Button>
<Link external href={pubkeyLink(pubkey)}> <Button on:click={onClick}>
<ProfileName {pubkey} /> <ProfileName {pubkey} />
</Link> </Button>
</div> </div>
{/each} {/each}
</div> </div>
@@ -77,7 +78,7 @@
term, term,
select: selectPubkey, select: selectPubkey,
search: profileSearch, search: profileSearch,
component: SuggestionProfile, component: ProfileSuggestion,
class: "rounded-box", class: "rounded-box",
style: `left: 4px; width: ${input?.clientWidth + 12}px`, style: `left: 4px; width: ${input?.clientWidth + 12}px`,
}} }}
+21 -6
View File
@@ -1,18 +1,23 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {groupBy, uniqBy} from "@welshman/lib" import {groupBy, uniqBy, batch} from "@welshman/lib"
import {REACTION} from "@welshman/util" import {REACTION, DELETE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {deriveEvents} from "@welshman/store" import {deriveEvents} from "@welshman/store"
import {pubkey, repository, load, displayProfileByPubkey} from "@welshman/app" import {pubkey, repository, load, displayProfileByPubkey} from "@welshman/app"
import {displayList} from "@lib/util" import {displayList} from "@lib/util"
import {isMobile} from "@lib/html"
import {displayReaction} from "@app/state" import {displayReaction} from "@app/state"
export let event export let event
export let onReactionClick export let onReactionClick
export let relays: string[] = [] export let relays: string[] = []
export let reactionClass = ""
export let noTooltip = false
const filters = [{kinds: [REACTION], "#e": [event.id]}] const reactions = deriveEvents(repository, {
const reactions = deriveEvents(repository, {filters}) filters: [{kinds: [REACTION], "#e": [event.id]}],
})
$: groupedReactions = groupBy( $: groupedReactions = groupBy(
e => e.content, e => e.content,
@@ -20,7 +25,16 @@
) )
onMount(() => { onMount(() => {
load({relays, filters}) load({
relays,
filters: [{kinds: [REACTION, DELETE], "#e": [event.id]}],
onEvent: batch(300, (events: TrustedEvent[]) => {
load({
relays,
filters: [{kinds: [DELETE], "#e": events.map(e => e.id)}],
})
}),
})
}) })
</script> </script>
@@ -35,7 +49,8 @@
<button <button
type="button" type="button"
data-tip={tooltip} data-tip={tooltip}
class="flex-inline btn btn-neutral btn-xs tooltip gap-1 rounded-full" class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full {reactionClass}"
class:tooltip={!noTooltip && !isMobile}
class:border={isOwn} class:border={isOwn}
class:border-solid={isOwn} class:border-solid={isOwn}
class:border-primary={isOwn} class:border-primary={isOwn}
-24
View File
@@ -1,24 +0,0 @@
<script lang="ts">
import {onMount} from "svelte"
import {deriveEvents} from "@welshman/store"
import {repository, load} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import {COMMENT} from "@app/state"
export let event
export let relays: string[] = []
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const replies = deriveEvents(repository, {filters})
onMount(() => {
load({relays, filters})
})
</script>
{#if $replies.length > 0}
<button type="button" on:click class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full">
<Icon icon="reply" />
{$replies.length}
</button>
{/if}
+32 -5
View File
@@ -1,21 +1,48 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {randomId} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
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 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 {addRoomMembership} from "@app/commands" import {hasNip29} from "@app/state"
import {addRoomMembership, nip29, getThunkError} from "@app/commands"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {pushToast} from "@app/toast"
export let url export let url
const room = randomId()
const relay = deriveRelay(url)
const back = () => history.back() const back = () => history.back()
const tryCreate = async () => { const tryCreate = async () => {
addRoomMembership(url, room) if (hasNip29($relay)) {
const createMessage = await getThunkError(nip29.createRoom(url, room))
if (createMessage && !createMessage.match(/^duplicate:|already a member/)) {
return pushToast({theme: "error", message: createMessage})
}
const editMessage = await getThunkError(nip29.editMeta(url, room, {name}))
if (editMessage) {
return pushToast({theme: "error", message: editMessage})
}
const joinMessage = await getThunkError(nip29.joinRoom(url, room))
if (joinMessage && !joinMessage.includes("already")) {
return pushToast({theme: "error", message: joinMessage})
}
}
addRoomMembership(url, room, name)
goto(makeSpacePath(url, room)) goto(makeSpacePath(url, room))
} }
@@ -29,7 +56,7 @@
} }
} }
let room = "" let name = ""
let loading = false let loading = false
</script> </script>
@@ -44,7 +71,7 @@
<p slot="label">Room Name</p> <p slot="label">Room Name</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input"> <label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="hashtag" /> <Icon icon="hashtag" />
<input bind:value={room} class="grow" type="text" /> <input bind:value={name} class="grow" type="text" />
</label> </label>
</Field> </Field>
<ModalFooter> <ModalFooter>
@@ -52,7 +79,7 @@
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={!room || loading}> <Button type="submit" class="btn btn-primary" disabled={!name || loading}>
<Spinner {loading}>Create Room</Spinner> <Spinner {loading}>Create Room</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
+96 -35
View File
@@ -1,24 +1,55 @@
<script lang="ts"> <script lang="ts">
import {postJson, assoc} from "@welshman/lib"
import {makeSecret, Nip46Broker} from "@welshman/signer" import {makeSecret, Nip46Broker} from "@welshman/signer"
import {addSession, nip46Perms, loadHandle} from "@welshman/app" import {pubkey, loadHandle, updateSession} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.svelte" import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte" import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal, clearModals} from "@app/modal" import {pushModal, clearModals} from "@app/modal"
import {setChecked} from "@app/notifications" import {setChecked} from "@app/notifications"
import {PLATFORM_NAME} from "@app/state" import {BURROW_URL, PLATFORM_NAME, NIP46_PERMS} from "@app/state"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {loginWithNip46} from "@app/commands"
const relays = ["wss://relay.nsec.app"]
const signerDomain = "nsec.app"
const signerPubkey = "e24a86943d37a91ab485d6f9a7c66097c25ddd67e8bd1b75ed252a3c266cf9bb"
const login = () => pushModal(LogIn) const login = () => pushModal(LogIn)
const trySignup = async () => { const withLoading =
const secret = makeSecret() (cb: (...args: any[]) => any) =>
const handle = await loadHandle(`${username}@${handler.domain}`) async (...args: any[]) => {
loading = true
try {
await cb(...args)
} finally {
loading = false
}
}
const signupPassword = withLoading(async () => {
const res = await postJson(BURROW_URL + "/user", {email, password})
if (res.error) {
return pushToast({message: res.error, theme: "error"})
}
pushModal(SignUpSuccess, {email}, {replaceState: true})
})
const signupNsecApp = withLoading(async () => {
const handle = await loadHandle(`${username}@${signerDomain}`)
if (handle?.pubkey) { if (handle?.pubkey) {
return pushToast({ return pushToast({
@@ -27,48 +58,49 @@
}) })
} }
const signupBroker = Nip46Broker.get({secret, handler}) const clientSecret = makeSecret()
const pubkey = await signupBroker.createAccount(username, nip46Perms) const broker = Nip46Broker.get({
relays,
clientSecret,
signerPubkey,
algorithm: "nip04",
})
if (!pubkey) { const userPubkey = await broker.createAccount(username, signerDomain, NIP46_PERMS)
if (!userPubkey) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Sorry, it looks like something went wrong. Please try again.", message: "Sorry, it looks like something went wrong. Please try again.",
}) })
} }
// Gotta use user pubkey as the handler pubkey for historical reasons // Now we can log in. Use the user's pubkey for the handler (legacy stuff)
const loginBroker = Nip46Broker.get({secret, handler: {...handler, pubkey}}) const success = await loginWithNip46({relays, signerPubkey: userPubkey, clientSecret})
if (await loginBroker.connect("", nip46Perms)) { if (!success) {
addSession({method: "nip46", pubkey, secret, handler: {...handler, pubkey}}) return pushToast({
theme: "error",
message: "Sorry, it looks like something went wrong. Please try again.",
})
}
updateSession($pubkey!, assoc("email", email))
pushToast({message: "Successfully logged in!"}) pushToast({message: "Successfully logged in!"})
setChecked("*") setChecked("*")
clearModals() clearModals()
} else {
pushToast({
theme: "error",
message: "Something went wrong! Please try again.",
}) })
const signup = () => {
if (BURROW_URL) {
signupPassword()
} else {
signupNsecApp()
} }
} }
const signup = async () => { let email = ""
loading = true let password = ""
try {
await trySignup()
} finally {
loading = false
}
}
const handler = {
domain: "nsec.app",
relays: ["wss://relay.nsec.app"],
pubkey: "e24a86943d37a91ab485d6f9a7c66097c25ddd67e8bd1b75ed252a3c266cf9bb",
}
let username = "" let username = ""
let loading = false let loading = false
</script> </script>
@@ -80,21 +112,50 @@
<Button class="link" on:click={() => pushModal(InfoNostr)}>nostr protocol</Button>, which allows <Button class="link" on:click={() => pushModal(InfoNostr)}>nostr protocol</Button>, which allows
you to own your social identity. you to own your social identity.
</p> </p>
{#if BURROW_URL}
<FieldInline>
<p slot="label">Email</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="user-rounded" />
<input bind:value={email} />
</label>
</FieldInline>
<FieldInline>
<p slot="label">Password</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="key" />
<input bind:value={password} type="password" />
</label>
</FieldInline>
<Button type="submit" class="btn btn-primary" disabled={loading || !email || !password}>
<Spinner {loading}>Sign Up</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
<p class="text-sm opacity-75">
Note that your email and password will only work to log in to {PLATFORM_NAME}. To use your key
on other nostr applications, you can create a nostr key yourself, or export your key from {PLATFORM_NAME}
later.
</p>
{:else}
<Field> <Field>
<div class="flex items-center gap-2" slot="input"> <div class="flex items-center gap-2" slot="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">
<Icon icon="user-rounded" /> <Icon icon="user-rounded" />
<input bind:value={username} class="grow" type="text" placeholder="username" /> <input bind:value={username} class="grow" type="text" placeholder="username" />
</label> </label>
@{handler.domain} @{signerDomain}
</div> </div>
</Field> </Field>
<Button type="submit" class="btn btn-primary" disabled={!username || loading}> <Button type="submit" class="btn btn-primary" disabled={loading || !username}>
<Spinner {loading}>Sign Up</Spinner> <Spinner {loading}>Sign Up</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>
{/if}
<Divider>Or</Divider> <Divider>Or</Divider>
<Link external href="https://nosta.me" class="btn {username ? 'btn-neutral' : 'btn-primary'}"> <Link
external
href="https://nosta.me"
class="btn {username || email || password ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="square-share-line" /> <Icon icon="square-share-line" />
Get started on Nosta.me Get started on Nosta.me
</Link> </Link>
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
import {pushModal} from "@app/modal"
export let email
const login = () => pushModal(LogInPassword)
</script>
<div class="column gap-4">
<h1 class="heading">Success!</h1>
<p class="m-auto max-w-sm text-center">
A confirmation email has been sent to {email}.
</p>
<p>Once you've confirmed your account you'll be redirected to the login page.</p>
<Button class="btn btn-primary" on:click={login}>Back to Login</Button>
</div>
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
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 {pushToast} from "@app/toast"
import {clearModals} from "@app/modal"
import {attemptRelayAccess} from "@app/commands"
export let url
export let error
const back = () => history.back()
const joinRelay = async (claim: string) => {
const error = await attemptRelayAccess(url, claim)
if (error) {
return pushToast({theme: "error", message: error})
}
pushToast({
message: "You have successfully joined the space!",
})
clearModals()
}
const join = async () => {
loading = true
try {
await joinRelay(claim)
} finally {
loading = false
}
}
let claim = ""
let loading = false
</script>
<form class="column gap-4" on:submit|preventDefault={join}>
<ModalHeader>
<div slot="title">Access Error</div>
<div slot="info">We couldn't connect you to this space.</div>
</ModalHeader>
<p>
We received an error from the relay indicating you don't have access to {displayRelayUrl(url)}.
</p>
<p class="border-l border-solid border-error pl-4 text-error">
{error}
</p>
<p>If you have one, you can try entering an invite code below to request access.</p>
<Field>
<p slot="label">Invite code</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="link-round" />
<input bind:value={claim} class="grow" type="text" />
</label>
<p slot="info">Enter an invite code provided to you by the admin of the relay.</p>
</Field>
<ModalFooter>
<Button class="btn btn-link" on:click={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={!claim || loading}>
<Spinner {loading}>Request Access</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</ModalFooter>
</form>
+1 -1
View File
@@ -62,7 +62,7 @@
let url = "" let url = ""
let loading = false let loading = false
$: linkIsValid = Boolean(tryCatch(() => isRelayUrl(normalizeRelayUrl(url)))) $: linkIsValid = Boolean(tryCatch(() => isRelayUrl(normalizeRelayUrl(url.split("|")[0]))))
</script> </script>
<form class="column gap-4" on:submit|preventDefault={join}> <form class="column gap-4" on:submit|preventDefault={join}>
+5 -6
View File
@@ -5,6 +5,7 @@
import {max} from "@welshman/lib" import {max} from "@welshman/lib"
import {deriveEvents, deriveIsDeleted} from "@welshman/store" import {deriveEvents, deriveIsDeleted} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util"
import {thunks, load, pubkey, repository, formatTimestampRelative} from "@welshman/app" import {thunks, load, pubkey, repository, formatTimestampRelative} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
@@ -14,9 +15,8 @@
import ThunkStatus from "@app/components/ThunkStatus.svelte" import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ThreadMenu from "@app/components/ThreadMenu.svelte" import ThreadMenu from "@app/components/ThreadMenu.svelte"
import {publishDelete, publishReaction} from "@app/commands" import {publishDelete, publishReaction} from "@app/commands"
import {deriveNotification} from "@app/notifications" import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {COMMENT} from "@app/state"
export let url export let url
export let event export let event
@@ -27,7 +27,6 @@
const path = makeSpacePath(url, "threads", event.id) const path = makeSpacePath(url, "threads", event.id)
const filters = [{kinds: [COMMENT], "#E": [event.id]}] const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const replies = deriveEvents(repository, {filters}) const replies = deriveEvents(repository, {filters})
const notification = deriveNotification(path, filters, url)
const showPopover = () => popover.show() const showPopover = () => popover.show()
@@ -44,7 +43,7 @@
} }
const onEmoji = (emoji: NativeEmoji) => const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode}) publishReaction({event, content: emoji.unicode, relays: [url]})
let popover: Instance let popover: Instance
@@ -56,8 +55,8 @@
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<ReactionSummary relays={[url]} {event} {onReactionClick} />
<div class="flex flex-grow flex-wrap justify-end gap-2"> <div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary relays={[url]} {event} {onReactionClick} reactionClass="tooltip-left" />
{#if $deleted} {#if $deleted}
<div class="btn btn-error btn-xs rounded-full">Deleted</div> <div class="btn btn-error btn-xs rounded-full">Deleted</div>
{:else if thunk} {:else if thunk}
@@ -69,7 +68,7 @@
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span> <span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div> </div>
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex"> <div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
{#if $notification} {#if $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary" /> <div class="h-2 w-2 rounded-full bg-primary" />
{/if} {/if}
Active {formatTimestampRelative(lastActive)} Active {formatTimestampRelative(lastActive)}
+19 -17
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Readable} from "svelte/store" import {writable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap" import {createEvent, THREAD} from "@welshman/util"
import {createEvent} from "@welshman/util"
import {publishThunk} from "@welshman/app" import {publishThunk} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -11,16 +10,17 @@
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 {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {THREAD, GENERAL, tagRoom} from "@app/state" import {GENERAL, tagRoom, PROTECTED} from "@app/state"
import {getPubkeyHints} from "@app/commands" import {getEditor} from "@app/editor"
import {getEditorOptions, getEditorTags} from "@lib/editor"
export let url export let url
const uploading = writable(false)
const back = () => history.back() const back = () => history.back()
const submit = () => { const submit = () => {
if ($loading) return if ($uploading) return
if (!title) { if (!title) {
return pushToast({ return pushToast({
@@ -29,7 +29,7 @@
}) })
} }
const content = $editor.getText({blockSeparator: "\n"}) const content = $editor.getText({blockSeparator: "\n"}).trim()
if (!content.trim()) { if (!content.trim()) {
return pushToast({ return pushToast({
@@ -38,7 +38,12 @@
}) })
} }
const tags = [["title", title], tagRoom(GENERAL, url), ...getEditorTags($editor)] const tags = [
...$editor.storage.nostr.getEditorTags(),
tagRoom(GENERAL, url),
["title", title],
PROTECTED,
]
publishThunk({ publishThunk({
relays: [url], relays: [url],
@@ -49,14 +54,11 @@
} }
let title: string let title: string
let editor: Readable<Editor> let element: HTMLElement
let editor: ReturnType<typeof getEditor>
$: loading = $editor?.storage.fileUpload.loading
onMount(() => { onMount(() => {
editor = createEditor( editor = getEditor({submit, element, uploading, placeholder: "What's on your mind?"})
getEditorOptions({submit, getPubkeyHints, placeholder: "What's on your mind?"}),
)
}) })
</script> </script>
@@ -81,14 +83,14 @@
<Field> <Field>
<p slot="label">Message*</p> <p slot="label">Message*</p>
<div slot="input" class="note-editor flex-grow overflow-hidden"> <div slot="input" class="note-editor flex-grow overflow-hidden">
<EditorContent editor={$editor} /> <div bind:this={element} />
</div> </div>
</Field> </Field>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
on:click={$editor.commands.selectFiles}> on:click={$editor.commands.selectFiles}>
{#if $loading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
<Icon icon="paperclip" size={3} /> <Icon icon="paperclip" size={3} />
+14 -5
View File
@@ -4,31 +4,40 @@
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte" import ThreadActions from "@app/components/ThreadActions.svelte"
import {makeThreadPath} from "@app/routes" import {makeThreadPath} from "@app/routes"
import {pubkeyLink} from "@app/state" import {pushModal} from "@app/modal"
export let url export let url
export let event export let event
export let hideActions = false export let hideActions = false
const title = event.tags.find(nthEq(0, "title"))?.[1] const title = event.tags.find(nthEq(0, "title"))?.[1]
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
</script> </script>
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeThreadPath(url, event.id)}> <Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeThreadPath(url, event.id)}>
{#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">{title}</p> <p class="text-xl">{title}</p>
<p class="text-sm opacity-75"> <p class="text-sm opacity-75">
{formatTimestamp(event.created_at)} {formatTimestamp(event.created_at)}
</p> </p>
</div> </div>
<Content {event} expandMode="inline" /> {:else}
<div class="flex w-full items-end justify-between gap-2"> <p class="mb-3 h-0 text-xs opacity-75">
{formatTimestamp(event.created_at)}
</p>
{/if}
<Content {event} expandMode="inline" quoteProps={{relays: [url]}} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75"> <span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by Posted by
<Link external href={pubkeyLink(event.pubkey)} class="link-content"> <button type="button" on:click|preventDefault={openProfile} class="link-content">
@<ProfileName pubkey={event.pubkey} /> @<ProfileName pubkey={event.pubkey} />
</Link> </button>
</span> </span>
{#if !hideActions} {#if !hideActions}
<ThreadActions showActivity {url} {event} /> <ThreadActions showActivity {url} {event} />
+1 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {COMMENT} from "@welshman/util"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
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"
@@ -6,7 +7,6 @@
import ThreadShare from "@app/components/ThreadShare.svelte" import ThreadShare from "@app/components/ThreadShare.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte" import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {COMMENT} from "@app/state"
export let url export let url
export let event export let event
+16 -17
View File
@@ -1,16 +1,14 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Readable} from "svelte/store" import {writable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {append} from "@welshman/lib"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import {fly, slideAndFade} from "@lib/transition" import {fly, slideAndFade} from "@lib/transition"
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 ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {getEditorOptions, getEditorTags} from "@lib/editor" import {publishComment} from "@app/commands"
import {getPubkeyHints, publishComment} from "@app/commands" import {tagRoom, GENERAL, PROTECTED} from "@app/state"
import {tagRoom, GENERAL} from "@app/state" import {getEditor} from "@app/editor"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
export let url export let url
@@ -18,13 +16,15 @@
export let onClose export let onClose
export let onSubmit export let onSubmit
const uploading = writable(false)
const submit = () => { const submit = () => {
if ($loading) return if ($uploading) return
const content = $editor.getText({blockSeparator: "\n"}) const content = $editor.getText({blockSeparator: "\n"}).trim()
const tags = append(tagRoom(GENERAL, url), getEditorTags($editor)) const tags = [...$editor.storage.nostr.getEditorTags(), tagRoom(GENERAL, url), PROTECTED]
if (!content.trim()) { if (!content) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Please provide a message for your reply.", message: "Please provide a message for your reply.",
@@ -34,12 +34,11 @@
onSubmit(publishComment({event, content, tags, relays: [url]})) onSubmit(publishComment({event, content, tags, relays: [url]}))
} }
let editor: Readable<Editor> let editor: ReturnType<typeof getEditor>
let element: HTMLElement
$: loading = $editor?.storage.fileUpload.loading
onMount(() => { onMount(() => {
editor = createEditor(getEditorOptions({submit, getPubkeyHints, autofocus: !isMobile})) editor = getEditor({element, submit, uploading, autofocus: !isMobile})
}) })
</script> </script>
@@ -47,16 +46,16 @@
in:fly in:fly
out:slideAndFade out:slideAndFade
on:submit|preventDefault={submit} on:submit|preventDefault={submit}
class="card2 sticky bottom-2 z-feature mx-2 mt-2 bg-neutral"> class="card2 sticky bottom-2 z-feature mx-2 mt-4 bg-neutral">
<div class="relative"> <div class="relative">
<div class="note-editor flex-grow overflow-hidden"> <div class="note-editor flex-grow overflow-hidden">
<EditorContent editor={$editor} /> <div bind:this={element} />
</div> </div>
<Button <Button
data-tip="Add an image" data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2" class="tooltip tooltip-left absolute bottom-1 right-2"
on:click={$editor.commands.selectFiles}> on:click={$editor.commands.selectFiles}>
{#if $loading} {#if $uploading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
{:else} {:else}
<Icon icon="paperclip" size={3} /> <Icon icon="paperclip" size={3} />
+7 -6
View File
@@ -7,7 +7,8 @@
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 ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {roomsByUrl} from "@app/state" import ChannelName from "@app/components/ChannelName.svelte"
import {channelsByUrl} from "@app/state"
import {makeRoomPath} from "@app/routes" import {makeRoomPath} from "@app/routes"
import {setKey} from "@app/implicit" import {setKey} from "@app/implicit"
@@ -37,14 +38,14 @@
<div slot="info">Which room would you like to share this thread to?</div> <div slot="info">Which room would you like to share this thread to?</div>
</ModalHeader> </ModalHeader>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
{#each $roomsByUrl.get(url) || [] as room (room)} {#each $channelsByUrl.get(url) || [] as channel (channel.room)}
<button <button
type="button" type="button"
class="btn" class="btn"
class:btn-neutral={selection !== room} class:btn-neutral={selection !== channel.room}
class:btn-primary={selection === room} class:btn-primary={selection === channel.room}
on:click={() => toggleRoom(room)}> on:click={() => toggleRoom(channel.room)}>
#{room} #<ChannelName {...channel} />
</button> </button>
{/each} {/each}
</div> </div>
+7 -5
View File
@@ -30,9 +30,11 @@
$: isFailure = !canCancel && ps.every(s => [Failure, Timeout].includes(s.status)) $: isFailure = !canCancel && ps.every(s => [Failure, Timeout].includes(s.status))
$: failure = Object.entries($status).find(([url, s]) => [Failure, Timeout].includes(s.status)) $: failure = Object.entries($status).find(([url, s]) => [Failure, Timeout].includes(s.status))
$: {
// Delay updating isPending so users can see that the message is sent // Delay updating isPending so users can see that the message is sent
if (!ps.some(s => s.status == Pending)) { $: {
isPending = isPending || ps.some(s => s.status === Pending)
if (!ps.some(s => s.status === Pending)) {
setTimeout(() => { setTimeout(() => {
isPending = false isPending = false
}, 2000) }, 2000)
@@ -44,13 +46,13 @@
{#if isFailure && failure} {#if isFailure && failure}
{@const [url, {message, status}] = failure} {@const [url, {message, status}] = failure}
<Tippy <Tippy
class={$$props.class} class="flex items-center {$$props.class}"
component={ThunkStatusDetail} component={ThunkStatusDetail}
props={{url, message, status, retry}} props={{url, message, status, retry}}
params={{interactive: true}}> params={{interactive: true}}>
<span class="tooltip flex cursor-pointer items-center gap-1"> <span class="flex cursor-pointer items-center gap-1 text-error">
<Icon icon="danger" size={3} /> <Icon icon="danger" size={3} />
<span class="opacity-50">Failed to send!</span> <span>Failed to send!</span>
</span> </span>
</Tippy> </Tippy>
{:else if canCancel || isPending} {:else if canCancel || isPending}
+1 -1
View File
@@ -19,7 +19,7 @@
} }
</script> </script>
<div class="card2 bg-alt col-2"> <div class="card2 bg-alt col-2 shadow-2xl">
<p> <p>
Failed to publish to {displayRelayUrl(url)}: {message}. Failed to publish to {displayRelayUrl(url)}: {message}.
</p> </p>
+1 -1
View File
@@ -11,7 +11,7 @@
{#key $toast.id} {#key $toast.id}
<div <div
role="alert" role="alert"
class="alert flex justify-center" class="alert flex justify-center whitespace-normal text-left"
class:bg-base-100={theme === "info"} class:bg-base-100={theme === "info"}
class:text-base-content={theme === "info"} class:text-base-content={theme === "info"}
class:alert-error={theme === "error"}> class:alert-error={theme === "error"}>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import type {NodeViewProps} from "@tiptap/core"
import {NodeViewWrapper} from "svelte-tiptap"
import {deriveProfileDisplay} from "@welshman/app"
export let node: NodeViewProps["node"]
export let selected: NodeViewProps["selected"]
const display = deriveProfileDisplay(node.attrs.pubkey)
</script>
<NodeViewWrapper as="span">
<button class="tiptap-object {selected ? 'tiptap-active' : ''}">
@{$display}
</button>
</NodeViewWrapper>
@@ -3,18 +3,16 @@
import { import {
userFollows, userFollows,
deriveUserWotScore, deriveUserWotScore,
deriveProfile,
deriveHandleForPubkey, deriveHandleForPubkey,
displayHandle, displayHandle,
deriveProfileDisplay, deriveProfileDisplay,
} from "@welshman/app" } from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte"
import WotScore from "@lib/components/WotScore.svelte" import WotScore from "@lib/components/WotScore.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
export let value export let value
const pubkey = value const pubkey = value
const profile = deriveProfile(pubkey)
const profileDisplay = deriveProfileDisplay(pubkey) const profileDisplay = deriveProfileDisplay(pubkey)
const handle = deriveHandleForPubkey(pubkey) const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey) const score = deriveUserWotScore(pubkey)
@@ -24,11 +22,11 @@
<div class="flex max-w-full gap-3"> <div class="flex max-w-full gap-3">
<div class="py-1"> <div class="py-1">
<Avatar src={$profile?.picture} size={10} /> <ProfileCircle {pubkey} />
</div> </div>
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="text-bold overflow-hidden text-ellipsis"> <div class="text-bold overflow-hidden text-ellipsis text-base">
{$profileDisplay} {$profileDisplay}
</div> </div>
<WotScore score={$score} active={following} /> <WotScore score={$score} active={following} />
+102
View File
@@ -0,0 +1,102 @@
import type {Writable} from "svelte/store"
import {derived} from "svelte/store"
import {createEditor, SvelteNodeViewRenderer} from "svelte-tiptap"
import {ctx} from "@welshman/lib"
import type {StampedEvent} from "@welshman/util"
import {signer, profileSearch} from "@welshman/app"
import {MentionSuggestion, WelshmanExtension} from "@welshman/editor"
import {getSetting, userSettingValues} from "@app/state"
import ProfileSuggestion from "./ProfileSuggestion.svelte"
import EditMention from "./EditMention.svelte"
export const getUploadType = () => getSetting<"nip96" | "blossom">("upload_type")
export const getUploadUrl = () => {
const {upload_type, nip96_urls, blossom_urls} = userSettingValues.get()
return upload_type === "nip96"
? nip96_urls[0] || "https://nostr.build"
: blossom_urls[0] || "https://cdn.satellite.earth"
}
export const signWithAssert = async (template: StampedEvent) => {
const event = await signer.get().sign(template)
return event!
}
export const getEditor = ({
aggressive = false,
autofocus = false,
charCount,
content = "",
element,
placeholder = "",
submit,
uploading,
wordCount,
}: {
aggressive?: boolean
autofocus?: boolean
charCount?: Writable<number>
content?: string
element: HTMLElement
placeholder?: string
submit: () => void
uploading?: Writable<boolean>
wordCount?: Writable<number>
}) =>
createEditor({
element,
content,
autofocus,
extensions: [
WelshmanExtension.configure({
submit,
sign: signWithAssert,
defaultUploadType: getUploadType(),
defaultUploadUrl: getUploadUrl(),
extensions: {
placeholder: {
config: {
placeholder,
},
},
breakOrSubmit: {
config: {
aggressive,
},
},
fileUpload: {
config: {
onDrop() {
uploading?.set(true)
},
onComplete() {
uploading?.set(false)
},
},
},
nprofile: {
extend: {
addNodeView: () => SvelteNodeViewRenderer(EditMention),
addProseMirrorPlugins() {
return [
MentionSuggestion({
editor: (this as any).editor,
search: derived(profileSearch, s => s.searchValues),
getRelays: (pubkey: string) => ctx.app.router.FromPubkeys([pubkey]).getUrls(),
component: ProfileSuggestion,
}),
]
},
},
},
},
}),
],
onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars)
},
})
+3 -1
View File
@@ -7,6 +7,7 @@ export type ModalOptions = {
drawer?: boolean drawer?: boolean
fullscreen?: boolean fullscreen?: boolean
replaceState?: boolean replaceState?: boolean
path?: string
} }
export type Modal = { export type Modal = {
@@ -26,10 +27,11 @@ export const pushModal = (
options: ModalOptions = {}, options: ModalOptions = {},
) => { ) => {
const id = randomId() const id = randomId()
const path = options.path || ""
modals.update(assoc(id, {id, component, props, options})) modals.update(assoc(id, {id, component, props, options}))
goto("#" + id, {replaceState: options.replaceState}) goto(path + "#" + id, {replaceState: options.replaceState})
return id return id
} }
+71 -67
View File
@@ -1,85 +1,89 @@
import {writable, derived} from "svelte/store" import {derived} from "svelte/store"
import {page} from "$app/stores" import {synced, throttled} from "@welshman/store"
import {deriveEvents} from "@welshman/store" import {pubkey} from "@welshman/app"
import {repository, pubkey} from "@welshman/app" import {prop, identity, now} from "@welshman/lib"
import {prop, max, sortBy, assoc, lt, now} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util" import {MESSAGE} from "@welshman/util"
import {DIRECT_MESSAGE} from "@welshman/util" import {makeSpacePath, makeChatPath, makeThreadPath, makeRoomPath} from "@app/routes"
import {makeSpacePath} from "@app/routes"
import { import {
MESSAGE, THREAD_FILTER,
THREAD, COMMENT_FILTER,
COMMENT, chats,
deriveEventsForUrl, getUrlsForEvent,
getMembershipUrls, userRoomsByUrl,
userMembership, repositoryStore,
} from "@app/state" } from "@app/state"
// Checked state // Checked state
export const checked = writable<Record<string, number>>({}) export const checked = synced<Record<string, number>>("checked", {})
export const deriveChecked = (key: string) => derived(checked, prop(key)) export const deriveChecked = (key: string) => derived(checked, prop(key))
export const setChecked = (key: string, ts = now()) => export const setChecked = (key: string) => checked.update(state => ({...state, [key]: now()}))
checked.update(state => ({...state, [key]: ts}))
// Filters for various routes // Derived notifications state
export const CHAT_FILTERS: Filter[] = [{kinds: [DIRECT_MESSAGE]}] export const notifications = derived(
throttled(
export const SPACE_FILTERS: Filter[] = [{kinds: [THREAD, MESSAGE, COMMENT]}] 1000,
derived([pubkey, checked, chats, userRoomsByUrl, repositoryStore, getUrlsForEvent], identity),
export const ROOM_FILTERS: Filter[] = [{kinds: [MESSAGE]}] ),
([$pubkey, $checked, $chats, $userRoomsByUrl, $repository, $getUrlsForEvent]) => {
export const THREAD_FILTERS: Filter[] = [ const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => {
{kinds: [THREAD]}, if (!latestEvent || latestEvent.pubkey === $pubkey) {
{kinds: [COMMENT], "#K": [String(THREAD)]}, return false
]
export const getNotificationFilters = (since: number): Filter[] =>
[...CHAT_FILTERS, ...SPACE_FILTERS, ...THREAD_FILTERS].map(assoc("since", since))
export const getRoomFilters = (room: string): Filter[] => ROOM_FILTERS.map(assoc("#~", [room]))
// Notification derivation
export const getNotification = (
pubkey: string | null,
lastChecked: number,
events: TrustedEvent[],
) => {
const [latestEvent] = sortBy($e => -$e.created_at, events)
return latestEvent?.pubkey !== pubkey && lt(lastChecked, latestEvent?.created_at)
} }
export const deriveNotification = (path: string, filters: Filter[], url?: string) => { for (const [entryPath, ts] of Object.entries($checked)) {
const events = url ? deriveEventsForUrl(url, filters) : deriveEvents(repository, {filters}) const isMatch = entryPath === "*" || entryPath.startsWith(path)
return derived( if (isMatch && ts > latestEvent.created_at) {
[pubkey, deriveChecked("*"), deriveChecked(path), events], return false
([$pubkey, $allChecked, $checked, $events]) => { }
return getNotification($pubkey, max([$allChecked, $checked]), $events)
},
)
} }
export const spacesNotifications = derived( return true
[pubkey, checked, userMembership, deriveEvents(repository, {filters: SPACE_FILTERS})], }
([$pubkey, $checked, $userMembership, $events]) => {
return getMembershipUrls($userMembership).filter(url => {
const path = makeSpacePath(url)
const lastChecked = max([$checked["*"], $checked[path]])
const [latestEvent] = sortBy($e => -$e.created_at, $events)
return latestEvent?.pubkey !== $pubkey && lt(lastChecked, latestEvent?.created_at) const paths = new Set<string>()
})
for (const {pubkeys, messages} of $chats) {
const chatPath = makeChatPath(pubkeys)
if (hasNotification(chatPath, messages[0])) {
paths.add("/chat")
paths.add(chatPath)
}
}
const allThreadEvents = $repository.query([THREAD_FILTER, COMMENT_FILTER])
const allMessageEvents = $repository.query([{kinds: [MESSAGE]}])
for (const [url, rooms] of $userRoomsByUrl.entries()) {
const spacePath = makeSpacePath(url)
const threadPath = makeThreadPath(url)
const latestEvent = allThreadEvents.find(e => $getUrlsForEvent(e.id).includes(url))
if (hasNotification(threadPath, latestEvent)) {
paths.add(spacePath)
paths.add(threadPath)
}
for (const room of rooms) {
const roomPath = makeRoomPath(url, room)
const latestEvent = allMessageEvents.find(
e =>
$getUrlsForEvent(e.id).includes(url) && e.tags.find(t => t[0] === "h" && t[1] === room),
)
if (hasNotification(roomPath, latestEvent)) {
paths.add(spacePath)
paths.add(roomPath)
}
}
}
return paths
}, },
) )
export const inactiveSpacesNotifications = derived(
[page, spacesNotifications],
([$page, $spacesNotifications]) =>
$spacesNotifications.filter(url => !$page.url.pathname.startsWith(makeSpacePath(url))),
)
+64
View File
@@ -0,0 +1,64 @@
import {get} from "svelte/store"
import {partition, assoc, now} from "@welshman/lib"
import {MESSAGE, THREAD, COMMENT} from "@welshman/util"
import type {Subscription} from "@welshman/net"
import type {AppSyncOpts} from "@welshman/app"
import {subscribe, load, repository, pull, hasNegentropy} from "@welshman/app"
import {userRoomsByUrl, getUrlsForEvent} from "@app/state"
// Utils
export const pullConservatively = ({relays, filters}: AppSyncOpts) => {
const $getUrlsForEvent = get(getUrlsForEvent)
const [smart, dumb] = partition(hasNegentropy, relays)
const promises = [pull({relays: smart, filters})]
const allEvents = repository.query(filters, {shouldSort: false})
// Since pulling from relays without negentropy is expensive, limit how many
// duplicates we repeatedly download
for (const url of dumb) {
const events = allEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
if (events.length > 100) {
filters = filters.map(assoc("since", events[10]!.created_at))
}
promises.push(pull({relays: [url], filters}))
}
return Promise.all(promises)
}
// Application requests
export const listenForNotifications = () => {
const subs: Subscription[] = []
for (const [url, rooms] of userRoomsByUrl.get()) {
load({
relays: [url],
filters: [
{kinds: [THREAD], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
...Array.from(rooms).map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
],
})
subs.push(
subscribe({
relays: [url],
filters: [
{kinds: [THREAD], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
{kinds: [MESSAGE], "#h": Array.from(rooms), since: now()},
],
}),
)
}
return () => {
for (const sub of subs) {
sub.close()
}
}
}
+11 -4
View File
@@ -1,5 +1,5 @@
import type {Page} from "@sveltejs/kit" import type {Page} from "@sveltejs/kit"
import {userMembership, makeChatId, decodeRelay, encodeRelay, getMembershipUrls} from "@app/state" import {makeChatId, decodeRelay, encodeRelay, userRoomsByUrl} from "@app/state"
export const makeSpacePath = (url: string, ...extra: string[]) => { export const makeSpacePath = (url: string, ...extra: string[]) => {
let path = `/spaces/${encodeRelay(url)}` let path = `/spaces/${encodeRelay(url)}`
@@ -15,13 +15,20 @@ export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}
export const makeRoomPath = (url: string, room: string) => `/spaces/${encodeRelay(url)}/${room}` export const makeRoomPath = (url: string, room: string) => `/spaces/${encodeRelay(url)}/${room}`
export const makeThreadPath = (url: string, eventId: string) => export const makeThreadPath = (url: string, eventId?: string) => {
`/spaces/${encodeRelay(url)}/threads/${eventId}` let path = `/spaces/${encodeRelay(url)}/threads`
if (eventId) {
path += "/" + eventId
}
return path
}
export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1] export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
export const getPrimaryNavItemIndex = ($page: Page) => { export const getPrimaryNavItemIndex = ($page: Page) => {
const urls = getMembershipUrls(userMembership.get()) const urls = Array.from(userRoomsByUrl.get().keys())
switch (getPrimaryNavItem($page)) { switch (getPrimaryNavItem($page)) {
case "discover": case "discover":
+272 -167
View File
@@ -1,28 +1,36 @@
import twColors from "tailwindcss/colors" import twColors from "tailwindcss/colors"
import {get, derived} from "svelte/store" import {get, derived} from "svelte/store"
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import type {Maybe} from "@welshman/lib"
import { import {
ctx, ctx,
setContext, setContext,
remove, remove,
assoc,
sortBy, sortBy,
sort, sort,
uniq, uniq,
partition,
nth, nth,
pushToMapKey, pushToMapKey,
nthEq, nthEq,
shuffle, shuffle,
parseJson, parseJson,
fromPairs,
memoize,
addToMapKey,
} from "@welshman/lib" } from "@welshman/lib"
import { import {
getIdFilters, getIdFilters,
WRAP, WRAP,
CLIENT_AUTH,
AUTH_JOIN,
REACTION, REACTION,
ZAP_RESPONSE, ZAP_RESPONSE,
DIRECT_MESSAGE, DIRECT_MESSAGE,
GROUP_META,
MESSAGE,
GROUPS,
THREAD,
COMMENT,
getGroupTags,
getRelayTagValues, getRelayTagValues,
getPubkeyTagValues, getPubkeyTagValues,
isHashedEvent, isHashedEvent,
@@ -30,11 +38,16 @@ import {
readList, readList,
getListTags, getListTags,
asDecryptedEvent, asDecryptedEvent,
isSignedEvent,
hasValidSignature,
normalizeRelayUrl, normalizeRelayUrl,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util" import type {
TrustedEvent,
Repository,
SignedEvent,
PublishedList,
List,
Filter,
} from "@welshman/util"
import {Nip59} from "@welshman/signer" import {Nip59} from "@welshman/signer"
import { import {
pubkey, pubkey,
@@ -46,30 +59,30 @@ import {
getDefaultNetContext, getDefaultNetContext,
makeRouter, makeRouter,
tracker, tracker,
makeTrackerStore,
makeRepositoryStore,
relay, relay,
getSession, getSession,
getSigner, getSigner,
hasNegentropy,
pull,
createSearch, createSearch,
userFollows, userFollows,
ensurePlaintext, ensurePlaintext,
thunks,
walkThunks,
} from "@welshman/app" } from "@welshman/app"
import type {AppSyncOpts} from "@welshman/app" import type {Thunk, Relay} from "@welshman/app"
import type {SubscribeRequestWithHandlers} from "@welshman/net" import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {deriveEvents, deriveEventsMapped, withGetter} from "@welshman/store" import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store"
export const ROOM = "~" export const ROOM = "h"
export const GENERAL = "general" export const GENERAL = "_"
export const MESSAGE = 209 export const PROTECTED = ["-"]
export const THREAD = 309 export const LEGACY_MESSAGE = 209
export const COMMENT = 1111 export const LEGACY_THREAD = 309
export const MEMBERSHIPS = 10209
export const INDEXER_RELAYS = [ export const INDEXER_RELAYS = [
"wss://purplepag.es/", "wss://purplepag.es/",
@@ -91,6 +104,8 @@ export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT
export const PLATFORM_DESCRIPTION = import.meta.env.VITE_PLATFORM_DESCRIPTION export const PLATFORM_DESCRIPTION = import.meta.env.VITE_PLATFORM_DESCRIPTION
export const BURROW_URL = import.meta.env.VITE_BURROW_URL
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com" export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
@@ -99,6 +114,19 @@ export const IMGPROXY_URL = "https://imgproxy.coracle.social"
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE] export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
export const THREAD_FILTER: Filter = {kinds: [THREAD, LEGACY_THREAD]}
export const COMMENT_FILTER: Filter = {
kinds: [COMMENT],
"#K": [String(THREAD), String(LEGACY_THREAD)],
}
export const NIP46_PERMS =
"nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt," +
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, GROUPS, WRAP, REACTION]
.map(k => `sign_event:${k}`)
.join(",")
export const colors = [ export const colors = [
["amber", twColors.amber[600]], ["amber", twColors.amber[600]],
["blue", twColors.blue[600]], ["blue", twColors.blue[600]],
@@ -144,7 +172,7 @@ export const pubkeyLink = (
relays = ctx.app.router.FromPubkeys([pubkey]).getUrls(), relays = ctx.app.router.FromPubkeys([pubkey]).getUrls(),
) => entityLink(nip19.nprofileEncode({pubkey, relays})) ) => entityLink(nip19.nprofileEncode({pubkey, relays}))
export const tagRoom = (room: string, url: string) => [ROOM, room, url] export const tagRoom = (room: string, url: string) => [ROOM, room]
export const getDefaultPubkeys = () => { export const getDefaultPubkeys = () => {
const appPubkeys = DEFAULT_PUBKEYS.split(",") const appPubkeys = DEFAULT_PUBKEYS.split(",")
@@ -153,6 +181,8 @@ export const getDefaultPubkeys = () => {
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys] return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
} }
const failedUnwraps = new Set()
export const ensureUnwrapped = async (event: TrustedEvent) => { export const ensureUnwrapped = async (event: TrustedEvent) => {
if (event.kind !== WRAP) { if (event.kind !== WRAP) {
return event return event
@@ -160,7 +190,7 @@ export const ensureUnwrapped = async (event: TrustedEvent) => {
let rumor = repository.eventsByWrap.get(event.id) let rumor = repository.eventsByWrap.get(event.id)
if (rumor) { if (rumor || failedUnwraps.has(event.id)) {
return rumor return rumor
} }
@@ -184,53 +214,16 @@ export const ensureUnwrapped = async (event: TrustedEvent) => {
// Send the rumor via our relay so listeners get updated // Send the rumor via our relay so listeners get updated
relay.send("EVENT", rumor) relay.send("EVENT", rumor)
} else {
failedUnwraps.add(event.id)
} }
return rumor return rumor
} }
export const pullConservatively = ({relays, filters}: AppSyncOpts) => { export const trackerStore = makeTrackerStore()
const [smart, dumb] = partition(hasNegentropy, relays)
const promises = [pull({relays: smart, filters})]
// Since pulling from relays without negentropy is expensive, limit how many export const repositoryStore = makeRepositoryStore()
// duplicates we repeatedly download
if (dumb.length > 0) {
const events = sortBy(e => -e.created_at, repository.query(filters))
if (events.length > 100) {
filters = filters.map(assoc("since", events[100]!.created_at))
}
promises.push(pull({relays: dumb, filters}))
}
return Promise.all(promises)
}
setContext({
net: getDefaultNetContext({
isValid: (url: string, event: TrustedEvent) => {
if (!isSignedEvent(event) || !hasValidSignature(event)) {
return false
}
const roomTags = event.tags.filter(nthEq(0, "~"))
if (roomTags.length > 0 && !roomTags.some(nthEq(2, url))) {
return false
}
return true
},
}),
app: getDefaultAppContext({
dufflepudUrl: DUFFLEPUD_URL,
indexerRelays: INDEXER_RELAYS,
requestTimeout: 5000,
router: makeRouter(),
}),
})
export const deriveEvent = (idOrAddress: string, hints: string[] = []) => { export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
let attempted = false let attempted = false
@@ -251,25 +244,64 @@ export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
) )
} }
export const eventIsForUrl = (url: string, event: TrustedEvent) => export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thunks]) => {
event.tags.find(nthEq(0, "~"))?.[2] === url const getThunksByEventId = memoize(() => {
const thunksByEventId = new Map<string, Thunk[]>()
export const getEventsForUrl = (url: string, filters: Filter[]) => for (const thunk of walkThunks(Object.values($thunks))) {
sortBy( 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.request.relays) {
urls.push(url)
}
}
return uniq(urls)
}
})
export const getEventsForUrl = (repository: Repository, url: string, filters: Filter[]) => {
const $getUrlsForEvent = get(getUrlsForEvent)
const $events = repository.query(filters)
return sortBy(
e => -e.created_at, e => -e.created_at,
repository.query(filters).filter(e => eventIsForUrl(url, e)), $events.filter(e => $getUrlsForEvent(e.id).includes(url)),
) )
}
export const deriveEventsForUrl = (url: string, filters: Filter[]) => export const deriveEventsForUrl = (url: string, filters: Filter[]) =>
derived(deriveEvents(repository, {filters}), $events => derived([deriveEvents(repository, {filters}), getUrlsForEvent], ([$events, $getUrlsForEvent]) =>
sortBy( sortBy(
e => -e.created_at, e => -e.created_at,
$events.filter(e => eventIsForUrl(url, e)), $events.filter(e => $getUrlsForEvent(e.id).includes(url)),
), ),
) )
// Context
setContext({
net: getDefaultNetContext(),
app: getDefaultAppContext({
dufflepudUrl: DUFFLEPUD_URL,
indexerRelays: INDEXER_RELAYS,
requestTimeout: 5000,
router: makeRouter(),
}),
})
// Settings // Settings
export const canDecrypt = synced("canDecrypt", false)
export const SETTINGS = 38489 export const SETTINGS = 38489
export type Settings = { export type Settings = {
@@ -319,27 +351,29 @@ export const {
export const hasMembershipUrl = (list: List | undefined, url: string) => export const hasMembershipUrl = (list: List | undefined, url: string) =>
getListTags(list).some(t => { getListTags(list).some(t => {
if (t[0] === "r") return t[1] === url if (t[0] === "r") return t[1] === url
if (t[0] === "~") return t[2] === url if (t[0] === "group") return t[2] === url
return false return false
}) })
export const getMembershipUrls = (list?: List) => sort(getRelayTagValues(getListTags(list))) export const getMembershipUrls = (list?: List) => {
const tags = getListTags(list)
return sort(uniq([...getRelayTagValues(tags), ...getGroupTags(tags).map(nth(2))]))
}
export const getMembershipRooms = (list?: List) => export const getMembershipRooms = (list?: List) =>
getListTags(list) getGroupTags(getListTags(list)).map(([_, room, url, name = ""]) => ({url, room, name}))
.filter(t => t[0] === "~")
.map(t => ({url: t[2], room: t[1]}))
export const getMembershipRoomsByUrl = (url: string, list?: List) => export const getMembershipRoomsByUrl = (url: string, list?: List) =>
sort( sort(
getListTags(list) getGroupTags(getListTags(list))
.filter(t => t[0] === "~" && t[2] === url) .filter(t => t[2] === url)
.map(nth(1)), .map(nth(1)),
) )
export const memberships = deriveEventsMapped<PublishedList>(repository, { export const memberships = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [MEMBERSHIPS]}], filters: [{kinds: [GROUPS]}],
itemToEvent: item => item.event, itemToEvent: item => item.event,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
}) })
@@ -353,89 +387,7 @@ export const {
store: memberships, store: memberships,
getKey: list => list.event.pubkey, getKey: list => list.event.pubkey,
load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) => load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) =>
load({ load({...request, filters: [{kinds: [GROUPS], authors: [pubkey]}]}),
...request,
filters: [{kinds: [MEMBERSHIPS], authors: [pubkey]}],
}),
})
// Messages
export type ChannelMessage = {
url: string
room: string
event: TrustedEvent
}
export const readMessage = (event: TrustedEvent): Maybe<ChannelMessage> => {
const roomTags = event.tags.filter(nthEq(0, ROOM))
if (roomTags.length !== 1) return undefined
const [_, room, url] = roomTags[0]
if (!url || !room) return undefined
return {url: normalizeRelayUrl(url), room, event}
}
export const channelMessages = deriveEventsMapped<ChannelMessage>(repository, {
filters: [{kinds: [MESSAGE, COMMENT]}],
eventToItem: readMessage,
itemToEvent: item => item.event,
})
// Channels
export type Channel = {
id: string
url: string
room: string
messages: ChannelMessage[]
}
export const makeChannelId = (url: string, room: string) => `${url}|${room}`
export const splitChannelId = (id: string) => id.split("|")
export const channels = derived(
[memberships, channelMessages],
([$memberships, $channelMessages]) => {
const messagesByChannelId = new Map<string, ChannelMessage[]>()
// Add known rooms by membership so we don't have to scan messages to load all rooms
for (const membership of $memberships) {
for (const {url, room} of getMembershipRooms(membership)) {
messagesByChannelId.set(makeChannelId(url, room), [])
}
}
// Add messages/rooms without memberships
for (const message of $channelMessages) {
pushToMapKey(messagesByChannelId, makeChannelId(message.url, message.room), message)
}
return Array.from(messagesByChannelId.entries()).map(([id, messages]) => {
const [url, room] = splitChannelId(id)
return {id, url, room, messages}
})
},
)
export const {
indexStore: channelsById,
deriveItem: deriveChannel,
loadItem: loadChannel,
} = collection({
name: "channels",
store: channels,
getKey: channel => channel.id,
load: (id: string, request: Partial<SubscribeRequestWithHandlers> = {}) => {
const [url, room] = splitChannelId(id)
return load({...request, relays: [url], filters: [{"#~": [room]}]})
},
}) })
// Chats // Chats
@@ -502,19 +454,142 @@ export const chatSearch = derived(chats, $chats =>
}), }),
) )
// Rooms // Messages
export const roomsByUrl = derived(channels, $channels => { // TODO: remove support for legacy messages
const $roomsByUrl = new Map<string, string[]>() export const adaptLegacyMessage = (event: TrustedEvent) => {
if (event.kind === LEGACY_MESSAGE) {
let room = event.tags.find(nthEq(0, "~"))?.[1] || GENERAL
for (const channel of $channels) { if (room === "general") {
if (channel.room) { room = GENERAL
pushToMapKey($roomsByUrl, channel.url, channel.room)
}
} }
return $roomsByUrl return {...event, kind: MESSAGE, tags: [...event.tags, tagRoom(room, "")]}
}
return event
}
export const messages = derived(
deriveEvents(repository, {filters: [{kinds: [MESSAGE, LEGACY_MESSAGE]}]}),
$events => $events.map(adaptLegacyMessage),
)
// Nip29
export const groupMeta = deriveEvents(repository, {filters: [{kinds: [GROUP_META]}]})
export const hasNip29 = (relay?: Relay) =>
relay?.profile?.supported_nips?.map(String)?.includes("29")
// Channels
export type ChannelMeta = {
access: "public" | "private"
membership: "open" | "closed"
picture?: string
about?: string
}
export type Channel = {
url: string
room: string
name: string
meta?: ChannelMeta
}
export const makeChannelId = (url: string, room: string) => `${url}|${room}`
export const splitChannelId = (id: string) => id.split("|")
export const channelsById = withGetter(
derived(
[groupMeta, memberships, messages, getUrlsForEvent],
([$groupMeta, $memberships, $messages, $getUrlsForEvent]) => {
const channelsById = new Map<string, Channel>()
// Add meta using group meta events
for (const event of $groupMeta) {
const meta = fromPairs(event.tags)
const room = meta.d
if (room) {
for (const url of $getUrlsForEvent(event.id)) {
const id = makeChannelId(url, room)
channelsById.set(id, {
url,
room,
name: meta.name || room,
meta: {
access: meta.private ? "private" : "public",
membership: meta.closed ? "closed" : "open",
picture: meta.picture,
about: meta.about,
},
}) })
}
}
}
// Add known rooms based on membership events
for (const membership of $memberships) {
for (const {url, room, name} of getMembershipRooms(membership)) {
const id = makeChannelId(url, room)
if (!channelsById.has(id)) {
channelsById.set(id, {url, room, name})
}
}
}
// Add rooms based on known messages
for (const event of $messages) {
const [_, room] = event.tags.find(nthEq(0, ROOM)) || []
if (room) {
for (const url of $getUrlsForEvent(event.id)) {
const id = makeChannelId(url, room)
if (!channelsById.has(id)) {
channelsById.set(id, {url, room, name: room})
}
}
}
}
return channelsById
},
),
)
export const deriveChannel = (url: string, room: string) =>
derived(channelsById, $channelsById => $channelsById.get(makeChannelId(url, room)))
export const channelsByUrl = derived(channelsById, $channelsById => {
const $channelsByUrl = new Map<string, Channel[]>()
for (const channel of $channelsById.values()) {
pushToMapKey($channelsByUrl, channel.url, channel)
}
return $channelsByUrl
})
export const displayChannel = (url: string, room: string) => {
if (room === GENERAL) {
return "general"
}
return channelsById.get().get(makeChannelId(url, room))?.name || room
}
export const roomComparator = (url: string) => (room: string) =>
displayChannel(url, room).toLowerCase()
export const channelIsLocked = (channel?: Channel) =>
channel?.meta?.access === "private" && channel?.meta?.membership === "closed"
// User stuff // User stuff
@@ -532,7 +607,7 @@ export const userSettingValues = withGetter(
derived(userSettings, $s => $s?.values || defaultSettings), derived(userSettings, $s => $s?.values || defaultSettings),
) )
export const getSetting = (key: keyof Settings["values"]) => userSettingValues.get()[key] export const getSetting = <T>(key: keyof Settings["values"]) => userSettingValues.get()[key] as T
export const userMembership = withGetter( export const userMembership = withGetter(
derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => { derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
@@ -544,6 +619,36 @@ export const userMembership = withGetter(
}), }),
) )
export const userRoomsByUrl = withGetter(
derived(userMembership, $userMembership => {
const tags = getListTags($userMembership)
const $userRoomsByUrl = new Map<string, Set<string>>()
for (const [_, room, url] of getGroupTags(tags)) {
addToMapKey($userRoomsByUrl, url, room)
}
for (const url of getRelayTagValues(tags)) {
addToMapKey($userRoomsByUrl, url, GENERAL)
}
return $userRoomsByUrl
}),
)
export const deriveUserRooms = (url: string) =>
derived(userRoomsByUrl, $userRoomsByUrl =>
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || [GENERAL]))),
)
export const deriveOtherRooms = (url: string) =>
derived([deriveUserRooms(url), channelsByUrl], ([$userRooms, $channelsByUrl]) =>
sortBy(
roomComparator(url),
($channelsByUrl.get(url) || []).filter(c => !$userRooms.includes(c.room)).map(c => c.room),
),
)
// Other utils // Other utils
export const encodeRelay = (url: string) => encodeURIComponent(normalizeRelayUrl(url)) export const encodeRelay = (url: string) => encodeURIComponent(normalizeRelayUrl(url))
+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 16C2 13.1716 2 11.7574 2.87868 10.8787C3.75736 10 5.17157 10 8 10H16C18.8284 10 20.2426 10 21.1213 10.8787C22 11.7574 22 13.1716 22 16C22 18.8284 22 20.2426 21.1213 21.1213C20.2426 22 18.8284 22 16 22H8C5.17157 22 3.75736 22 2.87868 21.1213C2 20.2426 2 18.8284 2 16Z" stroke="#1C274C" stroke-width="1.5"/>
<path d="M6 10V8C6 4.68629 8.68629 2 12 2C15.3137 2 18 4.68629 18 8V10" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 554 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 18L16 6M16 6L20 10.125M16 6L12 10.125" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 6L8 18M8 18L12 13.875M8 18L4 13.875" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

+1 -2
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import {throttle} from "throttle-debounce"
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import {between} from "@welshman/lib" import {between, throttle} from "@welshman/lib"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte" import EmojiPicker from "@lib/components/EmojiPicker.svelte"
+4 -4
View File
@@ -1,13 +1,13 @@
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2 {$$props.class}"> <div class="grid grid-cols-1 gap-2 md:grid-cols-3 {$$props.class}">
<label class="flex items-center gap-2 font-bold"> <label class="flex items-center gap-2 font-bold">
<slot name="label" /> <slot name="label" />
</label> </label>
<div class="flex items-center gap-2"> <div class="col-span-2 flex items-center gap-2">
<slot name="input" /> <slot name="input" />
</div> </div>
<p class="flex-end text-sm md:col-span-3">
{#if $$slots.info} {#if $$slots.info}
<p class="flex-end text-sm sm:col-span-2">
<slot name="info" /> <slot name="info" />
</p>
{/if} {/if}
</p>
</div> </div>
+4
View File
@@ -51,6 +51,7 @@
import KeyMinimalisticSquare3 from "@assets/icons/Key Minimalistic Square 3.svg?dataurl" import KeyMinimalisticSquare3 from "@assets/icons/Key Minimalistic Square 3.svg?dataurl"
import Letter from "@assets/icons/Letter.svg?dataurl" import Letter from "@assets/icons/Letter.svg?dataurl"
import LinkRound from "@assets/icons/Link Round.svg?dataurl" import LinkRound from "@assets/icons/Link Round.svg?dataurl"
import Lock from "@assets/icons/Lock.svg?dataurl"
import Login from "@assets/icons/Login.svg?dataurl" import Login from "@assets/icons/Login.svg?dataurl"
import Login2 from "@assets/icons/Login 2.svg?dataurl" import Login2 from "@assets/icons/Login 2.svg?dataurl"
import Magnifer from "@assets/icons/Magnifer.svg?dataurl" import Magnifer from "@assets/icons/Magnifer.svg?dataurl"
@@ -73,6 +74,7 @@
import ShopMinimalistic from "@assets/icons/Shop Minimalistic.svg?dataurl" import ShopMinimalistic from "@assets/icons/Shop Minimalistic.svg?dataurl"
import SmileCircle from "@assets/icons/Smile Circle.svg?dataurl" import SmileCircle from "@assets/icons/Smile Circle.svg?dataurl"
import SquareShareLine from "@assets/icons/Square Share Line.svg?dataurl" import SquareShareLine from "@assets/icons/Square Share Line.svg?dataurl"
import SortVertical from "@assets/icons/Sort Vertical.svg?dataurl"
import TrashBin2 from "@assets/icons/Trash Bin 2.svg?dataurl" import TrashBin2 from "@assets/icons/Trash Bin 2.svg?dataurl"
import UFO3 from "@assets/icons/UFO 3.svg?dataurl" import UFO3 from "@assets/icons/UFO 3.svg?dataurl"
import UserHeart from "@assets/icons/User Heart.svg?dataurl" import UserHeart from "@assets/icons/User Heart.svg?dataurl"
@@ -132,6 +134,7 @@
letter: Letter, letter: Letter,
"link-round": LinkRound, "link-round": LinkRound,
login: Login, login: Login,
lock: Lock,
"login-2": Login2, "login-2": Login2,
magnifer: Magnifer, magnifer: Magnifer,
mailbox: Mailbox, mailbox: Mailbox,
@@ -155,6 +158,7 @@
"trash-bin-2": TrashBin2, "trash-bin-2": TrashBin2,
"ufo-3": UFO3, "ufo-3": UFO3,
"square-share-line": SquareShareLine, "square-share-line": SquareShareLine,
"sort-vertical": SortVertical,
"user-heart": UserHeart, "user-heart": UserHeart,
"user-circle": UserCircle, "user-circle": UserCircle,
"user-rounded": UserRounded, "user-rounded": UserRounded,
+1 -2
View File
@@ -4,10 +4,9 @@
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import {identity} from "@welshman/lib" import {identity} from "@welshman/lib"
import {createSearch} from "@welshman/app" import {createSearch} from "@welshman/app"
import {Suggestions, SuggestionString} from "@welshman/editor"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte" import Tippy from "@lib/components/Tippy.svelte"
import Suggestions from "@lib/editor/Suggestions.svelte"
import SuggestionString from "@lib/editor/SuggestionString.svelte"
export let value: string export let value: string
export let options: string[] export let options: string[]
@@ -1,3 +1,3 @@
<div class="flex flex-col gap-1 px-2 py-4"> <div class="flex flex-col gap-1 px-2 py-4 {$$props.class}">
<slot /> <slot />
</div> </div>
+2 -2
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import {slide, fade} from "svelte/transition" import {slide, fade} from "svelte/transition"
export let loading export let loading = false
</script> </script>
<span class="flex items-center"> <span class="flex min-h-10 items-center">
{#if loading} {#if loading}
<span class="pr-3" transition:slide|local={{axis: "x"}}> <span class="pr-3" transition:slide|local={{axis: "x"}}>
<span class="loading loading-spinner" transition:fade|local={{duration: 100}} /> <span class="loading loading-spinner" transition:fade|local={{duration: 100}} />
-20
View File
@@ -1,20 +0,0 @@
<script lang="ts">
import cx from "classnames"
import type {NodeViewProps} from "@tiptap/core"
import {NodeViewWrapper} from "svelte-tiptap"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {clip} from "@app/toast"
export let node: NodeViewProps["node"]
export let selected: NodeViewProps["selected"]
const copy = () => clip(node.attrs.lnbc)
</script>
<NodeViewWrapper class="inline">
<Button on:click={copy} class={cx("link-content", {"link-content-selected": selected})}>
<Icon icon="bolt" size={3} class="inline-block translate-y-px" />
{node.attrs.lnbc.slice(0, 16)}...
</Button>
</NodeViewWrapper>
-32
View File
@@ -1,32 +0,0 @@
<script lang="ts">
import cx from "classnames"
import type {NodeViewProps} from "@tiptap/core"
import {NodeViewWrapper} from "svelte-tiptap"
import {ellipsize, nthEq} from "@welshman/lib"
import {type TrustedEvent, fromNostrURI, Address} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import {deriveEvent, entityLink} from "@app/state"
export let node: NodeViewProps["node"]
export let selected: NodeViewProps["selected"]
const displayEvent = (e: TrustedEvent) => {
const content = e?.tags.find(nthEq(0, "alt"))?.[1] || e?.content
return content.length > 1
? ellipsize(content, 30)
: fromNostrURI(nevent || naddr).slice(0, 16) + "..."
}
$: ({identifier, pubkey, kind, id, relays = [], nevent, naddr} = node.attrs)
$: event = deriveEvent(id || new Address(kind, pubkey, identifier).toString(), relays)
</script>
<NodeViewWrapper class="inline">
<Link
external
href={entityLink(node.attrs.nevent)}
class={cx("link-content", {"link-content-selected": selected})}>
{displayEvent($event)}
</Link>
</NodeViewWrapper>
-18
View File
@@ -1,18 +0,0 @@
<script lang="ts">
import cx from "classnames"
import type {NodeViewProps} from "@tiptap/core"
import {NodeViewWrapper} from "svelte-tiptap"
import Icon from "@lib/components/Icon.svelte"
export let node: NodeViewProps["node"]
export let selected: NodeViewProps["selected"]
</script>
<NodeViewWrapper class={cx("link-content inline", {"link-content-selected": selected})}>
{#if node.attrs.uploading}
<span class="loading loading-spinner loading-xs translate-y-[2px] scale-75" />
{:else}
<Icon icon="paperclip" size={3} class="inline-block translate-y-px" />
{/if}
{node.attrs.file.name}
</NodeViewWrapper>
-21
View File
@@ -1,21 +0,0 @@
<script lang="ts">
import cx from "classnames"
import type {NodeViewProps} from "@tiptap/core"
import {NodeViewWrapper} from "svelte-tiptap"
import {displayUrl} from "@welshman/lib"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
export let node: NodeViewProps["node"]
export let selected: NodeViewProps["selected"]
</script>
<NodeViewWrapper class="inline-block">
<Link
external
href={node.attrs.url}
class={cx("link-content", {"link-content-selected": selected})}>
<Icon icon="link-round" size={3} class="inline-block" />
{displayUrl(node.attrs.url)}
</Link>
</NodeViewWrapper>
-23
View File
@@ -1,23 +0,0 @@
<script lang="ts">
import cx from "classnames"
import type {NodeViewProps} from "@tiptap/core"
import {NodeViewWrapper} from "svelte-tiptap"
import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import {pubkeyLink} from "@app/state"
export let node: NodeViewProps["node"]
export let selected: NodeViewProps["selected"]
$: profile = deriveProfile(node.attrs.pubkey, node.attrs.relays)
</script>
<NodeViewWrapper class="inline">
<Link
external
href={pubkeyLink(node.attrs.pubkey, node.attrs.relays)}
class={cx("link-content", {"link-content-selected": selected})}>
@{displayProfile($profile)}
</Link>
</NodeViewWrapper>
-16
View File
@@ -1,16 +0,0 @@
<script lang="ts">
import type {NodeViewProps} from "@tiptap/core"
import {NodeViewWrapper} from "svelte-tiptap"
import Icon from "@lib/components/Icon.svelte"
export let node: NodeViewProps["node"]
</script>
<NodeViewWrapper class="link-content inline">
{#if node.attrs.uploading}
<span class="loading loading-spinner loading-xs translate-y-[2px] scale-75" />
{:else}
<Icon icon="paperclip" size={3} class="inline-block translate-y-px" />
{/if}
{node.attrs.file.name}
</NodeViewWrapper>
-395
View File
@@ -1,395 +0,0 @@
import type {CommandProps, Editor} from "@tiptap/core"
import {Extension} from "@tiptap/core"
import {now} from "@welshman/lib"
import type {StampedEvent, SignedEvent} from "@welshman/util"
import type {ImageAttributes, VideoAttributes} from "nostr-editor"
import {readServerConfig, uploadFile} from "nostr-tools/nip96"
import {getToken} from "nostr-tools/nip98"
import type {Node} from "prosemirror-model"
import {Plugin, PluginKey} from "prosemirror-state"
import {writable} from "svelte/store"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
uploadFile: {
selectFiles: () => ReturnType
uploadFiles: () => ReturnType
getMetaTags: () => string[][]
}
}
}
export interface FileUploadOptions {
allowedMimeTypes: string[]
expiration: number
immediateUpload: boolean
hash: (file: File) => Promise<string>
sign?: (event: StampedEvent) => Promise<SignedEvent | undefined>
onDrop: (currentEditor: Editor, file: File, pos: number) => void
onComplete: (currentEditor: Editor) => void
}
interface UploadTask {
url?: string
sha256?: string
error?: string
}
function bufferToHex(buffer: ArrayBuffer) {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, "0"))
.join("")
}
export const FileUploadExtension = Extension.create<FileUploadOptions>({
name: "fileUpload",
addStorage() {
return {
loading: writable(false),
tags: [] as string[][],
}
},
addOptions() {
return {
allowedMimeTypes: [
"image/jpeg",
"image/png",
"image/gif",
"video/mp4",
"video/mpeg",
"video/webm",
],
immediateUpload: true,
expiration: 60000,
async hash(file: File) {
return bufferToHex(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))
},
onDrop() {},
onComplete() {},
}
},
addCommands() {
return {
selectFiles: () => props => {
props.tr.setMeta("selectFiles", true)
return true
},
uploadFiles: () => (props: CommandProps) => {
props.tr.setMeta("uploadFiles", true)
return true
},
getMetaTags: () =>
((props: CommandProps) => {
const tags: string[][] = []
// make sure the file uploaded is still in the editor content
props.editor.state.doc.descendants(node => {
if (!(node.type.name === "image" || node.type.name === "video")) {
return
}
const tag = props.editor.storage.fileUpload.tags.find((t: string[]) =>
t[1].includes(node.attrs.src),
)
if (tag) {
tags.push(tag)
}
})
return tags
}) as any,
}
},
addProseMirrorPlugins() {
const uploader = new Uploader(this.editor, this.options)
return [
new Plugin({
key: new PluginKey("fileUploadPlugin"),
state: {
init() {
return {}
},
apply(tr) {
setTimeout(() => {
if (tr.getMeta("selectFiles")) {
uploader.selectFiles()
tr.setMeta("selectFiles", null)
} else if (tr.getMeta("uploadFiles")) {
uploader.uploadFiles()
tr.setMeta("uploadFiles", null)
}
})
return {}
},
},
props: {
handleDrop: (_, event) => {
return uploader.handleDrop(event)
},
},
}),
]
},
})
class Uploader {
constructor(
public editor: Editor,
private options: FileUploadOptions,
) {}
get view() {
return this.editor.view
}
addFile(file: File, pos: number) {
if (
!this.options.allowedMimeTypes.some(amt => amt.split("*").every(s => file.type.includes(s)))
) {
return false
}
const {tr} = this.view.state
const [mimetype] = file.type.split("/")
const node = this.view.state.schema.nodes[mimetype].create({
file,
src: URL.createObjectURL(file),
alt: "",
uploading: false,
uploadError: null,
})
tr.insert(pos, node)
this.view.dispatch(tr)
if (this.options.immediateUpload) {
this.editor.storage.fileUpload.loading.set(true)
this.upload(node).then(() => this.editor.storage.fileUpload.loading.set(false))
}
this.options.onDrop(this.editor, file, pos)
return true
}
findNodePosition(node: Node) {
let pos = -1
this.view.state.doc.descendants((n, p) => {
if (n === node) {
pos = p
return false
}
})
return pos
}
findNodes(uploading: boolean) {
const nodes = [] as [Node, number][]
this.view.state.doc.descendants((node, pos) => {
if (!(node.type.name === "image" || node.type.name === "video")) {
return
}
if (node.attrs.sha256) {
return
}
if ((node.attrs.uploading || false) !== uploading) {
return
}
nodes.push([node, pos])
})
return nodes
}
updateNodeAttributes(nodeRef: Node, attrs: Record<string, unknown>) {
const {tr} = this.editor.view.state
const pos = this.findNodePosition(nodeRef)
if (pos === -1) return
Object.entries(attrs).forEach(
([key, value]) => value !== undefined && tr.setNodeAttribute(pos, key, value),
)
this.view.dispatch(tr)
}
onUploadDone(nodeRef: Node, response: UploadTask) {
this.findNodes(true).forEach(([node, pos]) => {
if (node.attrs.src === nodeRef.attrs.src) {
this.updateNodeAttributes(node, {
uploading: false,
src: response.url,
sha256: response.sha256,
uploadError: response.error,
})
}
})
}
async upload(node: Node) {
const {sign, hash, expiration} = this.options
const {
file,
alt,
uploadType,
uploadUrl: serverUrl,
} = node.attrs as ImageAttributes | VideoAttributes
this.updateNodeAttributes(node, {uploading: true, uploadError: null})
try {
if (uploadType === "nip96") {
const res = (await uploadNIP96({file, alt, sign: sign!, serverUrl}))!
// add the tags as received from nip-96 to the storage
this.editor.storage.fileUpload.tags.push(["imeta", ...res.tags!])
this.onUploadDone(node, res)
} else {
const res = await uploadBlossom({file, serverUrl, hash, sign, expiration})
this.editor.storage.fileUpload.tags.push([
"imeta",
`url ${res.url}`,
`size ${res.size}`,
`m ${res.type}`,
`x ${res.sha256}`,
])
this.onUploadDone(node, res)
}
} catch (error) {
const msg = error as string
this.onUploadDone(node, {error: msg})
throw new Error(msg as string)
}
}
async uploadFiles() {
const tasks = this.findNodes(false).map(([node]) => {
return this.upload(node)
})
try {
this.editor.storage.fileUpload.loading.set(true)
await Promise.all(tasks)
this.options.onComplete(this.editor)
} finally {
this.editor.storage.fileUpload.loading.set(false)
}
}
selectFiles() {
const input = document.createElement("input")
input.type = "file"
input.multiple = true
input.accept = this.options.allowedMimeTypes.join(",")
input.onchange = event => {
const files = (event.target as HTMLInputElement).files
if (files) {
Array.from(files).forEach(file => {
if (file) {
const pos = this.view.state.selection.from + 1
this.addFile(file, pos)
}
})
}
}
input.click()
}
handleDrop(event: DragEvent) {
event.preventDefault()
const pos = this.view.posAtCoords({left: event.clientX, top: event.clientY})?.pos
if (pos === undefined) return false
const file = event.dataTransfer?.files?.[0]
if (file) {
this.addFile(file, pos)
}
}
}
export interface NIP96Options {
file: File
alt?: string
serverUrl: string
expiration?: number
sign: (event: StampedEvent) => Promise<SignedEvent | undefined>
}
export async function uploadNIP96(options: NIP96Options) {
try {
const server = await readServerConfig(options.serverUrl)
const authorization = await getToken(server.api_url, "POST", options.sign as any, true)
const res = await uploadFile(options.file, server.api_url, authorization, {
alt: options.alt || "",
expiration: options.expiration?.toString() || "",
content_type: options.file.type,
})
if (res.status === "error") {
throw new Error(res.message)
}
const url = res.nip94_event?.tags.find(x => x[0] === "url")?.[1] || ""
const sha256 = res.nip94_event?.tags.find(x => x[0] === "x")?.[1] || ""
return {
url,
sha256,
tags: res.nip94_event?.tags.flatMap(item => item.join(" ")),
}
} catch (error) {
console.warn(error)
}
}
export interface BlossomOptions {
file: File
serverUrl: string
expiration?: number
hash?: (file: File) => Promise<string>
sign?: (event: StampedEvent) => Promise<SignedEvent | undefined>
}
export interface BlossomResponse {
sha256: string
size: number
type: string
uploaded: number
url: string
}
export interface BlossomResponseError {
message: string
}
export async function uploadBlossom(options: BlossomOptions) {
if (!options.hash) {
throw new Error("No hash function provided")
}
if (!options.sign) {
throw new Error("No signer provided")
}
const created_at = now()
const hash = await options.hash(options.file)
const event = await options.sign({
kind: 24242,
content: `Upload ${options.file.name}`,
created_at,
tags: [
["t", "upload"],
["x", hash],
["size", options.file.size.toString()],
["expiration", (created_at + (options.expiration || 60000)).toString()],
],
})
const data = JSON.stringify(event)
const base64 = btoa(data)
const authorization = `Nostr ${base64}`
const res = await fetch(options.serverUrl + "/upload", {
method: "PUT",
body: options.file,
headers: {
authorization,
},
})
const json = await res.json()
if (res.status === 200) {
return json as BlossomResponse
}
throw new Error((json as BlossomResponseError).message)
}
-124
View File
@@ -1,124 +0,0 @@
import {last} from "@welshman/lib"
import {Node, InputRule, nodePasteRule} from "@tiptap/core"
import type {Node as ProsemirrorNode} from "@tiptap/pm/model"
import type {MarkdownSerializerState} from "prosemirror-markdown"
import {createPasteRuleMatch} from "./util"
export const LINK_REGEX = /([a-z\+:]{2,30}:\/\/)?[^<>\(\)\s]+\.[a-z]{2,6}[^\s<>"'\.!?,:\)\(]*/gi
export interface LinkAttributes {
url: string
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
inlineLink: {
insertLink: (options: {url: string}) => ReturnType
}
}
}
export const LinkExtension = Node.create({
atom: true,
name: "inlineLink",
group: "inline",
inline: true,
selectable: true,
draggable: true,
priority: 1000,
addAttributes() {
return {
url: {default: null},
}
},
renderHTML(props) {
return ["div", {"data-url": props.node.attrs.url}]
},
renderText(props) {
return props.node.attrs.url
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write(node.attrs.url)
},
parse: {},
},
}
},
addCommands() {
return {
insertLink:
({url}) =>
({commands}) => {
return commands.insertContent(
{type: this.name, attrs: {url}},
{
updateSelection: false,
},
)
},
}
},
addInputRules() {
return [
new InputRule({
find: text => {
const match = last(Array.from(text.matchAll(LINK_REGEX)))
if (match && text.length === match.index + match[0].length + 1) {
return {
index: match.index!,
text: match[0],
data: {
url: match[0],
},
}
}
return null
},
handler: ({state, range, match}) => {
const {tr} = state
if (match[0]) {
try {
tr.insert(range.from - 1, this.type.create(match.data))
.delete(tr.mapping.map(range.from - 1), tr.mapping.map(range.to))
.insert(
tr.mapping.map(range.to),
this.editor.schema.text(last(Array.from(match.input!))),
)
} catch (e) {
// If the node was already linkified, the above code breaks for whatever reason
}
}
tr.scrollIntoView()
},
}),
]
},
addPasteRules() {
return [
nodePasteRule({
type: this.type,
getAttributes: match => match.data,
find: text => {
const matches = []
for (const match of text.matchAll(LINK_REGEX)) {
try {
matches.push(createPasteRuleMatch(match, {url: match[0]}))
} catch (e) {
continue
}
}
return matches
},
}),
]
},
})
-5
View File
@@ -1,5 +0,0 @@
<script lang="ts">
export let value
</script>
{value}
-100
View File
@@ -1,100 +0,0 @@
<svelte:options accessors />
<script lang="ts">
import {throttle} from "throttle-debounce"
import {fly, slide} from "svelte/transition"
import {clamp} from "@welshman/lib"
import Icon from "@lib/components/Icon.svelte"
import {theme} from "@app/theme"
export let term
export let search
export let select
export let component
export let loading = false
export let allowCreate = false
let index = 0
let element: Element
let items: string[] = []
$: populateItems(term)
const populateItems = throttle(300, term => {
items = $search.searchValues(term).slice(0, 5)
})
const setIndex = (newIndex: number, block: any) => {
index = clamp([0, items.length - 1], newIndex)
}
export const onKeyDown = (e: any) => {
if (["Enter", "Tab"].includes(e.code)) {
const value = items[index]
if (value) {
select(value)
return true
} else if (term && allowCreate) {
select(term)
return true
}
}
if (e.code === "Space" && term && allowCreate) {
select(term)
return true
}
if (e.code === "ArrowUp") {
setIndex(index - 1, "start")
return true
}
if (e.code === "ArrowDown") {
setIndex(index + 1, "start")
return true
}
}
</script>
{#if term}
<div
data-theme={$theme}
bind:this={element}
transition:fly|local={{duration: 200}}
class="mt-2 max-h-[350px] overflow-y-auto overflow-x-hidden shadow-xl {$$props.class} bg-alt"
style={$$props.style}>
{#if term && allowCreate && !items.includes(term)}
<button
class="white-space-nowrap block w-full min-w-0 cursor-pointer overflow-x-hidden text-ellipsis px-4 py-2 text-left transition-all hover:brightness-150"
on:mousedown|preventDefault
on:click|preventDefault={() => select(term)}>
Use "<svelte:component this={component} value={term} />"
</button>
{/if}
{#each items as value, i (value)}
<button
class="white-space-nowrap block flex w-full min-w-0 cursor-pointer items-center overflow-x-hidden text-ellipsis px-4 py-2 text-left transition-all hover:brightness-150"
on:mousedown|preventDefault
on:click|preventDefault={() => select(value)}>
{#if index === i}
<div transition:slide|local={{axis: "x"}} class="flex items-center pr-2">
<Icon icon="alt-arrow-right" />
</div>
{/if}
<svelte:component this={component} {value} />
</button>
{/each}
</div>
{#if loading}
<div transition:slide|local class="flex gap-2 px-4 py-2">
<div>
<i class="fa fa-circle-notch fa-spin" />
</div>
Loading more options...
</div>
{/if}
{/if}
-104
View File
@@ -1,104 +0,0 @@
import type {SvelteComponent, ComponentType} from "svelte"
import type {Readable} from "svelte/store"
import tippy, {type Instance} from "tippy.js"
import type {Editor} from "@tiptap/core"
import {PluginKey} from "@tiptap/pm/state"
import Suggestion from "@tiptap/suggestion"
import type {Search} from "@welshman/app"
export type SuggestionsOptions = {
char: string
name: string
editor: Editor
search: Readable<Search<any, any>>
select: (value: any, props: any) => void
allowCreate?: boolean
suggestionComponent: ComponentType
suggestionsComponent: ComponentType
}
export const createSuggestions = (options: SuggestionsOptions) =>
Suggestion({
char: options.char,
editor: options.editor,
pluginKey: new PluginKey(`suggest-${options.name}`),
command: ({editor, range, props}) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(" ")
if (overrideSpace) {
range.to += 1
}
editor
.chain()
.focus()
.insertContentAt(range, [
{type: options.name, attrs: props},
{type: "text", text: " "},
])
.run()
window.getSelection()?.collapseToEnd()
},
allow: ({state, range}) => {
const $from = state.doc.resolve(range.from)
const type = state.schema.nodes[options.name]
return !!$from.parent.type.contentMatch.matchType(type)
},
render: () => {
let popover: Instance[]
let suggestions: SvelteComponent
const mapProps = (props: any) => ({
term: props.query,
search: options.search,
allowCreate: options.allowCreate,
component: options.suggestionComponent,
select: (value: string) => options.select(value, props),
})
return {
onStart: props => {
const target = document.createElement("div")
popover = tippy("body", {
getReferenceClientRect: props.clientRect as any,
appendTo: document.querySelector("dialog[open]") || document.body,
content: target,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})
suggestions = new options.suggestionsComponent({target, props: mapProps(props)})
},
onUpdate: props => {
suggestions.$set(mapProps(props))
if (props.clientRect) {
popover[0].setProps({
getReferenceClientRect: props.clientRect as any,
})
}
},
onKeyDown: props => {
if (props.event.key === "Escape") {
popover[0].hide()
return true
}
return Boolean(suggestions.onKeyDown?.(props.event))
},
onExit: () => {
popover[0].destroy()
suggestions.$destroy()
},
}
},
})
-148
View File
@@ -1,148 +0,0 @@
import {nprofileEncode} from "nostr-tools/nip19"
import {SvelteNodeViewRenderer} from "svelte-tiptap"
import Placeholder from "@tiptap/extension-placeholder"
import Code from "@tiptap/extension-code"
import CodeBlock from "@tiptap/extension-code-block"
import Document from "@tiptap/extension-document"
import Dropcursor from "@tiptap/extension-dropcursor"
import Gapcursor from "@tiptap/extension-gapcursor"
import History from "@tiptap/extension-history"
import Paragraph from "@tiptap/extension-paragraph"
import Text from "@tiptap/extension-text"
import HardBreakExtension from "@tiptap/extension-hard-break"
import {
Bolt11Extension,
NProfileExtension,
NEventExtension,
NAddrExtension,
ImageExtension,
VideoExtension,
TagExtension,
} from "nostr-editor"
import type {StampedEvent} from "@welshman/util"
import {signer, profileSearch} from "@welshman/app"
import {FileUploadExtension} from "./FileUpload"
import {createSuggestions} from "./Suggestions"
import {LinkExtension} from "./LinkExtension"
import EditMention from "./EditMention.svelte"
import EditEvent from "./EditEvent.svelte"
import EditImage from "./EditImage.svelte"
import EditBolt11 from "./EditBolt11.svelte"
import EditVideo from "./EditVideo.svelte"
import EditLink from "./EditLink.svelte"
import Suggestions from "./Suggestions.svelte"
import SuggestionProfile from "./SuggestionProfile.svelte"
import {asInline} from "./util"
import {getSetting} from "@app/state"
export {
createSuggestions,
LinkExtension,
EditMention,
EditEvent,
EditImage,
EditBolt11,
EditVideo,
EditLink,
Suggestions,
SuggestionProfile,
}
export * from "./util"
type UploadType = "nip96" | "blossom"
type EditorOptions = {
submit: () => void
getPubkeyHints: (pubkey: string) => string[]
submitOnEnter?: boolean
placeholder?: string
autofocus?: boolean
uploadType?: UploadType
defaultUploadUrl?: string
}
export const getEditorOptions = ({
submit,
getPubkeyHints,
submitOnEnter,
placeholder = "",
autofocus = false,
uploadType = getSetting("upload_type") as UploadType,
defaultUploadUrl = getSetting("upload_type") == "nip96"
? (getSetting("nip96_urls") as string[])[0] || "https://nostr.build"
: (getSetting("blossom_urls") as string[])[0] || "https://cdn.satellite.earth",
}: EditorOptions) => ({
autofocus,
content: "",
extensions: [
Code,
CodeBlock,
Document,
Dropcursor,
Gapcursor,
History,
Paragraph,
Text,
TagExtension,
Placeholder.configure({placeholder}),
HardBreakExtension.extend({
addKeyboardShortcuts() {
return {
"Shift-Enter": () => this.editor.commands.setHardBreak(),
"Mod-Enter": () => {
if (this.editor.getText().trim()) {
submit()
return true
}
return this.editor.commands.setHardBreak()
},
Enter: () => {
if (submitOnEnter && this.editor.getText().trim()) {
submit()
return true
}
return this.editor.commands.setHardBreak()
},
}
},
}),
LinkExtension.extend({addNodeView: () => SvelteNodeViewRenderer(EditLink)}),
Bolt11Extension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(EditBolt11)})),
NProfileExtension.extend({
addNodeView: () => SvelteNodeViewRenderer(EditMention),
addProseMirrorPlugins() {
return [
createSuggestions({
char: "@",
name: "nprofile",
editor: this.editor,
search: profileSearch,
select: (pubkey: string, props: any) => {
const relays = getPubkeyHints(pubkey)
const nprofile = nprofileEncode({pubkey, relays})
return props.command({pubkey, nprofile, relays})
},
suggestionComponent: SuggestionProfile,
suggestionsComponent: Suggestions,
}),
]
},
}),
NEventExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(EditEvent)})),
NAddrExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(EditEvent)})),
ImageExtension.extend(
asInline({addNodeView: () => SvelteNodeViewRenderer(EditImage)}),
).configure({defaultUploadUrl, defaultUploadType: uploadType}),
VideoExtension.extend(
asInline({addNodeView: () => SvelteNodeViewRenderer(EditVideo)}),
).configure({defaultUploadUrl, defaultUploadType: uploadType}),
FileUploadExtension.configure({
immediateUpload: true,
allowedMimeTypes: ["image/*", "video/*"],
sign: (event: StampedEvent) => signer.get()!.sign(event),
}),
],
})
-94
View File
@@ -1,94 +0,0 @@
import type {JSONContent, PasteRuleMatch, InputRuleMatch} from "@tiptap/core"
import {Editor} from "@tiptap/core"
import {ctx} from "@welshman/lib"
import {Address} from "@welshman/util"
export const asInline = (extend: Record<string, any>) => ({
inline: true,
group: "inline",
...extend,
})
export const createInputRuleMatch = <T extends Record<string, unknown>>(
match: RegExpMatchArray,
data: T,
): InputRuleMatch => ({index: match.index!, text: match[0], match, data})
export const createPasteRuleMatch = <T extends Record<string, unknown>>(
match: RegExpMatchArray,
data: T,
): PasteRuleMatch => ({index: match.index!, text: match[0], match, data})
export const findNodes = (type: string, json: JSONContent) => {
const results: JSONContent[] = []
for (const node of json.content || []) {
if (node.type === type) {
results.push(node)
}
for (const result of findNodes(type, node)) {
results.push(result)
}
}
return results
}
export const findMarks = (type: string, json: JSONContent) => {
const results: JSONContent[] = []
for (const node of json.content || []) {
for (const mark of node.marks || []) {
if (mark.type === type) {
results.push(mark)
}
}
for (const result of findMarks(type, node)) {
results.push(result)
}
}
return results
}
export const getEditorTags = (editor: Editor) => {
const json = editor.getJSON()
const topicTags = findMarks("tag", json).map(({attrs}: any) => [
"t",
attrs.tag.replace(/^#/, "").toLowerCase(),
])
const naddrTags = findNodes("naddr", json).map(
({attrs: {kind, pubkey, identifier, relays = []}}: any) => {
const address = new Address(kind, pubkey, identifier).toString()
return ["q", address, ctx.app.router.FromRelays(relays).getUrl(), pubkey]
},
)
const neventTags = findNodes("nevent", json).map(({attrs: {id, author, relays = []}}: any) => [
"q",
id,
ctx.app.router.FromRelays(relays).getUrl(),
author || "",
])
const mentionTags = findNodes("nprofile", json).map(({attrs: {pubkey, relays = []}}: any) => [
"p",
pubkey,
ctx.app.router.FromRelays(relays).getUrl(),
"",
])
const imetaTags = findNodes("image", json).map(({attrs: {src, sha256}}: any) => [
"imeta",
`url ${src}`,
`x ${sha256}`,
`ox ${sha256}`,
])
return [...topicTags, ...naddrTags, ...neventTags, ...mentionTags, ...imetaTags]
}
+56 -92
View File
@@ -3,13 +3,14 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {get, derived} from "svelte/store" import {get, derived} from "svelte/store"
import {page} from "$app/stores"
import {dev} from "$app/environment" import {dev} from "$app/environment"
import {bytesToHex, hexToBytes} from "@noble/hashes/utils" import {bytesToHex, hexToBytes} from "@noble/hashes/utils"
import {identity, uniq, sleep, take, sortBy, ago, now, HOUR, WEEK, Worker} from "@welshman/lib" import {identity, sleep, take, sortBy, ago, now, HOUR, WEEK, MONTH, Worker} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import { import {
MESSAGE,
PROFILE, PROFILE,
DELETE,
REACTION, REACTION,
ZAP_RESPONSE, ZAP_RESPONSE,
FOLLOWS, FOLLOWS,
@@ -19,7 +20,6 @@
getPubkeyTagValues, getPubkeyTagValues,
getListTags, getListTags,
} from "@welshman/util" } from "@welshman/util"
import {throttled} from "@welshman/store"
import { import {
relays, relays,
handles, handles,
@@ -36,8 +36,8 @@
signer, signer,
dropSession, dropSession,
getRelayUrls, getRelayUrls,
subscribe,
userInboxRelaySelections, userInboxRelaySelections,
load,
} from "@welshman/app" } from "@welshman/app"
import * as lib from "@welshman/lib" import * as lib from "@welshman/lib"
import * as util from "@welshman/util" import * as util from "@welshman/util"
@@ -49,20 +49,11 @@
import {setupTracking} from "@app/tracking" import {setupTracking} from "@app/tracking"
import {setupAnalytics} from "@app/analytics" import {setupAnalytics} from "@app/analytics"
import {theme} from "@app/theme" import {theme} from "@app/theme"
import { import {INDEXER_RELAYS, userMembership, ensureUnwrapped, canDecrypt} from "@app/state"
INDEXER_RELAYS, import {loadUserData} from "@app/commands"
getMembershipUrls, import {listenForNotifications} from "@app/requests"
getMembershipRooms,
userMembership,
ensureUnwrapped,
MESSAGE,
COMMENT,
THREAD,
GENERAL,
} from "@app/state"
import {loadUserData, subscribePersistent} from "@app/commands"
import * as commands from "@app/commands" import * as commands from "@app/commands"
import {checked} from "@app/notifications" import * as requests from "@app/requests"
import * as notifications from "@app/notifications" import * as notifications from "@app/notifications"
import * as state from "@app/state" import * as state from "@app/state"
@@ -86,25 +77,53 @@
...app, ...app,
...state, ...state,
...commands, ...commands,
...requests,
...notifications, ...notifications,
}) })
const getScoreEvent = () => { if (!db) {
const ALWAYS_KEEP = Infinity setupTracking()
const NEVER_KEEP = 0 setupAnalytics()
const reactionKinds = [REACTION, ZAP_RESPONSE] ready = initStorage("flotilla", 4, {
relays: storageAdapters.fromCollectionStore("url", relays, {throttle: 3000}),
handles: storageAdapters.fromCollectionStore("nip05", handles, {throttle: 3000}),
freshness: storageAdapters.fromObjectStore(freshness, {
throttle: 3000,
migrate: (data: {key: string; value: number}[]) => {
const cutoff = ago(HOUR)
return data.filter(({value}) => value > cutoff)
},
}),
plaintext: storageAdapters.fromObjectStore(plaintext, {
throttle: 3000,
migrate: (data: {key: string; value: number}[]) => data.slice(0, 10_000),
}),
events: storageAdapters.fromRepositoryAndTracker(repository, tracker, {
throttle: 3000,
migrate: (events: TrustedEvent[]) => {
if (events.length < 15_000) {
return events
}
const NEVER_KEEP = 0
const ALWAYS_KEEP = Infinity
const reactionKinds = [REACTION, ZAP_RESPONSE, DELETE]
const metaKinds = [PROFILE, FOLLOWS, RELAYS, INBOX_RELAYS] const metaKinds = [PROFILE, FOLLOWS, RELAYS, INBOX_RELAYS]
const $sessionKeys = new Set(Object.keys(app.sessions.get())) const $sessionKeys = new Set(Object.keys(app.sessions.get()))
const $userFollows = new Set(getPubkeyTagValues(getListTags(get(app.userFollows)))) const $userFollows = new Set(getPubkeyTagValues(getListTags(get(app.userFollows))))
const $maxWot = get(app.maxWot) const $maxWot = get(app.maxWot)
return (e: TrustedEvent) => { const scoreEvent = (e: TrustedEvent) => {
const isFollowing = $userFollows.has(e.pubkey) const isFollowing = $userFollows.has(e.pubkey)
// No need to keep a record of everyone who follows the current user // No need to keep a record of everyone who follows the current user
if (e.kind === FOLLOWS && !isFollowing) return NEVER_KEEP if (e.kind === FOLLOWS && !isFollowing) return NEVER_KEEP
// Drop room messages after a month, re-load on demand
if (e.kind === MESSAGE && e.created_at < ago(MONTH)) return NEVER_KEEP
// Always keep stuff by or tagging a signed in user // Always keep stuff by or tagging a signed in user
if ($sessionKeys.has(e.pubkey)) return ALWAYS_KEEP if ($sessionKeys.has(e.pubkey)) return ALWAYS_KEEP
if (e.tags.some(t => $sessionKeys.has(t[1]))) return ALWAYS_KEEP if (e.tags.some(t => $sessionKeys.has(t[1]))) return ALWAYS_KEEP
@@ -122,47 +141,13 @@
return score return score
} }
}
const migrateFreshness = (data: {key: string; value: number}[]) => {
const cutoff = ago(HOUR)
return data.filter(({value}) => value < cutoff)
}
const migratePlaintext = (data: {key: string; value: number}[]) => data.slice(0, 10_000)
const migrateEvents = (events: TrustedEvent[]) => {
if (events.length < 50_000) {
return events
}
const scoreEvent = getScoreEvent()
return take( return take(
30_000, 10_000,
sortBy(e => -scoreEvent(e), events), sortBy(e => -scoreEvent(e), events),
) )
} },
if (!db) {
setupTracking()
setupAnalytics()
ready = initStorage("flotilla", 4, {
events: storageAdapters.fromRepository(repository, {throttle: 300, migrate: migrateEvents}),
relays: {keyPath: "url", store: throttled(1000, relays)},
handles: {keyPath: "nip05", store: throttled(1000, handles)},
checked: storageAdapters.fromObjectStore(checked, {throttle: 1000}),
freshness: storageAdapters.fromObjectStore(freshness, {
throttle: 1000,
migrate: migrateFreshness,
}), }),
plaintext: storageAdapters.fromObjectStore(plaintext, {
throttle: 1000,
migrate: migratePlaintext,
}),
tracker: storageAdapters.fromTracker(tracker, {throttle: 1000}),
}).then(() => sleep(300)) }).then(() => sleep(300))
// Unwrap gift wraps as they come in, but throttled // Unwrap gift wraps as they come in, but throttled
@@ -171,6 +156,10 @@
unwrapper.addGlobalHandler(ensureUnwrapped) unwrapper.addGlobalHandler(ensureUnwrapped)
repository.on("update", ({added}) => { repository.on("update", ({added}) => {
if (!$canDecrypt) {
return
}
for (const event of added) { for (const event of added) {
if (event.kind === WRAP) { if (event.kind === WRAP) {
unwrapper.push(event) unwrapper.push(event)
@@ -189,45 +178,22 @@
} }
// Listen for space data, populate space-based notifications // Listen for space data, populate space-based notifications
let unsubRooms: any let unsubSpaces: any
userMembership.subscribe($membership => { userMembership.subscribe($membership => {
unsubRooms?.() unsubSpaces?.()
unsubSpaces = listenForNotifications()
const since = ago(30)
const rooms = uniq(getMembershipRooms($membership).map(m => m.room)).concat(GENERAL)
const relays = uniq(getMembershipUrls($membership))
// Get one event for each of our notification categories
load({
relays,
filters: [
{kinds: [THREAD], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#~": [room], limit: 1})),
],
})
// Listen for new notifications/memberships
unsubRooms = subscribePersistent({
relays,
filters: [
{kinds: [THREAD], since},
{kinds: [COMMENT], "#K": [String(THREAD)], since},
{kinds: [MESSAGE], "#~": rooms, since},
],
})
}) })
// Listen for chats, populate chat-based notifications // Listen for chats, populate chat-based notifications
let unsubChats: any let chatsSub: any
derived([pubkey, userInboxRelaySelections], identity).subscribe( derived([pubkey, canDecrypt, userInboxRelaySelections], identity).subscribe(
([$pubkey, $userInboxRelaySelections]) => { ([$pubkey, $canDecrypt, $userInboxRelaySelections]) => {
unsubChats?.() chatsSub?.close()
if ($pubkey) { if ($pubkey && $canDecrypt) {
unsubChats = subscribePersistent({ chatsSub = subscribe({
filters: [ filters: [
{kinds: [WRAP], "#p": [$pubkey], since: ago(WEEK, 2)}, {kinds: [WRAP], "#p": [$pubkey], since: ago(WEEK, 2)},
{kinds: [WRAP], "#p": [$pubkey], limit: 100}, {kinds: [WRAP], "#p": [$pubkey], limit: 100},
@@ -252,9 +218,7 @@
{:then} {:then}
<div data-theme={$theme}> <div data-theme={$theme}>
<AppContainer> <AppContainer>
{#key $page.url.pathname}
<slot /> <slot />
{/key}
</AppContainer> </AppContainer>
<ModalContainer /> <ModalContainer />
<div class="tippy-target" /> <div class="tippy-target" />
+6 -18
View File
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {page} from "$app/stores"
import {ctx} from "@welshman/lib" import {ctx} from "@welshman/lib"
import {WRAP} from "@welshman/util" import {WRAP} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import {pubkey} from "@welshman/app"
import {pubkey, repository} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -13,7 +12,8 @@
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte" import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import ChatStart from "@app/components/ChatStart.svelte" import ChatStart from "@app/components/ChatStart.svelte"
import ChatItem from "@app/components/ChatItem.svelte" import ChatItem from "@app/components/ChatItem.svelte"
import {chatSearch, pullConservatively, ensureUnwrapped} from "@app/state" import {chatSearch} from "@app/state"
import {pullConservatively} from "@app/requests"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const startChat = () => pushModal(ChatStart) const startChat = () => pushModal(ChatStart)
@@ -23,23 +23,9 @@
relays: ctx.app.router.UserInbox().getUrls(), relays: ctx.app.router.UserInbox().getUrls(),
}) })
const onUpdate = ({added}: {added: TrustedEvent[]}) => {
for (const event of added) {
ensureUnwrapped(event)
}
}
let term = "" let term = ""
$: chats = $chatSearch.searchOptions(term).filter(c => c.pubkeys.length > 1) $: chats = $chatSearch.searchOptions(term).filter(c => c.pubkeys.length > 1)
onMount(() => {
repository.on("update", onUpdate)
return () => {
repository.off("update", onUpdate)
}
})
</script> </script>
<SecondaryNav> <SecondaryNav>
@@ -67,5 +53,7 @@
</div> </div>
</SecondaryNav> </SecondaryNav>
<Page> <Page>
{#key $page.url.pathname}
<slot /> <slot />
{/key}
</Page> </Page>
+2 -2
View File
@@ -15,10 +15,10 @@
import SpaceCheck from "@app/components/SpaceCheck.svelte" import SpaceCheck from "@app/components/SpaceCheck.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import { import {
userMembership,
memberships, memberships,
membershipByPubkey, membershipByPubkey,
getMembershipUrls, getMembershipUrls,
userRoomsByUrl,
getDefaultPubkeys, getDefaultPubkeys,
} from "@app/state" } from "@app/state"
import {discoverRelays} from "@app/commands" import {discoverRelays} from "@app/commands"
@@ -98,7 +98,7 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if getMembershipUrls($userMembership).includes(relay.url)} {#if $userRoomsByUrl.has(relay.url)}
<div <div
class="tooltip absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary" class="tooltip absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary"
data-tip="You are already a member of this space."> data-tip="You are already a member of this space.">
+2 -2
View File
@@ -40,14 +40,14 @@
<CardButton> <CardButton>
<div slot="icon"><Icon icon="compass" size={7} /></div> <div slot="icon"><Icon icon="compass" size={7} /></div>
<div slot="title">Browse the network</div> <div slot="title">Browse the network</div>
<div slot="info">Find your people on the nostr network</div> <div slot="info">Find your people on the nostr network.</div>
</CardButton> </CardButton>
</Link> </Link>
<Button on:click={startChat}> <Button on:click={startChat}>
<CardButton> <CardButton>
<div slot="icon"><Icon icon="chat-round" size={7} /></div> <div slot="icon"><Icon icon="chat-round" size={7} /></div>
<div slot="title">Start a conversation</div> <div slot="title">Start a conversation</div>
<div slot="info">Use nostr's encrypted group chats to stay in touch</div> <div slot="info">Use nostr's encrypted group chats to stay in touch.</div>
</CardButton> </CardButton>
</Button> </Button>
</div> </div>
+7 -2
View File
@@ -1,9 +1,14 @@
<script lang="ts"> <script lang="ts">
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {PLATFORM_NAME, pubkeyLink} from "@app/state" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
const pubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322" const pubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"
const openProfile = () => pushModal(ProfileDetail, {pubkey})
</script> </script>
<div class="mt-8 min-h-screen bg-base-200 sm:hero"> <div class="mt-8 min-h-screen bg-base-200 sm:hero">
@@ -34,7 +39,7 @@
<p class="text-sm"> <p class="text-sm">
Built with 💜 by Built with 💜 by
<span class="text-primary"> <span class="text-primary">
@<Link external href={pubkeyLink(pubkey)} class="link">hodlbod</Link> @<Button on:click={openProfile} class="link">hodlbod</Button>
</span> </span>
</p> </p>
<p class="text-xs"> <p class="text-xs">

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