Compare commits

...

77 Commits

Author SHA1 Message Date
Jon Staab 4c6b8155f8 Bump version 2024-12-16 19:04:10 -08: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
95 changed files with 2462 additions and 1312 deletions
+1
View File
@@ -1,4 +1,5 @@
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_NAME=Flotilla
VITE_PLATFORM_LOGO=static/flotilla.png
+1 -1
View File
@@ -24,5 +24,5 @@ static/pwa-192x192.png
static/pwa-512x512.png
static/apple-touch-icon-180x180.png
static/maskable-icon-512x512.png
src/assets
src/assets/icons/*.webp
manifest.webmanifest
+64 -64
View File
@@ -1,12 +1,12 @@
{
"name": "flotilla",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flotilla",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@capacitor/android": "^6.1.2",
"@capacitor/cli": "^6.1.2",
@@ -28,18 +28,17 @@
"@tiptap/extension-text": "^2.6.6",
"@tiptap/suggestion": "^2.6.4",
"@types/qrcode": "^1.5.5",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.27",
"@welshman/content": "~0.0.12",
"@welshman/dvm": "~0.0.10",
"@welshman/feeds": "~0.0.25",
"@welshman/lib": "~0.0.26",
"@welshman/net": "~0.0.36",
"@welshman/signer": "~0.0.14",
"@welshman/store": "~0.0.12",
"@welshman/util": "~0.0.45",
"@welshman/app": "~0.0.34",
"@welshman/content": "~0.0.14",
"@welshman/dvm": "~0.0.12",
"@welshman/feeds": "~0.0.27",
"@welshman/lib": "~0.0.33",
"@welshman/net": "~0.0.43",
"@welshman/signer": "~0.0.17",
"@welshman/store": "~0.0.14",
"@welshman/util": "~0.0.52",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -52,8 +51,7 @@
"nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4",
"svelte-tiptap": "^1.1.3",
"throttle-debounce": "^5.0.2"
"svelte-tiptap": "^1.1.3"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
@@ -4664,18 +4662,19 @@
}
},
"node_modules/@welshman/app": {
"version": "0.0.27",
"resolved": "https://registry.npmjs.org/@welshman/app/-/app-0.0.27.tgz",
"integrity": "sha512-o8voN+ldio+LjYathHKhTG0Vx8rZLoHksAPLLg4rzgAjqKIgWetQ4XjU/Fjqv/5rNNjBh6u0Jr5vR1PUkyptOw==",
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/@welshman/app/-/app-0.0.34.tgz",
"integrity": "sha512-wxCWvoal/ctRvImK8dIgg7IajA4eXPheUAXPwPmO6ZuYPV+ytIYzmKGalsYMDMDweQrWVKOf0gLEMOap8LS0iQ==",
"license": "MIT",
"dependencies": {
"@welshman/dvm": "~0.0.10",
"@welshman/feeds": "~0.0.25",
"@welshman/lib": "~0.0.24",
"@welshman/net": "~0.0.35",
"@welshman/signer": "~0.0.14",
"@welshman/store": "~0.0.12",
"@welshman/util": "~0.0.45",
"@types/throttle-debounce": "^5.0.2",
"@welshman/dvm": "~0.0.11",
"@welshman/feeds": "~0.0.26",
"@welshman/lib": "~0.0.33",
"@welshman/net": "~0.0.41",
"@welshman/signer": "~0.0.16",
"@welshman/store": "~0.0.13",
"@welshman/util": "~0.0.50",
"fuse.js": "^7.0.0",
"idb": "^8.0.0",
"svelte": "^4.2.18",
@@ -4683,69 +4682,70 @@
}
},
"node_modules/@welshman/content": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.0.12.tgz",
"integrity": "sha512-hdrZkHlDKJx8i8FdEJo4NFlBMRJWDkZHBYCBCbx77fcxPN8nJ2yKCl7bmIM51XwEFRrZMOQrmQswvYuOr8h1DQ==",
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@welshman/content/-/content-0.0.14.tgz",
"integrity": "sha512-LwdBJOF5n2EIdmLgn4tJliTKEmTEDZz68zvcmjVhpn34vkc/7lQvHz5pfsQK/CRjPGFsMEdjrSepGXD8v2JAwA==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.0.2",
"@welshman/lib": "~0.0.33",
"nostr-tools": "^2.7.2"
}
},
"node_modules/@welshman/dvm": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/@welshman/dvm/-/dvm-0.0.10.tgz",
"integrity": "sha512-9MlwSmsFeczt+tirKWBenOaWRy1QzcXj0V4Ibc4O2ZdpGkybR3AI79uYLwAI+TG/pkEzHqY+1i7OXfjhBXqlyw==",
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@welshman/dvm/-/dvm-0.0.12.tgz",
"integrity": "sha512-6VqDJzzsfw2UxxIVbJB3+xw6o3qg29+Kkrp1Ei537oXnqmH4W4fGVyoGSdJQQfhEbzdDzT4u/OCUX+z85wfNNA==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.21",
"@welshman/net": "~0.0.25",
"@welshman/util": "~0.0.37",
"@welshman/lib": "~0.0.33",
"@welshman/net": "~0.0.41",
"@welshman/util": "~0.0.50",
"nostr-tools": "^2.7.2"
}
},
"node_modules/@welshman/feeds": {
"version": "0.0.25",
"resolved": "https://registry.npmjs.org/@welshman/feeds/-/feeds-0.0.25.tgz",
"integrity": "sha512-XoSQjOH71n6gFH5Lgm5szSrXTh82a8B608mDakYHdv7XXtGh2Li12kmy6qYFtQtG+pYUx6O+QrWa7UDwmOKdJw==",
"version": "0.0.27",
"resolved": "https://registry.npmjs.org/@welshman/feeds/-/feeds-0.0.27.tgz",
"integrity": "sha512-AMm3v3mJlCYMI8C86/LhF/hNx2dkBqdHVWebTX69NNIK24Srbd67YduOdIeuIAIYlBZj93BMi8lvOL5YY2RWPw==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.24",
"@welshman/util": "~0.0.45"
"@welshman/lib": "~0.0.33",
"@welshman/util": "~0.0.50"
}
},
"node_modules/@welshman/lib": {
"version": "0.0.26",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.26.tgz",
"integrity": "sha512-RMOsRBb0YXKfAupx1bmrNxacv13pheYvdc91DxGYbtvraPeP+B5C0RR9cQzLfFkv0neCpMA318fYXeTk1KeXaQ==",
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.33.tgz",
"integrity": "sha512-otaTKItm0DDR+/IHI5puYo1hU3ssd0R9LTxS+DcIKL6H+0fxtn6OLUmhcHROQukqZ6Jf7l7sfj9MX50KqPicjQ==",
"license": "MIT",
"dependencies": {
"@scure/base": "^1.1.6",
"@types/events": "^3.0.3",
"@types/throttle-debounce": "^5.0.2",
"events": "^3.3.0",
"throttle-debounce": "^5.0.0"
"events": "^3.3.0"
}
},
"node_modules/@welshman/net": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.36.tgz",
"integrity": "sha512-ZRYC9Hl45bI/kfnKd+DSX/RnbDIA4VhEGUpQTGikqCjfmbWxvN8zr3ajvkUMQHhe95VyKyjpjQKPpkJn9g6+MQ==",
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.43.tgz",
"integrity": "sha512-qcQPl944ArM9+GvwXb0nsWaMKQgsBCKtGvLOWJZs24S+n+8LiW1hmUP71evp5hFoLFTbws/l3OXAML9jO+W7Lw==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.25",
"@welshman/util": "~0.0.45",
"@welshman/lib": "~0.0.33",
"@welshman/util": "~0.0.50",
"isomorphic-ws": "^5.0.0",
"ws": "^8.16.0"
}
},
"node_modules/@welshman/signer": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@welshman/signer/-/signer-0.0.14.tgz",
"integrity": "sha512-aQkKloUpFwtI42hAV7/QpJ0unNouIIG6OXYwBuhFgFDxA3QuE3iVMO/9HYCEpytZkll7+NJ1uWKysMunuy+OeQ==",
"version": "0.0.17",
"resolved": "https://registry.npmjs.org/@welshman/signer/-/signer-0.0.17.tgz",
"integrity": "sha512-mZhOKTmtEgAFI2D0KJrZIX5A6WDnHk1+YwIlzL3FyZwdxYnqg4Hx/MPHtAyZoMVt19iodKeZ+Fis/sLblXsXgg==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.24",
"@welshman/net": "~0.0.35",
"@welshman/util": "~0.0.45",
"@welshman/lib": "~0.0.33",
"@welshman/net": "~0.0.41",
"@welshman/util": "~0.0.50",
"nostr-tools": "^2.7.2"
},
"peerDependencies": {
@@ -4753,23 +4753,23 @@
}
},
"node_modules/@welshman/store": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@welshman/store/-/store-0.0.12.tgz",
"integrity": "sha512-69ONyeKOIG0Ba1tEoRPW4JnRaag7yargRS2WhXtyYsbfvQErivWBWLgM4wJz9Qovd+RxljssUswJfDHnZ2FCNQ==",
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@welshman/store/-/store-0.0.14.tgz",
"integrity": "sha512-6y5c+/5yLcRGbVr7doOys0KJH/dnxCjjrUm0iQIvp/JEdSbGKcNhPjfWqs2mnh51PZIF9UlR9eDSjlE0nUxfgg==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.23",
"@welshman/util": "~0.0.42",
"@welshman/lib": "~0.0.33",
"@welshman/util": "~0.0.50",
"svelte": "^4.2.18"
}
},
"node_modules/@welshman/util": {
"version": "0.0.45",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.45.tgz",
"integrity": "sha512-6ktWY/LQsBqVYN+PIfT7Aob48QDf4XjXOI1aDJ7tsewaG4FKuMDy9rfHE7/FjVMGhYrKsugaL2GBVFcS4UfRTg==",
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.52.tgz",
"integrity": "sha512-w17nJ9T8mhwy010WnSjGzRn9kPerZvtG6Ay5fGHw13ZC0hnOD8fkWi85r4/sI+FbCaMLAeKM57P9XD8rIkOfpw==",
"license": "MIT",
"dependencies": {
"@welshman/lib": "~0.0.24",
"@welshman/lib": "~0.0.33",
"nostr-tools": "^2.7.2"
}
},
+11 -13
View File
@@ -1,6 +1,6 @@
{
"name": "flotilla",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -56,18 +56,17 @@
"@tiptap/extension-text": "^2.6.6",
"@tiptap/suggestion": "^2.6.4",
"@types/qrcode": "^1.5.5",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "~0.0.27",
"@welshman/content": "~0.0.12",
"@welshman/dvm": "~0.0.10",
"@welshman/feeds": "~0.0.25",
"@welshman/lib": "~0.0.26",
"@welshman/net": "~0.0.36",
"@welshman/signer": "~0.0.14",
"@welshman/store": "~0.0.12",
"@welshman/util": "~0.0.45",
"@welshman/app": "~0.0.34",
"@welshman/content": "~0.0.14",
"@welshman/dvm": "~0.0.12",
"@welshman/feeds": "~0.0.27",
"@welshman/lib": "~0.0.33",
"@welshman/net": "~0.0.43",
"@welshman/signer": "~0.0.17",
"@welshman/store": "~0.0.14",
"@welshman/util": "~0.0.52",
"daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5",
@@ -80,7 +79,6 @@
"nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"qrcode": "^1.5.4",
"svelte-tiptap": "^1.1.3",
"throttle-debounce": "^5.0.2"
"svelte-tiptap": "^1.1.3"
}
}
+155 -75
View File
@@ -1,5 +1,5 @@
import {get} from "svelte/store"
import {ctx, uniq, sleep, chunk, equals, choice} from "@welshman/lib"
import {ctx, sample, uniq, sleep, chunk, equals, choice} from "@welshman/lib"
import {
DELETE,
PROFILE,
@@ -8,6 +8,12 @@ import {
FOLLOWS,
REACTION,
AUTH_JOIN,
GROUP_JOIN,
GROUP_LEAVE,
GROUP_CREATE,
GROUP_EDIT_META,
GROUPS,
COMMENT,
isSignedEvent,
createEvent,
displayProfile,
@@ -15,16 +21,16 @@ import {
makeList,
addToListPublicly,
removeFromListByPredicate,
getTag,
getListTags,
getRelayTags,
isShareableRelayUrl,
getRelayTagValues,
} from "@welshman/util"
import type {TrustedEvent, EventTemplate, List} from "@welshman/util"
import type {SubscribeRequestWithHandlers, Subscription} from "@welshman/net"
import {PublishStatus, AuthStatus, SocketStatus, SubscriptionEvent} from "@welshman/net"
import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {PublishStatus, AuthStatus, SocketStatus} from "@welshman/net"
import {Nip59, makeSecret, stamp, Nip46Broker} from "@welshman/signer"
import type {Nip46Handler} from "@welshman/signer"
import {
pubkey,
signer,
@@ -46,19 +52,20 @@ import {
nip44EncryptToSelf,
loadRelay,
addSession,
nip46Perms,
subscribe,
clearStorage,
dropSession,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {
COMMENT,
tagRoom,
PROTECTED,
userMembership,
MEMBERSHIPS,
INDEXER_RELAYS,
NIP46_PERMS,
loadMembership,
loadSettings,
getDefaultPubkeys,
getMembershipUrls,
userRoomsByUrl,
} from "@app/state"
// Utils
@@ -91,76 +98,92 @@ export const makeIMeta = (url: string, data: Record<string, string>) => [
...Object.entries(data).map(([k, v]) => [k, v].join(" ")),
]
export const subscribePersistent = (request: SubscribeRequestWithHandlers) => {
let sub: Subscription
let done = false
export const getThunkError = async (thunk: Thunk) => {
const result = await thunk.result
const [{status, message}] = Object.values(result) as any
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()
if (status !== PublishStatus.Success) {
return message
}
}
// Log in
export const loginWithNip46 = async (token: string, handler: Nip46Handler) => {
const secret = makeSecret()
const broker = Nip46Broker.get({secret, handler})
const result = await broker.connect(token, nip46Perms)
export const loginWithNip46 = async ({
relays,
signerPubkey,
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()
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
}
// Log out
export const logout = async () => {
const $pubkey = pubkey.get()
if ($pubkey) {
dropSession($pubkey)
}
await clearStorage()
localStorage.clear()
}
// Loaders
export const loadUserData = (
pubkey: string,
request: Partial<SubscribeRequestWithHandlers> = {},
) => {
const promise = Promise.all([
loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request),
loadSettings(pubkey, request),
loadProfile(pubkey, request),
loadFollows(pubkey, request),
loadMutes(pubkey, request),
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, request),
loadMembership(pubkey, request),
loadSettings(pubkey, request),
loadProfile(pubkey, request),
loadFollows(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 () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
await sleep(300)
const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey)
loadProfile(pubkey)
loadFollows(pubkey)
loadMutes(pubkey)
loadMembership(pubkey, {relays})
loadProfile(pubkey, {relays})
loadFollows(pubkey, {relays})
loadMutes(pubkey, {relays})
}
}
})
@@ -185,10 +208,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
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 relays = uniq([...ctx.app.router.FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -196,7 +246,7 @@ export const addSpaceMembership = 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 event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([
@@ -208,17 +258,21 @@ export const removeSpaceMembership = async (url: string) => {
return publishThunk({event, relays})
}
export const addRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS})
const event = await addToListPublicly(list, tagRoom(room, url)).reconcile(nip44EncryptToSelf)
export const addRoomMembership = async (url: string, room: string, name: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
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)])
return publishThunk({event, relays})
}
export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: MEMBERSHIPS})
const pred = (t: string[]) => equals(tagRoom(room, url), t)
const list = get(userMembership) || makeList({kind: GROUPS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([
url,
@@ -247,7 +301,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
url,
...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(),
...getMembershipUrls(userMembership.get()),
...userRoomsByUrl.get().keys(),
],
})
}
@@ -268,7 +322,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
relays: [
...INDEXER_RELAYS,
...ctx.app.router.FromUser().getUrls(),
...getMembershipUrls(userMembership.get()),
...userRoomsByUrl.get().keys(),
],
})
}
@@ -294,7 +348,11 @@ export const checkRelayAccess = async (url: string, claim = "") => {
result[url].message?.replace(/^.*: /, "") ||
"join request rejected"
return `Failed to join relay (${message})`
// 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})`
}
}
}
@@ -330,10 +388,15 @@ export const checkRelayAuth = async (url: string, timeout = 3000) => {
}
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) {
const error = await check(url)
const error = await check()
if (error) {
return error
@@ -365,14 +428,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 = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeReaction = ({event, content, tags = []}: ReactionParams) =>
createEvent(REACTION, {content, tags: [...tags, ...tagReactionTo(event)]})
export const makeReaction = ({event, content}: ReactionParams) => {
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[]}) =>
publishThunk({event: makeReaction(params), relays})
@@ -401,19 +487,13 @@ export const makeComment = ({event, content, tags = []}: ReplyParams) => {
if (seenRoots.size === 0) {
tags.push(["K", String(event.kind)])
tags.push(["E", event.id])
} else {
tags.push(["k", String(event.kind)])
tags.push(["e", event.id])
}
tags.push(["k", String(event.kind)])
tags.push(["e", event.id])
return createEvent(COMMENT, {content, tags})
}
export const publishComment = ({relays, ...params}: ReplyParams & {relays: string[]}) =>
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 Toast from "@app/components/Toast.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>
<div class="flex h-screen overflow-hidden">
+11 -15
View File
@@ -1,19 +1,24 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Readable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {createEditor, EditorContent} from "svelte-tiptap"
import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {getEditorOptions, getEditorTags} from "@lib/editor"
import {getPubkeyHints} from "@app/commands"
export let onSubmit
export let onSubmit: any
export let content = ""
export let editor = createEditor(
getEditorOptions({
submit,
getPubkeyHints,
submitOnEnter: true,
autofocus: !isMobile,
}),
)
let editor: Readable<Editor>
const submit = () => {
function submit() {
if ($loading) return
onSubmit({
@@ -27,15 +32,6 @@
$: loading = $editor?.storage.fileUpload.loading
onMount(() => {
editor = createEditor(
getEditorOptions({
submit,
getPubkeyHints,
submitOnEnter: true,
autofocus: !isMobile,
}),
)
$editor.commands.setContent(content)
})
</script>
@@ -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">
import {readable} from "svelte/store"
import {hash} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
@@ -12,104 +11,89 @@
import {isMobile} from "@lib/html"
import LongPress from "@lib/components/LongPress.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 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 ChannelConversation from "@app/components/ChannelConversation.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.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 {pushDrawer, pushModal} from "@app/modal"
import {pushModal} from "@app/modal"
export let url, room
export let event: TrustedEvent
export let replyTo: any = undefined
export let showPubkey = false
export let isHead = false
export let inert = false
const thunk = $thunks[event.id]
const profile = deriveProfile(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 onClick = () => {
const root = $rootEvent || event
pushDrawer(ChannelConversation, {url, room, event: root})
}
const reply = () => replyTo(event)
const onLongPress = () => pushModal(ChannelMessageMenuMobile, {url, event})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const onReactionClick = (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) {
publishDelete({relays: [url], event: reaction})
} else {
publishReaction({
event,
content,
relays: [url],
tags: [tagRoom(room, url)],
})
publishReaction({event, content, relays: [url]})
}
}
</script>
<LongPress
on:click={isMobile || inert ? null : onClick}
data-event={event.id}
onLongPress={inert ? null : onLongPress}
class="group relative flex w-full flex-col gap-1 p-2 text-left transition-colors {inert
? 'hover:bg-base-300'
: ''}">
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
<Link external href={pubkeyLink(event.pubkey)} class="flex items-start">
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={10} />
</Link>
<Button on:click={openProfile} class="flex items-start">
<Avatar src={$profile?.picture} class="border border-solid border-base-content" size={8} />
</Button>
{:else}
<div class="w-10 min-w-10 max-w-10" />
<div class="w-8 min-w-8 max-w-8" />
{/if}
<div class="-mt-1 min-w-0 flex-grow pr-1">
<div class="min-w-0 flex-grow pr-1">
{#if showPubkey}
<div class="flex items-center gap-2">
<Link
external
href={pubkeyLink(event.pubkey)}
class="text-sm font-bold"
style="color: {colorValue}">
<Button on:click={openProfile} class="text-sm font-bold" style="color: {colorValue}">
{$profileDisplay}
</Link>
</Button>
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span>
</div>
{/if}
<div class="text-sm">
<Content {event} />
<Content {event} quoteProps={{minimal: true, relays: [url]}} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
{/if}
</div>
</div>
</div>
<div class="row-2 ml-12">
{#if !isHead}
<ReplySummary relays={[url]} {event} on:click={onClick} />
{/if}
<ReactionSummary relays={[url]} {event} {onReactionClick} />
<div class="row-2 ml-10 mt-1">
<ReactionSummary relays={[url]} {event} {onReactionClick} reactionClass="tooltip-right" />
</div>
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}
on:click|stopPropagation>
<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} />
</button>
</LongPress>
@@ -1,19 +1,17 @@
<script lang="ts">
import {noop} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import Icon from "@lib/components/Icon.svelte"
import {tagRoom} from "@app/state"
import {publishReaction} from "@app/commands"
export let url, room, event
// Tell svelte-check to shut up
noop(room)
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({
event,
relays: [url],
content: emoji.unicode,
tags: [tagRoom(room, url)],
})
publishReaction({event, relays: [url], content: emoji.unicode})
</script>
<EmojiButton {onEmoji} class="btn join-item btn-xs">
@@ -5,31 +5,20 @@
import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import ChannelConversation from "@app/components/ChannelConversation.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import {publishReaction} from "@app/commands"
import {pushModal, pushDrawer} from "@app/modal"
import {tagRoom} from "@app/state"
import {pushModal} from "@app/modal"
export let url
export let room
export let event
const onEmoji = (emoji: NativeEmoji) => {
history.back()
publishReaction({
event,
relays: [url],
content: emoji.unicode,
tags: [tagRoom(room, url)],
})
publishReaction({event, relays: [url], content: emoji.unicode})
}
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 showDelete = () => pushModal(ConfirmDelete, {url, event})
@@ -40,10 +29,6 @@
<Icon size={4} icon="smile-circle" />
Send Reaction
</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}>
<Icon size={4} icon="code-2" />
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">
import {onMount} from "svelte"
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 {createEvent, DIRECT_MESSAGE, INBOX_RELAYS} from "@welshman/util"
import {
@@ -29,10 +32,11 @@
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ProfileList from "@app/components/ProfileList.svelte"
import ChatMessage from "@app/components/ChatMessage.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 {sendWrapped} from "@app/commands"
@@ -52,6 +56,15 @@
const showMembers = () =>
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) => {
// Remove p tags since they result in forking the conversation
const tags = [...params.tags.filter(nthNe(0, "p")), ...remove($pubkey!, pubkeys).map(tagPubkey)]
@@ -64,6 +77,7 @@
}
let loading = true
let editor: Readable<Editor>
let elements: Element[] = []
$: {
@@ -112,10 +126,11 @@
<div slot="title" class="flex flex-col gap-1 sm:flex-row sm:gap-2">
{#if others.length === 1}
{@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} />
<ProfileName {pubkey} />
</Link>
</Button>
{:else}
<div class="flex items-center gap-2">
<ProfileCircles pubkeys={others} size={5} />
@@ -170,7 +185,7 @@
{#if type === "date"}
<Divider>{value}</Divider>
{:else}
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} />
<ChatMessage event={assertEvent(value)} {pubkeys} {showPubkey} {replyTo} />
{/if}
{/each}
<p
@@ -185,5 +200,5 @@
<slot name="info" />
</p>
</div>
<ChatCompose {onSubmit} />
<ChatCompose bind:editor {onSubmit} />
</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">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {remove, assoc} from "@welshman/lib"
import {remove} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {pubkey, loadInboxRelaySelections} from "@welshman/app"
import {fade} from "@lib/transition"
@@ -10,7 +10,7 @@
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath} from "@app/routes"
import {CHAT_FILTERS, deriveNotification} from "@app/notifications"
import {notifications} from "@app/notifications"
export let id: string
export let pubkeys: string[]
@@ -19,7 +19,6 @@
const others = remove($pubkey!, pubkeys)
const active = $page.params.chat === id
const path = makeChatPath(pubkeys)
const notification = deriveNotification(path, CHAT_FILTERS.map(assoc("authors", pubkeys)))
onMount(() => {
for (const pk of others) {
@@ -47,7 +46,7 @@
</p>
{/if}
</div>
{#if !active && $notification}
{#if !active && $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary" transition:fade />
{/if}
</div>
+31 -26
View File
@@ -11,25 +11,27 @@
} from "@welshman/app"
import {isMobile} from "@lib/html"
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 LongPress from "@lib/components/LongPress.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ReplySummary from "@app/components/ReplySummary.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.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 {pushModal} from "@app/modal"
export let event: TrustedEvent
export let replyTo: any = undefined
export let pubkeys: string[]
export let showPubkey = false
const thunk = $thunks[event.id]
const isOwn = event.pubkey === $pubkey
const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey)
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
@@ -41,6 +43,8 @@
await sendWrapped({template, pubkeys})
}
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys})
const togglePopover = () => {
@@ -59,14 +63,15 @@
<ThunkStatus {thunk} class="mt-1" />
{/if}
<div
data-event={event.id}
class="group chat flex items-center justify-end gap-1 px-2"
class:chat-start={event.pubkey !== $pubkey}
class:flex-row-reverse={event.pubkey !== $pubkey}
class:chat-end={event.pubkey === $pubkey}>
class:chat-start={!isOwn}
class:flex-row-reverse={!isOwn}
class:chat-end={isOwn}>
<Tippy
bind:popover
component={ChatMessageMenu}
props={{event, pubkeys, popover}}
props={{event, pubkeys, popover, replyTo}}
params={{
interactive: true,
trigger: "manual",
@@ -85,27 +90,28 @@
<Icon icon="menu-dots" size={4} />
</button>
</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
class="bg-alt chat-bubble mx-1 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
onLongPress={showMobileMenu}>
{#if showPubkey && event.pubkey !== $pubkey}
{#if showPubkey}
<div class="flex items-center gap-2">
<Link external href={pubkeyLink(event.pubkey)} class="flex items-center gap-1">
<Avatar
src={$profile?.picture}
class="border border-solid border-base-content"
size={4} />
<div class="flex items-center gap-2">
<Link
external
href={pubkeyLink(event.pubkey)}
class="text-sm font-bold"
style="color: {colorValue}">
{$profileDisplay}
</Link>
</div>
</Link>
{#if !isOwn}
<Button on:click={openProfile} class="flex items-center gap-1">
<Avatar
src={$profile?.picture}
class="border border-solid border-base-content"
size={4} />
<div class="flex items-center gap-2">
<Button
on:click={openProfile}
class="text-sm font-bold"
style="color: {colorValue}">
{$profileDisplay}
</Button>
</div>
</Button>
{/if}
<span class="text-xs opacity-50">{formatTimestampAsTime(event.created_at)}</span>
</div>
{/if}
@@ -114,8 +120,7 @@
</div>
</LongPress>
<div class="row-2 z-feature -mt-1 ml-4">
<ReplySummary {event} />
<ReactionSummary {event} {onReactionClick} />
<ReactionSummary {event} {onReactionClick} noTooltip />
</div>
</div>
</div>
@@ -8,6 +8,9 @@
export let event
export let pubkeys
export let popover
export let replyTo
const reply = () => replyTo(event)
const showInfo = () => {
popover.hide()
@@ -17,6 +20,11 @@
<div class="join border border-solid border-neutral text-xs">
<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}>
<Icon size={4} icon="code-2" />
</Button>
+42 -16
View File
@@ -4,7 +4,7 @@
import {
parse,
truncate,
render as renderParsed,
renderAsHtml,
isText,
isTopic,
isCode,
@@ -36,6 +36,7 @@
export let showEntire = false
export let hideMedia = false
export let expandMode = "block"
export let quoteProps: Record<string, any> = {}
export let depth = 0
const fullContent = parse(event)
@@ -44,18 +45,38 @@
showEntire = true
}
const isBoundary = (i: number) => {
const isBlock = (i: number) => {
const parsed = fullContent[i]
if (!parsed || isNewline(parsed)) return true
if (isText(parsed)) return parsed.value.match(/^\s+$/)
if (!parsed || hideMedia) return false
if (isLink(parsed) && $userSettingValues.show_media && isStartOrEnd(i)) {
return true
}
if ((isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i) && depth < 1) {
return true
}
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 = () => {
warning = null
@@ -92,15 +113,17 @@
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
{#each shortContent as parsed, i}
{#if isNewline(parsed)}
<ContentNewline value={parsed.value} />
<ContentNewline value={parsed.value.slice(isBlock(i - 1) ? 1 : 0)} />
{:else if isTopic(parsed)}
<ContentTopic value={parsed.value} />
{: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)}
<ContentToken value={parsed.value} />
{:else if isLink(parsed)}
{#if isStartOrEnd(i) && !hideMedia && $userSettingValues.show_media}
{#if isBlock(i)}
<ContentLinkBlock value={parsed.value} />
{:else}
<ContentLinkInline value={parsed.value} />
@@ -108,10 +131,10 @@
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} />
{:else if isEvent(parsed) || isAddress(parsed)}
{#if isStartOrEnd(i) && depth < 2 && !hideMedia}
<ContentQuote value={parsed.value} {depth} {event}>
{#if isBlock(i)}
<ContentQuote {...quoteProps} value={parsed.value} {depth} {event}>
<div slot="note-content" let:event>
<svelte:self {hideMedia} {event} depth={depth + 1} />
<svelte:self {quoteProps} {hideMedia} {event} depth={depth + 1} />
</div>
</ContentQuote>
{:else}
@@ -123,16 +146,19 @@
</Link>
{/if}
{:else if isEllipsis(parsed) && expandInline}
{@html renderParsed(parsed)}
{@html renderAsHtml(parsed)}
<button type="button" class="text-sm underline"> Read more </button>
{:else}
{@html renderParsed(parsed)}
{@html renderAsHtml(parsed)}
{/if}
{/each}
</div>
{#if expandBlock}
<div class="relative z-feature -mt-6 flex justify-center bg-gradient-to-t from-base-100 py-2">
<button type="button" class="btn" on:click|stopPropagation|preventDefault={expand}>
<div class="relative z-feature -mt-6 flex justify-center py-2">
<button
type="button"
class="btn btn-neutral"
on:click|stopPropagation|preventDefault={expand}>
See more
</button>
</div>
+4 -2
View File
@@ -3,6 +3,8 @@
export let isBlock
</script>
<code class="link-content w-full" class:block={isBlock}>
{value}
<code
class="w-full overflow-auto whitespace-pre rounded bg-neutral px-1 text-neutral-content"
class:block={isBlock}>
{value.trim()}
</code>
+1 -1
View File
@@ -54,7 +54,7 @@
{/if}
</div>
{: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}
</p>
{/await}
+7 -4
View File
@@ -1,14 +1,17 @@
<script lang="ts">
import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import {pubkeyLink} from "@app/state"
import Button from "@lib/components/Button.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
export let value
const profile = deriveProfile(value.pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey: value.pubkey})
</script>
<Link external href={pubkeyLink(value.pubkey)} class="link-content">
<Button on:click={openProfile} class="link-content">
@{displayProfile($profile)}
</Link>
</Button>
+83 -24
View File
@@ -1,46 +1,105 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {goto} from "$app/navigation"
import {ctx, nthEq} from "@welshman/lib"
import {Address} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import {tracker, repository} from "@welshman/app"
import {Address, DIRECT_MESSAGE, MESSAGE, THREAD} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import {deriveEvent, entityLink, MESSAGE, THREAD} from "@app/state"
import {makeThreadPath} from "@app/routes"
import {deriveEvent, entityLink, ROOM} from "@app/state"
import {makeThreadPath, makeRoomPath} from "@app/routes"
export let value
export let event
export let depth = 0
export let relays: string[] = []
export let minimal = false
const {id, identifier, kind, pubkey, relays: relayHints = []} = value
const addr = new Address(kind, pubkey, identifier)
const idOrAddress = id || addr.toString()
const relays = ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls()
const quote = deriveEvent(idOrAddress, relays)
const entity = id ? nip19.neventEncode({id, relays}) : addr.toNaddr()
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
const mergedRelays = [
...relays,
...ctx.app.router.Quote(event, idOrAddress, relayHints).getUrls(),
]
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 url = e.tags.find(nthEq(0, "~"))?.[2]
const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
if (!url) return
if ([MESSAGE, THREAD].includes(e.kind)) return makeThreadPath(url, e.id)
if (element) {
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]
const id = e.tags.find(nthEq(0, "E"))?.[1]
setTimeout(() => {
element.style = "transition-property: all; transition-duration: 300ms;"
}, 800)
if (!id || !kind) return
if ([MESSAGE, THREAD].includes(parseInt(kind))) return makeThreadPath(url, id)
setTimeout(() => {
element.style = ""
}, 800 + 400)
}
return Boolean(element)
}
// If we found this event on a relay that the user is a member of, redirect internally
$: 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>
<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}
<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} />
</NoteCard>
{:else}
@@ -48,4 +107,4 @@
<Spinner loading>Loading event...</Spinner>
</div>
{/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>
+2
View File
@@ -11,6 +11,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import {PROTECTED} from "@app/state"
import {getPubkeyHints} from "@app/commands"
import {getEditorOptions, getEditorTags} from "@lib/editor"
import {pushToast} from "@app/toast"
@@ -46,6 +47,7 @@
["start", dateToSeconds(start).toString()],
["end", dateToSeconds(end).toString()],
...getEditorTags($editor),
PROTECTED,
],
})
+7 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import Icon from "@lib/components/Icon.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
@@ -8,10 +9,11 @@
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 json = JSON.stringify(event, null, 2)
const copyId = () => clip(note1)
const copyLink = () => clip(nevent1)
const copyPubkey = () => clip(npub1)
const copyJson = () => clip(json)
</script>
@@ -22,11 +24,11 @@
<div slot="info">The full details of this event are shown below.</div>
</ModalHeader>
<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">
<Icon icon="file" />
<input type="text" class="ellipsize min-w-0 grow" value={note1} />
<Button on:click={copyId} class="flex items-center">
<input type="text" class="ellipsize min-w-0 grow" value={nevent1} />
<Button on:click={copyLink} class="flex items-center">
<Icon icon="copy" />
</Button>
</label>
+4 -6
View File
@@ -1,17 +1,13 @@
<script lang="ts">
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"
export let event
const timeFmt = new Intl.DateTimeFormat(getLocale(), {timeStyle: "short"})
$: meta = fromPairs(event.tags) as Record<string, string>
$: end = parseInt(meta.end)
$: start = parseInt(meta.start)
$: startDate = secondsToDate(start)
$: endDate = secondsToDate(end)
$: startDateDisplay = formatTimestampAsDate(start)
$: endDateDisplay = formatTimestampAsDate(end)
$: isSingleDay = startDateDisplay === endDateDisplay
@@ -21,6 +17,8 @@
<span>{meta.title || meta.name}</span>
<div class="flex items-center gap-2 text-sm">
<Icon icon="clock-circle" size={4} />
{timeFmt.format(startDate)}{isSingleDay ? timeFmt.format(endDate) : formatTimestamp(end)}
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
</div>
+33 -12
View File
@@ -1,8 +1,17 @@
<script lang="ts">
import {session} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.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 {pushModal} from "@app/modal"
const back = () => history.back()
const startEject = () => pushModal(ProfileEject)
</script>
<div class="column gap-4">
@@ -10,21 +19,33 @@
<div slot="title">What is a private key?</div>
</ModalHeader>
<p>
Most software keeps track of users by giving them a username and password. This gives the
service
<strong>total control</strong> over their users, allowing them to ban them at any time, or sell their
activity.
Most online services keep track of users by giving them a username and password. This gives the
service <strong>total control</strong> over their users, allowing them to ban them at any time, or
sell their activity.
</p>
<p>
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
<strong>public key</strong>, which acts as your user id, and a <strong>private key</strong> which
allows you to authenticate any message you send.
<strong>public key</strong>, which acts as your user id, and a
<strong>private key</strong> which allows you to prove your identity.
</p>
<p>
It's very important to keep private keys safe, but this can sometimes be confusing for
newcomers. This is why {PLATFORM_NAME} supports <strong>remote signer</strong> login. These services
can store your keys securely for you, giving you access using a username and password.
</p>
<Button class="btn btn-primary" on:click={() => history.back()}>Got it</Button>
{#if $session?.email}
<p>
It's very important to keep private keys safe, but this can sometimes be tricky, which is why {PLATFORM_NAME}
supports a traditional account-based login for new users.
</p>
<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>
+47 -26
View File
@@ -9,8 +9,9 @@
import SignUp from "@app/components/SignUp.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte"
import LogInBunker from "@app/components/LogInBunker.svelte"
import LogInPassword from "@app/components/LogInPassword.svelte"
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 {loadUserData} from "@app/commands"
import {setChecked} from "@app/notifications"
@@ -18,28 +19,27 @@
const signUp = () => pushModal(SignUp)
const withLoading =
(cb: (...args: any[]) => any) =>
(s: string, cb: (...args: any[]) => any) =>
async (...args: any[]) => {
loading = true
loading = s
try {
await cb(...args)
} finally {
loading = false
loading = undefined
}
}
const onSuccess = async (session: Session, relays: string[] = []) => {
addSession(session)
await loadUserData(session.pubkey, {relays})
addSession(session)
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
}
const loginWithNip07 = withLoading(async () => {
const loginWithNip07 = withLoading("nip07", async () => {
const pubkey = await getNip07()?.getPublicKey()
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 pubkey = await signer.getPubkey()
@@ -66,19 +66,18 @@
}
})
const loginWithPassword = () => pushModal(LogInPassword)
const loginWithBunker = () => pushModal(LogInBunker)
let loading = false
let signers: any[] = []
let hasNativeSigner = Boolean(getNip07())
let loading: string | undefined
$: hasSigner = getNip07() || signers.length > 0
onMount(async () => {
if (Capacitor.isNativePlatform()) {
signers = await getNip55()
if (signers.length > 0) {
hasNativeSigner = true
}
}
})
</script>
@@ -92,7 +91,7 @@
</p>
{#if getNip07()}
<Button disabled={loading} on:click={loginWithNip07} class="btn btn-primary">
{#if loading}
{#if loading === "nip07"}
<span class="loading loading-spinner mr-3" />
{:else}
<Icon icon="widget" />
@@ -101,8 +100,8 @@
</Button>
{/if}
{#each signers as app}
<Button disabled={loading} class="btn btn-primary" on:click={() => loginWithSigner(app)}>
{#if loading}
<Button disabled={loading} class="btn btn-primary" on:click={() => loginWithNip55(app)}>
{#if loading === "nip55"}
<span class="loading loading-spinner mr-3" />
{:else}
<img src={app.iconUrl} alt={app.name} width="20" height="20" />
@@ -110,21 +109,43 @@
Log in with {app.name}
</Button>
{/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
disabled={loading}
on:click={loginWithBunker}
class="btn {hasNativeSigner ? 'btn-neutral' : 'btn-primary'}">
class="btn {hasSigner || BURROW_URL ? 'btn-neutral' : 'btn-primary'}">
<Icon icon="cpu" />
Log in with Remote Signer
</Button>
<Link
external
disabled={loading}
href="https://nostrapps.com#signers"
class="btn {hasNativeSigner ? '' : 'btn-neutral'}">
<Icon icon="compass" />
Browse Signer Apps
</Link>
{#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
external
disabled={loading}
href="https://nostrapps.com#signers"
class="btn {hasSigner || BURROW_URL ? '' : 'btn-neutral'}">
<Icon icon="compass" />
Browse Signer Apps
</Link>
{/if}
<div class="text-sm">
Need an account?
<Button class="link" on:click={signUp}>Register instead</Button>
+56 -34
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {onDestroy} from "svelte"
import {Nip46Broker} from "@welshman/signer"
import {nip46Perms, addSession} from "@welshman/app"
import {onMount, onDestroy} from "svelte"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import {addSession} from "@welshman/app"
import {slideAndFade} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
@@ -15,29 +15,24 @@
import {pushModal, clearModals} from "@app/modal"
import {setChecked} from "@app/notifications"
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 init = Nip46Broker.initiate({
perms: nip46Perms,
url: PLATFORM_URL,
name: PLATFORM_NAME,
relays: SIGNER_RELAYS,
image: PLATFORM_LOGO,
abortController,
})
const broker = Nip46Broker.get({clientSecret, relays: SIGNER_RELAYS})
const back = () => history.back()
const onSubmit = async () => {
const {pubkey, token, relays} = Nip46Broker.parseBunkerLink(bunker)
const {signerPubkey, connectSecret, relays} = broker.parseBunkerUrl(input)
if (loading) {
return
}
if (!pubkey || relays.length === 0) {
if (!signerPubkey || relays.length === 0) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that's an invalid bunker link.",
@@ -47,16 +42,16 @@
loading = true
try {
if (!(await loginWithNip46(token, {pubkey, relays}))) {
const success = await loginWithNip46({connectSecret, clientSecret, signerPubkey, relays})
if (success) {
abortController.abort()
} else {
return pushToast({
theme: "error",
message: "Something went wrong, please try again!",
})
}
abortController.abort()
await loadUserData(pubkey)
} finally {
loading = false
}
@@ -64,21 +59,48 @@
clearModals()
}
let bunker = ""
let url = ""
let input = ""
let loading = false
init.result.then(async pubkey => {
if (pubkey) {
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
addSession({
pubkey,
method: "nip46",
secret: init.clientSecret,
handler: {pubkey, relays: SIGNER_RELAYS},
})
const userPubkey = await broker.getPublicKey()
await loadUserData(pubkey)
await loadUserData(userPubkey)
addSession({
method: "nip46",
pubkey: userPubkey,
secret: clientSecret,
handler: {
pubkey: response.event.pubkey,
relays: SIGNER_RELAYS,
},
})
setChecked("*")
clearModals()
@@ -97,16 +119,16 @@
Connect your signer by scanning the QR code below or pasting a bunker link.
</div>
</ModalHeader>
{#if !loading}
{#if !loading && url}
<div class="w-xs m-auto" out:slideAndFade>
<QRCode code={init.nostrconnect} />
<QRCode code={url} />
</div>
{/if}
<Field>
<p slot="label">Bunker Link*</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="cpu" />
<input disabled={loading} bind:value={bunker} class="grow" placeholder="bunker://" />
<input disabled={loading} bind:value={input} class="grow" placeholder="bunker://" />
</label>
<p slot="info">
A login link provided by a nostr signing app.
@@ -118,7 +140,7 @@
<Icon icon="alt-arrow-left" />
Go back
</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>
<Icon icon="alt-arrow-right" />
</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">
import {clearStorage} 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 {logout} from "@app/commands"
const back = () => history.back()
const logout = async () => {
const doLogout = async () => {
loading = true
try {
await clearStorage()
localStorage.clear()
await logout()
window.location.href = "/"
} catch (e) {
loading = false
}
window.location.reload()
}
let loading = false
</script>
<form class="column gap-4" on:submit|preventDefault={logout}>
<form class="column gap-4" on:submit|preventDefault={doLogout}>
<ModalHeader>
<div slot="title">Are you sure you want<br />to log out?</div>
</ModalHeader>
+1 -1
View File
@@ -39,7 +39,7 @@
<div slot="info">Learn about {PLATFORM_NAME} and support the developer</div>
</CardButton>
</Link>
<Button on:click={logout} class="btn btn-error">
<Button on:click={logout} class="btn btn-neutral">
<Icon icon="exit" /> Log Out
</Button>
</div>
+41 -44
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import {displayRelayUrl} from "@welshman/util"
import {displayRelayUrl, GROUP_META} from "@welshman/util"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -15,22 +15,22 @@
import RoomCreate from "@app/components/RoomCreate.svelte"
import MenuSpaceRoomItem from "@app/components/MenuSpaceRoomItem.svelte"
import {
getMembershipRoomsByUrl,
getMembershipUrls,
userRoomsByUrl,
hasMembershipUrl,
userMembership,
memberships,
roomsByUrl,
GENERAL,
deriveUserRooms,
deriveOtherRooms,
} 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 {makeSpacePath} from "@app/routes"
export let url
const threadsPath = makeSpacePath(url, "threads")
const threadsNotification = deriveNotification(threadsPath, THREAD_FILTERS, url)
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const openMenu = () => {
showMenu = true
@@ -59,17 +59,16 @@
let replaceState = false
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)
onMount(async () => {
replaceState = Boolean(element.closest(".drawer"))
pullConservatively({relays: [url], filters: [{kinds: [GROUP_META]}]})
})
</script>
<div bind:this={element}>
<SecondaryNavSection>
<SecondaryNavSection class="max-h-screen">
<div>
<SecondaryNavItem class="w-full !justify-between" on:click={openMenu}>
<strong>{displayRelayUrl(url)}</strong>
@@ -93,13 +92,13 @@
</Button>
</li>
<li>
{#if getMembershipUrls($userMembership).includes(url)}
{#if $userRoomsByUrl.has(url)}
<Button on:click={leaveSpace} class="text-error">
<Icon icon="exit" />
Leave Space
</Button>
{:else}
<Button on:click={joinSpace}>
<Button on:click={joinSpace} class="bg-primary text-primary-content">
<Icon icon="login-2" />
Join Space
</Button>
@@ -109,37 +108,35 @@
</Popover>
{/if}
</div>
<SecondaryNavItem href={makeSpacePath(url)}>
<Icon icon="home-smile" /> Home
</SecondaryNavItem>
<SecondaryNavItem href={threadsPath} notification={$threadsNotification}>
<Icon icon="notes-minimalistic" /> Threads
</SecondaryNavItem>
<div class="h-2" />
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
<MenuSpaceRoomItem {url} room={GENERAL} />
{#each rooms as room, i (room)}
<MenuSpaceRoomItem {url} {room} />
{/each}
{#if otherRooms.length > 0}
<div class="h-2" />
<SecondaryNavHeader>
{#if rooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{/if}
{#each otherRooms as room, i (room)}
<SecondaryNavItem href={makeSpacePath(url, room)}>
<Icon icon="hashtag" />
{room}
<div class="flex min-h-0 flex-col gap-1 overflow-auto">
<SecondaryNavItem href={makeSpacePath(url)}>
<Icon icon="home-smile" /> Home
</SecondaryNavItem>
{/each}
<SecondaryNavItem on:click={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
<SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
<Icon icon="notes-minimalistic" /> Threads
</SecondaryNavItem>
<div class="h-2" />
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{#each $userRooms as room, i (room)}
<MenuSpaceRoomItem notify {url} {room} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2" />
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{/if}
{#each $otherRooms as room, i (room)}
<MenuSpaceRoomItem {url} {room} />
{/each}
<SecondaryNavItem on:click={addRoom}>
<Icon icon="add-circle" />
Create room
</SecondaryNavItem>
</div>
</SecondaryNavSection>
</div>
+5 -38
View File
@@ -1,54 +1,21 @@
<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 Button from "@lib/components/Button.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte"
import {checked, getNotification, deriveNotification, THREAD_FILTERS} from "@app/notifications"
import {
userMembership,
getMembershipRoomsByUrl,
deriveEventsForUrl,
MESSAGE,
GENERAL,
} from "@app/state"
import {makeRoomPath, makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
import {pushDrawer} from "@app/modal"
export let url
const path = makeSpacePath(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>
<Button on:click={openMenu} class="btn btn-neutral btn-sm relative md:hidden">
<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" />
{/if}
</Button>
+16 -7
View File
@@ -1,17 +1,26 @@
<script lang="ts">
import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import {makeSpacePath} from "@app/routes"
import {deriveNotification, getRoomFilters} from "@app/notifications"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeRoomPath} from "@app/routes"
import {deriveChannel, channelIsLocked} from "@app/state"
import {notifications} from "@app/notifications"
export let url
export let room
export let notify = false
const path = makeSpacePath(url, room)
const notification = deriveNotification(path, getRoomFilters(room), url)
const path = makeRoomPath(url, room)
const channel = deriveChannel(url, room)
</script>
<SecondaryNavItem href={path} notification={$notification}>
<Icon icon="hashtag" />
{room}
<SecondaryNavItem href={path} notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
{/if}
<div class="min-w-0 overflow-hidden text-ellipsis">
<ChannelName {url} {room} />
</div>
</SecondaryNavItem>
+3 -3
View File
@@ -5,7 +5,7 @@
import CardButton from "@lib/components/CardButton.svelte"
import MenuSpacesItem from "@app/components/MenuSpacesItem.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"
const addSpace = () => pushModal(SpaceAdd)
@@ -15,8 +15,8 @@
{#if PLATFORM_RELAY}
<MenuSpacesItem url={PLATFORM_RELAY} />
<Divider />
{:else if getMembershipUrls($userMembership).length > 0}
{#each getMembershipUrls($userMembership) as url (url)}
{:else if $userRoomsByUrl.size > 0}
{#each $userRoomsByUrl.keys() as url (url)}
<MenuSpacesItem {url} />
{/each}
<Divider />
+2 -2
View File
@@ -5,7 +5,7 @@
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import {makeSpacePath} from "@app/routes"
import {spacesNotifications} from "@app/notifications"
import {notifications} from "@app/notifications"
export let url
@@ -17,7 +17,7 @@
<div slot="icon"><SpaceAvatar {url} /></div>
<div slot="title" class="flex gap-1">
<RelayName {url} />
{#if $spacesNotifications.includes(url)}
{#if $notifications.has(path)}
<div class="relative top-1 h-2 w-2 rounded-full bg-primary" />
{/if}
</div>
+12 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {nip19} from "nostr-tools"
import {ctx} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
@@ -7,9 +8,11 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {entityLink} from "@app/state"
export let event
export let minimal = false
export let hideProfile = false
const relays = ctx.app.router.Event(event).getUrls()
@@ -34,9 +37,16 @@
{:else}
<div class="flex justify-between gap-2">
{#if !hideProfile}
<Profile pubkey={event.pubkey} />
{#if minimal}
@<ProfileName pubkey={event.pubkey} />
{:else}
<Profile pubkey={event.pubkey} />
{/if}
{/if}
<Link external href={entityLink(nevent)} class="text-sm opacity-75">
<Link
external
href={entityLink(nevent)}
class={cx("text-sm opacity-75", {"text-xs": minimal})}>
{formatTimestamp(event.created_at)}
</Link>
</div>
+2 -2
View File
@@ -23,13 +23,13 @@
}
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode})
publishReaction({event, content: emoji.unicode, relays: [url]})
</script>
<NoteCard {event} class="card2 bg-alt">
<Content {event} expandMode="inline" />
<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">
<Icon icon="smile-circle" size={4} />
</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">
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {userProfile} from "@welshman/app"
import Avatar from "@lib/components/Avatar.svelte"
import Divider from "@lib/components/Divider.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import MenuSpaces from "@app/components/MenuSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.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 {deriveNotification, inactiveSpacesNotifications, CHAT_FILTERS} from "@app/notifications"
const chatNotification = deriveNotification("/chat", CHAT_FILTERS)
import {makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
const addSpace = () => pushModal(SpaceAdd)
const showSpacesMenu = () =>
getMembershipUrls($userMembership).length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)
const showSpacesMenu = () => (spacePaths.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd))
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>
<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" />
</PrimaryNavItem>
<Divider />
{#each getMembershipUrls($userMembership) as url (url)}
{#each spaceUrls as url (url)}
<PrimaryNavItemSpace {url} />
{/each}
<PrimaryNavItem title="Add Space" on:click={addSpace} class="tooltip-right">
@@ -47,14 +58,14 @@
class="tooltip-right">
<Avatar src={$userProfile?.picture} class="!h-10 !w-10" />
</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" />
</PrimaryNavItem>
<PrimaryNavItem
title="Messages"
href="/chat"
on:click={openChat}
class="tooltip-right"
notification={$chatNotification}>
notification={$notifications.has("/chat")}>
<Avatar icon="letter" class="!h-10 !w-10" />
</PrimaryNavItem>
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
@@ -73,16 +84,16 @@
<PrimaryNavItem title="Search" href="/people">
<Avatar icon="magnifer" class="!h-10 !w-10" />
</PrimaryNavItem>
<PrimaryNavItem title="Notes" href="/notes">
<PrimaryNavItem title="Notes" on:click={openNotes}>
<Avatar icon="notes-minimalistic" class="!h-10 !w-10" />
</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" />
</PrimaryNavItem>
<PrimaryNavItem
title="Spaces"
on:click={showSpacesMenu}
notification={$inactiveSpacesNotifications.length > 0}>
<PrimaryNavItem title="Spaces" on:click={showSpacesMenu} notification={anySpaceNotifications}>
<Avatar icon="settings-minimalistic" class="!h-10 !w-10" />
</PrimaryNavItem>
</div>
@@ -3,18 +3,17 @@
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
import {makeSpacePath} from "@app/routes"
import {deriveNotification, SPACE_FILTERS} from "@app/notifications"
import {notifications} from "@app/notifications"
export let url
const path = makeSpacePath(url)
const notification = deriveNotification(path, SPACE_FILTERS, url)
</script>
<PrimaryNavItem
title={displayRelayUrl(url)}
href={path}
class="tooltip-right"
notification={$notification}>
notification={$notifications.has(path)}>
<SpaceAvatar {url} />
</PrimaryNavItem>
+9 -6
View File
@@ -9,10 +9,11 @@
displayHandle,
deriveProfileDisplay,
} 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 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
@@ -21,19 +22,21 @@
const handle = deriveHandleForPubkey(pubkey)
const score = deriveUserWotScore(pubkey)
const openProfile = () => pushModal(ProfileDetail, {pubkey})
$: following =
pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey)
</script>
<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} />
</Link>
</Button>
<div class="flex min-w-0 flex-col">
<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}
</Link>
</Button>
<WotScore score={$score} active={following} />
</div>
<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>
+16 -7
View File
@@ -1,20 +1,26 @@
<script lang="ts">
import {onMount} from "svelte"
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 type {TrustedEvent} from "@welshman/util"
import {createFeedController} from "@welshman/app"
import {createScroller} from "@lib/html"
import {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
export let url
export let pubkey
export let events: TrustedEvent[] = []
export let hideLoading = false
const ctrl = createFeedController({
useWindowing: true,
feed: feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
feed: makeIntersectionFeed(
makeRelayFeed(url),
feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
),
onEvent: (event: TrustedEvent) => {
if (getAncestorTags(event.tags).replies.length === 0) {
buffer.push(event)
@@ -24,7 +30,6 @@
let element: Element
let buffer: TrustedEvent[] = []
let events: TrustedEvent[] = []
onMount(() => {
const scroller = createScroller({
@@ -51,10 +56,14 @@
<div class="col-4" bind:this={element}>
<div class="flex flex-col gap-2">
{#each events as event (event.id)}
<NoteItem {url} {event} />
<div in:fly>
<NoteItem {url} {event} />
</div>
{/each}
<p class="center my-12 flex">
<Spinner loading />
</p>
{#if !hideLoading}
<p class="center my-12 flex">
<Spinner loading />
</p>
{/if}
</div>
</div>
+5 -4
View File
@@ -5,12 +5,12 @@
import {profileSearch} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Suggestions from "@lib/editor/Suggestions.svelte"
import SuggestionProfile from "@lib/editor/SuggestionProfile.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 autofocus = false
@@ -48,13 +48,14 @@
<div class="flex flex-col gap-2">
<div>
{#each value as pubkey (pubkey)}
{@const onClick = () => pushModal(ProfileDetail, {pubkey})}
<div class="flex-inline badge badge-neutral mr-1 gap-1">
<Button class="flex items-center" on:click={() => removePubkey(pubkey)}>
<Icon icon="close-circle" size={4} class="-ml-1 mt-px" />
</Button>
<Link external href={pubkeyLink(pubkey)}>
<Button on:click={onClick}>
<ProfileName {pubkey} />
</Link>
</Button>
</div>
{/each}
</div>
+21 -6
View File
@@ -1,18 +1,23 @@
<script lang="ts">
import {onMount} from "svelte"
import {groupBy, uniqBy} from "@welshman/lib"
import {REACTION} from "@welshman/util"
import {groupBy, uniqBy, batch} from "@welshman/lib"
import {REACTION, DELETE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {pubkey, repository, load, displayProfileByPubkey} from "@welshman/app"
import {displayList} from "@lib/util"
import {isMobile} from "@lib/html"
import {displayReaction} from "@app/state"
export let event
export let onReactionClick
export let relays: string[] = []
export let reactionClass = ""
export let noTooltip = false
const filters = [{kinds: [REACTION], "#e": [event.id]}]
const reactions = deriveEvents(repository, {filters})
const reactions = deriveEvents(repository, {
filters: [{kinds: [REACTION], "#e": [event.id]}],
})
$: groupedReactions = groupBy(
e => e.content,
@@ -20,7 +25,16 @@
)
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>
@@ -35,7 +49,8 @@
<button
type="button"
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-solid={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">
import {goto} from "$app/navigation"
import {randomId} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import Field from "@lib/components/Field.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {addRoomMembership} from "@app/commands"
import {hasNip29} from "@app/state"
import {addRoomMembership, nip29, getThunkError} from "@app/commands"
import {makeSpacePath} from "@app/routes"
import {pushToast} from "@app/toast"
export let url
const room = randomId()
const relay = deriveRelay(url)
const back = () => history.back()
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))
}
@@ -29,7 +56,7 @@
}
}
let room = ""
let name = ""
let loading = false
</script>
@@ -44,7 +71,7 @@
<p slot="label">Room Name</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="hashtag" />
<input bind:value={room} class="grow" type="text" />
<input bind:value={name} class="grow" type="text" />
</label>
</Field>
<ModalFooter>
@@ -52,7 +79,7 @@
<Icon icon="alt-arrow-left" />
Go back
</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>
<Icon icon="alt-arrow-right" />
</Button>
+104 -43
View File
@@ -1,24 +1,55 @@
<script lang="ts">
import {postJson, assoc} from "@welshman/lib"
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 Field from "@lib/components/Field.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import LogIn from "@app/components/LogIn.svelte"
import InfoNostr from "@app/components/InfoNostr.svelte"
import SignUpSuccess from "@app/components/SignUpSuccess.svelte"
import {pushModal, clearModals} from "@app/modal"
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 {loginWithNip46} from "@app/commands"
const relays = ["wss://relay.nsec.app"]
const signerDomain = "nsec.app"
const signerPubkey = "e24a86943d37a91ab485d6f9a7c66097c25ddd67e8bd1b75ed252a3c266cf9bb"
const login = () => pushModal(LogIn)
const trySignup = async () => {
const secret = makeSecret()
const handle = await loadHandle(`${username}@${handler.domain}`)
const withLoading =
(cb: (...args: any[]) => any) =>
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) {
return pushToast({
@@ -27,48 +58,49 @@
})
}
const signupBroker = Nip46Broker.get({secret, handler})
const pubkey = await signupBroker.createAccount(username, nip46Perms)
const clientSecret = makeSecret()
const broker = Nip46Broker.get({
relays,
clientSecret,
signerPubkey,
algorithm: "nip04",
})
if (!pubkey) {
const userPubkey = await broker.createAccount(username, signerDomain, NIP46_PERMS)
if (!userPubkey) {
return pushToast({
theme: "error",
message: "Sorry, it looks like something went wrong. Please try again.",
})
}
// Gotta use user pubkey as the handler pubkey for historical reasons
const loginBroker = Nip46Broker.get({secret, handler: {...handler, pubkey}})
// Now we can log in. Use the user's pubkey for the handler (legacy stuff)
const success = await loginWithNip46({relays, signerPubkey: userPubkey, clientSecret})
if (await loginBroker.connect("", nip46Perms)) {
addSession({method: "nip46", pubkey, secret, handler: {...handler, pubkey}})
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
} else {
pushToast({
if (!success) {
return pushToast({
theme: "error",
message: "Something went wrong! Please try again.",
message: "Sorry, it looks like something went wrong. Please try again.",
})
}
}
const signup = async () => {
loading = true
updateSession($pubkey!, assoc("email", email))
pushToast({message: "Successfully logged in!"})
setChecked("*")
clearModals()
})
try {
await trySignup()
} finally {
loading = false
const signup = () => {
if (BURROW_URL) {
signupPassword()
} else {
signupNsecApp()
}
}
const handler = {
domain: "nsec.app",
relays: ["wss://relay.nsec.app"],
pubkey: "e24a86943d37a91ab485d6f9a7c66097c25ddd67e8bd1b75ed252a3c266cf9bb",
}
let email = ""
let password = ""
let username = ""
let loading = false
</script>
@@ -80,21 +112,50 @@
<Button class="link" on:click={() => pushModal(InfoNostr)}>nostr protocol</Button>, which allows
you to own your social identity.
</p>
<Field>
<div class="flex items-center gap-2" slot="input">
<label class="input input-bordered flex w-full items-center gap-2">
{#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={username} class="grow" type="text" placeholder="username" />
<input bind:value={email} />
</label>
@{handler.domain}
</div>
</Field>
<Button type="submit" class="btn btn-primary" disabled={!username || loading}>
<Spinner {loading}>Sign Up</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</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>
<div class="flex items-center gap-2" slot="input">
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon="user-rounded" />
<input bind:value={username} class="grow" type="text" placeholder="username" />
</label>
@{signerDomain}
</div>
</Field>
<Button type="submit" class="btn btn-primary" disabled={loading || !username}>
<Spinner {loading}>Sign Up</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
{/if}
<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" />
Get started on Nosta.me
</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>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
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 this space.</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 loading = false
$: linkIsValid = Boolean(tryCatch(() => isRelayUrl(normalizeRelayUrl(url))))
$: linkIsValid = Boolean(tryCatch(() => isRelayUrl(normalizeRelayUrl(url.split("|")[0]))))
</script>
<form class="column gap-4" on:submit|preventDefault={join}>
+5 -6
View File
@@ -5,6 +5,7 @@
import {max} from "@welshman/lib"
import {deriveEvents, deriveIsDeleted} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util"
import {thunks, load, pubkey, repository, formatTimestampRelative} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
@@ -14,9 +15,8 @@
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ThreadMenu from "@app/components/ThreadMenu.svelte"
import {publishDelete, publishReaction} from "@app/commands"
import {deriveNotification} from "@app/notifications"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
import {COMMENT} from "@app/state"
export let url
export let event
@@ -27,7 +27,6 @@
const path = makeSpacePath(url, "threads", event.id)
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const replies = deriveEvents(repository, {filters})
const notification = deriveNotification(path, filters, url)
const showPopover = () => popover.show()
@@ -44,7 +43,7 @@
}
const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, relays: [url], content: emoji.unicode})
publishReaction({event, content: emoji.unicode, relays: [url]})
let popover: Instance
@@ -56,8 +55,8 @@
</script>
<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">
<ReactionSummary relays={[url]} {event} {onReactionClick} reactionClass="tooltip-left" />
{#if $deleted}
<div class="btn btn-error btn-xs rounded-full">Deleted</div>
{:else if thunk}
@@ -69,7 +68,7 @@
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div>
<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" />
{/if}
Active {formatTimestampRelative(lastActive)}
+3 -3
View File
@@ -2,7 +2,7 @@
import {onMount} from "svelte"
import type {Readable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {createEvent} from "@welshman/util"
import {createEvent, THREAD} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
@@ -11,7 +11,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
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 {getEditorOptions, getEditorTags} from "@lib/editor"
@@ -38,7 +38,7 @@
})
}
const tags = [["title", title], tagRoom(GENERAL, url), ...getEditorTags($editor)]
const tags = [["title", title], tagRoom(GENERAL, url), ...getEditorTags($editor), PROTECTED]
publishThunk({
relays: [url],
+18 -9
View File
@@ -4,31 +4,40 @@
import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte"
import {makeThreadPath} from "@app/routes"
import {pubkeyLink} from "@app/state"
import {pushModal} from "@app/modal"
export let url
export let event
export let hideActions = false
const title = event.tags.find(nthEq(0, "title"))?.[1]
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
</script>
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeThreadPath(url, event.id)}>
<div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">{title}</p>
<p class="text-sm opacity-75">
{#if title}
<div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">{title}</p>
<p class="text-sm opacity-75">
{formatTimestamp(event.created_at)}
</p>
</div>
{:else}
<p class="-mb-3 h-0 text-end text-xs opacity-75">
{formatTimestamp(event.created_at)}
</p>
</div>
<Content {event} expandMode="inline" />
<div class="flex w-full items-end justify-between gap-2">
{/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">
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} />
</Link>
</button>
</span>
{#if !hideActions}
<ThreadActions showActivity {url} {event} />
+1 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import {COMMENT} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
@@ -6,7 +7,6 @@
import ThreadShare from "@app/components/ThreadShare.svelte"
import ConfirmDelete from "@app/components/ConfirmDelete.svelte"
import {pushModal} from "@app/modal"
import {COMMENT} from "@app/state"
export let url
export let event
+3 -4
View File
@@ -2,7 +2,6 @@
import {onMount} from "svelte"
import type {Readable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {append} from "@welshman/lib"
import {isMobile} from "@lib/html"
import {fly, slideAndFade} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
@@ -10,7 +9,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {getEditorOptions, getEditorTags} from "@lib/editor"
import {getPubkeyHints, publishComment} from "@app/commands"
import {tagRoom, GENERAL} from "@app/state"
import {tagRoom, GENERAL, PROTECTED} from "@app/state"
import {pushToast} from "@app/toast"
export let url
@@ -22,7 +21,7 @@
if ($loading) return
const content = $editor.getText({blockSeparator: "\n"})
const tags = append(tagRoom(GENERAL, url), getEditorTags($editor))
const tags = [...getEditorTags($editor), tagRoom(GENERAL, url), PROTECTED]
if (!content.trim()) {
return pushToast({
@@ -47,7 +46,7 @@
in:fly
out:slideAndFade
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="note-editor flex-grow overflow-hidden">
<EditorContent editor={$editor} />
+7 -6
View File
@@ -7,7 +7,8 @@
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.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 {setKey} from "@app/implicit"
@@ -37,14 +38,14 @@
<div slot="info">Which room would you like to share this thread to?</div>
</ModalHeader>
<div class="grid grid-cols-3 gap-2">
{#each $roomsByUrl.get(url) || [] as room (room)}
{#each $channelsByUrl.get(url) || [] as channel (channel.room)}
<button
type="button"
class="btn"
class:btn-neutral={selection !== room}
class:btn-primary={selection === room}
on:click={() => toggleRoom(room)}>
#{room}
class:btn-neutral={selection !== channel.room}
class:btn-primary={selection === channel.room}
on:click={() => toggleRoom(channel.room)}>
#<ChannelName {...channel} />
</button>
{/each}
</div>
+7 -5
View File
@@ -30,9 +30,11 @@
$: isFailure = !canCancel && ps.every(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(() => {
isPending = false
}, 2000)
@@ -44,13 +46,13 @@
{#if isFailure && failure}
{@const [url, {message, status}] = failure}
<Tippy
class={$$props.class}
class="flex items-center {$$props.class}"
component={ThunkStatusDetail}
props={{url, message, status, retry}}
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} />
<span class="opacity-50">Failed to send!</span>
<span>Failed to send!</span>
</span>
</Tippy>
{:else if canCancel || isPending}
+1 -1
View File
@@ -19,7 +19,7 @@
}
</script>
<div class="card2 bg-alt col-2">
<div class="card2 bg-alt col-2 shadow-2xl">
<p>
Failed to publish to {displayRelayUrl(url)}: {message}.
</p>
+1 -1
View File
@@ -11,7 +11,7 @@
{#key $toast.id}
<div
role="alert"
class="alert flex justify-center"
class="alert flex justify-center whitespace-normal text-left"
class:bg-base-100={theme === "info"}
class:text-base-content={theme === "info"}
class:alert-error={theme === "error"}>
+3 -1
View File
@@ -7,6 +7,7 @@ export type ModalOptions = {
drawer?: boolean
fullscreen?: boolean
replaceState?: boolean
path?: string
}
export type Modal = {
@@ -26,10 +27,11 @@ export const pushModal = (
options: ModalOptions = {},
) => {
const id = randomId()
const path = options.path || ""
modals.update(assoc(id, {id, component, props, options}))
goto("#" + id, {replaceState: options.replaceState})
goto(path + "#" + id, {replaceState: options.replaceState})
return id
}
+60 -61
View File
@@ -1,85 +1,84 @@
import {writable, derived} from "svelte/store"
import {page} from "$app/stores"
import {deriveEvents} from "@welshman/store"
import {repository, pubkey} from "@welshman/app"
import {prop, max, sortBy, assoc, lt, now} from "@welshman/lib"
import type {Filter, TrustedEvent} from "@welshman/util"
import {DIRECT_MESSAGE} from "@welshman/util"
import {makeSpacePath} from "@app/routes"
import {derived} from "svelte/store"
import {synced} from "@welshman/store"
import {pubkey} from "@welshman/app"
import {prop, sortBy, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {MESSAGE} from "@welshman/util"
import {makeSpacePath, makeChatPath, makeThreadPath, makeRoomPath} from "@app/routes"
import {
MESSAGE,
THREAD,
COMMENT,
deriveEventsForUrl,
getMembershipUrls,
userMembership,
THREAD_FILTER,
COMMENT_FILTER,
chats,
getEventsForUrl,
userRoomsByUrl,
repositoryStore,
} from "@app/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 setChecked = (key: string, ts = now()) =>
checked.update(state => ({...state, [key]: ts}))
export const setChecked = (key: string) => checked.update(state => ({...state, [key]: now()}))
// Filters for various routes
// Derived notifications state
export const CHAT_FILTERS: Filter[] = [{kinds: [DIRECT_MESSAGE]}]
export const notifications = derived(
[pubkey, checked, chats, userRoomsByUrl, repositoryStore],
([$pubkey, $checked, $chats, $userRoomsByUrl, $repository]) => {
const hasNotification = (path: string, events: TrustedEvent[]) => {
const [latestEvent] = sortBy($e => -$e.created_at, events)
export const SPACE_FILTERS: Filter[] = [{kinds: [THREAD, MESSAGE, COMMENT]}]
if (!latestEvent || latestEvent.pubkey === $pubkey) {
return false
}
export const ROOM_FILTERS: Filter[] = [{kinds: [MESSAGE]}]
for (const [entryPath, ts] of Object.entries($checked)) {
const isMatch = entryPath === "*" || entryPath.startsWith(path)
export const THREAD_FILTERS: Filter[] = [
{kinds: [THREAD]},
{kinds: [COMMENT], "#K": [String(THREAD)]},
]
if (isMatch && ts > latestEvent.created_at) {
return false
}
}
export const getNotificationFilters = (since: number): Filter[] =>
[...CHAT_FILTERS, ...SPACE_FILTERS, ...THREAD_FILTERS].map(assoc("since", since))
return true
}
export const getRoomFilters = (room: string): Filter[] => ROOM_FILTERS.map(assoc("#~", [room]))
const paths = new Set<string>()
// Notification derivation
for (const {pubkeys, messages} of $chats) {
const chatPath = makeChatPath(pubkeys)
export const getNotification = (
pubkey: string | null,
lastChecked: number,
events: TrustedEvent[],
) => {
const [latestEvent] = sortBy($e => -$e.created_at, events)
if (hasNotification(chatPath, messages)) {
paths.add("/chat")
paths.add(chatPath)
}
}
return latestEvent?.pubkey !== pubkey && lt(lastChecked, latestEvent?.created_at)
}
for (const [url, rooms] of $userRoomsByUrl.entries()) {
const spacePath = makeSpacePath(url)
const threadPath = makeThreadPath(url)
const threadFilters = [THREAD_FILTER, COMMENT_FILTER]
const threadEvents = getEventsForUrl($repository, url, threadFilters)
export const deriveNotification = (path: string, filters: Filter[], url?: string) => {
const events = url ? deriveEventsForUrl(url, filters) : deriveEvents(repository, {filters})
if (hasNotification(threadPath, threadEvents)) {
paths.add(spacePath)
paths.add(threadPath)
}
return derived(
[pubkey, deriveChecked("*"), deriveChecked(path), events],
([$pubkey, $allChecked, $checked, $events]) => {
return getNotification($pubkey, max([$allChecked, $checked]), $events)
},
)
}
for (const room of rooms) {
const roomPath = makeRoomPath(url, room)
const roomFilters = [{kinds: [MESSAGE], "#h": [room]}]
const roomEvents = getEventsForUrl($repository, url, roomFilters)
export const spacesNotifications = derived(
[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)
if (hasNotification(roomPath, roomEvents)) {
paths.add(spacePath)
paths.add(roomPath)
}
}
}
return latestEvent?.pubkey !== $pubkey && lt(lastChecked, latestEvent?.created_at)
})
return paths
},
)
export const inactiveSpacesNotifications = derived(
[page, spacesNotifications],
([$page, $spacesNotifications]) =>
$spacesNotifications.filter(url => !$page.url.pathname.startsWith(makeSpacePath(url))),
)
+78
View File
@@ -0,0 +1,78 @@
import {partition, assoc, now} from "@welshman/lib"
import {MESSAGE, REACTION, DELETE, THREAD, COMMENT} from "@welshman/util"
import type {Subscription} from "@welshman/net"
import type {AppSyncOpts} from "@welshman/app"
import {subscribe, repository, load, pull, hasNegentropy} from "@welshman/app"
import {userRoomsByUrl, LEGACY_MESSAGE, GENERAL, getEventsForUrl} from "@app/state"
// Utils
export const pullConservatively = ({relays, filters}: AppSyncOpts) => {
const [smart, dumb] = partition(hasNegentropy, relays)
const promises = [pull({relays: smart, filters})]
// Since pulling from relays without negentropy is expensive, limit how many
// duplicates we repeatedly download
for (const url of dumb) {
const events = getEventsForUrl(repository, url, filters)
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 since = now()
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},
{kinds: [COMMENT], "#K": [String(THREAD)], since},
{kinds: [MESSAGE], "#h": Array.from(rooms), since},
],
}),
)
}
return () => {
for (const sub of subs) {
sub.close()
}
}
}
export const listenForChannelMessages = (url: string, room: string) => {
const since = now()
const relays = [url]
const kinds = [MESSAGE, REACTION, DELETE]
const legacyRoom = room === GENERAL ? "general" : room
// Load legacy immediate so our request doesn't get rejected by nip29 relays
load({relays, filters: [{kinds: [LEGACY_MESSAGE], "#~": [legacyRoom]}], delay: 0})
// Load historical state with negentropy if available
pullConservatively({relays, filters: [{kinds, "#h": [room]}]})
// Listen for new messages
return subscribe({relays, filters: [{kinds, "#h": [room], since}]})
}
+11 -4
View File
@@ -1,5 +1,5 @@
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[]) => {
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 makeThreadPath = (url: string, eventId: string) =>
`/spaces/${encodeRelay(url)}/threads/${eventId}`
export const makeThreadPath = (url: string, eventId?: string) => {
let path = `/spaces/${encodeRelay(url)}/threads`
if (eventId) {
path += "/" + eventId
}
return path
}
export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
export const getPrimaryNavItemIndex = ($page: Page) => {
const urls = getMembershipUrls(userMembership.get())
const urls = Array.from(userRoomsByUrl.get().keys())
switch (getPrimaryNavItem($page)) {
case "discover":
+270 -165
View File
@@ -1,28 +1,36 @@
import twColors from "tailwindcss/colors"
import {get, derived} from "svelte/store"
import {nip19} from "nostr-tools"
import type {Maybe} from "@welshman/lib"
import {
ctx,
setContext,
remove,
assoc,
sortBy,
sort,
uniq,
partition,
nth,
pushToMapKey,
nthEq,
shuffle,
parseJson,
fromPairs,
memoize,
addToMapKey,
} from "@welshman/lib"
import {
getIdFilters,
WRAP,
CLIENT_AUTH,
AUTH_JOIN,
REACTION,
ZAP_RESPONSE,
DIRECT_MESSAGE,
GROUP_META,
MESSAGE,
GROUPS,
THREAD,
COMMENT,
getGroupTags,
getRelayTagValues,
getPubkeyTagValues,
isHashedEvent,
@@ -30,11 +38,16 @@ import {
readList,
getListTags,
asDecryptedEvent,
isSignedEvent,
hasValidSignature,
normalizeRelayUrl,
} 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 {
pubkey,
@@ -46,30 +59,30 @@ import {
getDefaultNetContext,
makeRouter,
tracker,
makeTrackerStore,
makeRepositoryStore,
relay,
getSession,
getSigner,
hasNegentropy,
pull,
createSearch,
userFollows,
ensurePlaintext,
thunks,
walkThunks,
} from "@welshman/app"
import type {AppSyncOpts} from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app"
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 MEMBERSHIPS = 10209
export const LEGACY_THREAD = 309
export const INDEXER_RELAYS = [
"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 BURROW_URL = import.meta.env.VITE_BURROW_URL
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
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 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 = [
["amber", twColors.amber[600]],
["blue", twColors.blue[600]],
@@ -144,7 +172,7 @@ export const pubkeyLink = (
relays = ctx.app.router.FromPubkeys([pubkey]).getUrls(),
) => 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 = () => {
const appPubkeys = DEFAULT_PUBKEYS.split(",")
@@ -153,6 +181,8 @@ export const getDefaultPubkeys = () => {
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
}
const failedUnwraps = new Set()
export const ensureUnwrapped = async (event: TrustedEvent) => {
if (event.kind !== WRAP) {
return event
@@ -160,7 +190,7 @@ export const ensureUnwrapped = async (event: TrustedEvent) => {
let rumor = repository.eventsByWrap.get(event.id)
if (rumor) {
if (rumor || failedUnwraps.has(event.id)) {
return rumor
}
@@ -184,53 +214,16 @@ export const ensureUnwrapped = async (event: TrustedEvent) => {
// Send the rumor via our relay so listeners get updated
relay.send("EVENT", rumor)
} else {
failedUnwraps.add(event.id)
}
return rumor
}
export const pullConservatively = ({relays, filters}: AppSyncOpts) => {
const [smart, dumb] = partition(hasNegentropy, relays)
const promises = [pull({relays: smart, filters})]
export const trackerStore = makeTrackerStore()
// Since pulling from relays without negentropy is expensive, limit how many
// 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 repositoryStore = makeRepositoryStore()
export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
let attempted = false
@@ -251,25 +244,64 @@ export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
)
}
export const eventIsForUrl = (url: string, event: TrustedEvent) =>
event.tags.find(nthEq(0, "~"))?.[2] === url
export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thunks]) => {
const getThunksByEventId = memoize(() => {
const thunksByEventId = new Map<string, Thunk[]>()
export const getEventsForUrl = (url: string, filters: Filter[]) =>
sortBy(
for (const thunk of walkThunks(Object.values($thunks))) {
pushToMapKey(thunksByEventId, thunk.event.id, thunk)
}
return thunksByEventId
})
return (id: string) => {
const urls = Array.from($tracker.getRelays(id))
for (const thunk of getThunksByEventId().get(id) || []) {
for (const url of thunk.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,
repository.query(filters).filter(e => eventIsForUrl(url, e)),
$events.filter(e => $getUrlsForEvent(e.id).includes(url)),
)
}
export const deriveEventsForUrl = (url: string, filters: Filter[]) =>
derived(deriveEvents(repository, {filters}), $events =>
derived([deriveEvents(repository, {filters}), getUrlsForEvent], ([$events, $getUrlsForEvent]) =>
sortBy(
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
export const canDecrypt = synced("canDecrypt", false)
export const SETTINGS = 38489
export type Settings = {
@@ -319,27 +351,29 @@ export const {
export const hasMembershipUrl = (list: List | undefined, url: string) =>
getListTags(list).some(t => {
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
})
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) =>
getListTags(list)
.filter(t => t[0] === "~")
.map(t => ({url: t[2], room: t[1]}))
getGroupTags(getListTags(list)).map(([_, room, url, name = ""]) => ({url, room, name}))
export const getMembershipRoomsByUrl = (url: string, list?: List) =>
sort(
getListTags(list)
.filter(t => t[0] === "~" && t[2] === url)
getGroupTags(getListTags(list))
.filter(t => t[2] === url)
.map(nth(1)),
)
export const memberships = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [MEMBERSHIPS]}],
filters: [{kinds: [GROUPS]}],
itemToEvent: item => item.event,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
@@ -353,89 +387,7 @@ export const {
store: memberships,
getKey: list => list.event.pubkey,
load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) =>
load({
...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]}]})
},
load({...request, filters: [{kinds: [GROUPS], authors: [pubkey]}]}),
})
// Chats
@@ -502,20 +454,143 @@ export const chatSearch = derived(chats, $chats =>
}),
)
// Rooms
// Messages
export const roomsByUrl = derived(channels, $channels => {
const $roomsByUrl = new Map<string, string[]>()
// TODO: remove support for legacy messages
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 (channel.room) {
pushToMapKey($roomsByUrl, channel.url, channel.room)
if (room === "general") {
room = GENERAL
}
return {...event, kind: MESSAGE, tags: [...event.tags, tagRoom(room, "")]}
}
return $roomsByUrl
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
export const userSettings = withGetter(
@@ -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
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">
import {throttle} from "throttle-debounce"
import {type Instance} from "tippy.js"
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 Tippy from "@lib/components/Tippy.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
+6 -6
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">
<slot name="label" />
</label>
<div class="flex items-center gap-2">
<div class="col-span-2 flex items-center gap-2">
<slot name="input" />
</div>
{#if $$slots.info}
<p class="flex-end text-sm sm:col-span-2">
<p class="flex-end text-sm md:col-span-3">
{#if $$slots.info}
<slot name="info" />
</p>
{/if}
{/if}
</p>
</div>
+4
View File
@@ -51,6 +51,7 @@
import KeyMinimalisticSquare3 from "@assets/icons/Key Minimalistic Square 3.svg?dataurl"
import Letter from "@assets/icons/Letter.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 Login2 from "@assets/icons/Login 2.svg?dataurl"
import Magnifer from "@assets/icons/Magnifer.svg?dataurl"
@@ -73,6 +74,7 @@
import ShopMinimalistic from "@assets/icons/Shop Minimalistic.svg?dataurl"
import SmileCircle from "@assets/icons/Smile Circle.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 UFO3 from "@assets/icons/UFO 3.svg?dataurl"
import UserHeart from "@assets/icons/User Heart.svg?dataurl"
@@ -132,6 +134,7 @@
letter: Letter,
"link-round": LinkRound,
login: Login,
lock: Lock,
"login-2": Login2,
magnifer: Magnifer,
mailbox: Mailbox,
@@ -155,6 +158,7 @@
"trash-bin-2": TrashBin2,
"ufo-3": UFO3,
"square-share-line": SquareShareLine,
"sort-vertical": SortVertical,
"user-heart": UserHeart,
"user-circle": UserCircle,
"user-rounded": UserRounded,
@@ -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 />
</div>
+2 -2
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import {slide, fade} from "svelte/transition"
export let loading
export let loading = false
</script>
<span class="flex items-center">
<span class="flex min-h-10 items-center">
{#if loading}
<span class="pr-3" transition:slide|local={{axis: "x"}}>
<span class="loading loading-spinner" transition:fade|local={{duration: 100}} />
+16 -6
View File
@@ -2,7 +2,8 @@
import cx from "classnames"
import type {NodeViewProps} from "@tiptap/core"
import {NodeViewWrapper} from "svelte-tiptap"
import {ellipsize, nthEq} from "@welshman/lib"
import {always, nthEq} from "@welshman/lib"
import {parse, renderAsText, ParsedType} from "@welshman/content"
import {type TrustedEvent, fromNostrURI, Address} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import {deriveEvent, entityLink} from "@app/state"
@@ -10,12 +11,21 @@
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
const renderLink = (href: string, display: string) => display
return content.length > 1
? ellipsize(content, 30)
: fromNostrURI(nevent || naddr).slice(0, 16) + "..."
const displayEvent = (e: TrustedEvent) => {
const content = e?.tags.find(nthEq(0, "alt"))?.[1] || e?.content || ""
if (content.length < 1) {
return fromNostrURI(nevent || naddr).slice(0, 16) + "..."
}
const parsed = parse({...e, content})
// Try stripping entities, but if we get nothing back go ahead and show them
const renderEntity = always(parsed.find(p => p.type === ParsedType.Text) ? "" : "[quote]")
return renderAsText(parsed, {renderLink, renderEntity})
}
$: ({identifier, pubkey, kind, id, relays = [], nevent, naddr} = node.attrs)
-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>
-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
},
}),
]
},
})
+1 -2
View File
@@ -1,9 +1,8 @@
<svelte:options accessors />
<script lang="ts">
import {throttle} from "throttle-debounce"
import {fly, slide} from "svelte/transition"
import {clamp} from "@welshman/lib"
import {clamp, throttle} from "@welshman/lib"
import Icon from "@lib/components/Icon.svelte"
import {theme} from "@app/theme"
-5
View File
@@ -23,13 +23,11 @@ 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"
@@ -37,13 +35,11 @@ import {getSetting} from "@app/state"
export {
createSuggestions,
LinkExtension,
EditMention,
EditEvent,
EditImage,
EditBolt11,
EditVideo,
EditLink,
Suggestions,
SuggestionProfile,
}
@@ -108,7 +104,6 @@ export const getEditorOptions = ({
}
},
}),
LinkExtension.extend({addNodeView: () => SvelteNodeViewRenderer(EditLink)}),
Bolt11Extension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(EditBolt11)})),
NProfileExtension.extend({
addNodeView: () => SvelteNodeViewRenderer(EditMention),
+27 -56
View File
@@ -3,10 +3,9 @@
import {onMount} from "svelte"
import {nip19} from "nostr-tools"
import {get, derived} from "svelte/store"
import {page} from "$app/stores"
import {dev} from "$app/environment"
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, Worker} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
PROFILE,
@@ -36,8 +35,8 @@
signer,
dropSession,
getRelayUrls,
subscribe,
userInboxRelaySelections,
load,
} from "@welshman/app"
import * as lib from "@welshman/lib"
import * as util from "@welshman/util"
@@ -49,20 +48,11 @@
import {setupTracking} from "@app/tracking"
import {setupAnalytics} from "@app/analytics"
import {theme} from "@app/theme"
import {
INDEXER_RELAYS,
getMembershipUrls,
getMembershipRooms,
userMembership,
ensureUnwrapped,
MESSAGE,
COMMENT,
THREAD,
GENERAL,
} from "@app/state"
import {loadUserData, subscribePersistent} from "@app/commands"
import {INDEXER_RELAYS, userMembership, ensureUnwrapped, canDecrypt} from "@app/state"
import {loadUserData} from "@app/commands"
import {listenForNotifications} from "@app/requests"
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 state from "@app/state"
@@ -86,6 +76,7 @@
...app,
...state,
...commands,
...requests,
...notifications,
})
@@ -127,7 +118,7 @@
const migrateFreshness = (data: {key: string; value: number}[]) => {
const cutoff = ago(HOUR)
return data.filter(({value}) => value < cutoff)
return data.filter(({value}) => value > cutoff)
}
const migratePlaintext = (data: {key: string; value: number}[]) => data.slice(0, 10_000)
@@ -150,19 +141,20 @@
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}),
relays: {keyPath: "url", store: throttled(3000, relays)},
handles: {keyPath: "nip05", store: throttled(3000, handles)},
freshness: storageAdapters.fromObjectStore(freshness, {
throttle: 1000,
throttle: 3000,
migrate: migrateFreshness,
}),
plaintext: storageAdapters.fromObjectStore(plaintext, {
throttle: 1000,
throttle: 3000,
migrate: migratePlaintext,
}),
tracker: storageAdapters.fromTracker(tracker, {throttle: 1000}),
events: storageAdapters.fromRepositoryAndTracker(repository, tracker, {
throttle: 3000,
migrate: migrateEvents,
}),
}).then(() => sleep(300))
// Unwrap gift wraps as they come in, but throttled
@@ -171,6 +163,10 @@
unwrapper.addGlobalHandler(ensureUnwrapped)
repository.on("update", ({added}) => {
if (!$canDecrypt) {
return
}
for (const event of added) {
if (event.kind === WRAP) {
unwrapper.push(event)
@@ -189,45 +185,22 @@
}
// Listen for space data, populate space-based notifications
let unsubRooms: any
let unsubSpaces: any
userMembership.subscribe($membership => {
unsubRooms?.()
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},
],
})
unsubSpaces?.()
unsubSpaces = listenForNotifications()
})
// Listen for chats, populate chat-based notifications
let unsubChats: any
let chatsSub: any
derived([pubkey, userInboxRelaySelections], identity).subscribe(
([$pubkey, $userInboxRelaySelections]) => {
unsubChats?.()
chatsSub?.close()
if ($pubkey) {
unsubChats = subscribePersistent({
chatsSub = subscribe({
filters: [
{kinds: [WRAP], "#p": [$pubkey], since: ago(WEEK, 2)},
{kinds: [WRAP], "#p": [$pubkey], limit: 100},
@@ -252,9 +225,7 @@
{:then}
<div data-theme={$theme}>
<AppContainer>
{#key $page.url.pathname}
<slot />
{/key}
<slot />
</AppContainer>
<ModalContainer />
<div class="tippy-target" />
+7 -19
View File
@@ -1,9 +1,8 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {ctx} from "@welshman/lib"
import {WRAP} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {pubkey, repository} from "@welshman/app"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
import Button from "@lib/components/Button.svelte"
@@ -13,7 +12,8 @@
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import ChatStart from "@app/components/ChatStart.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"
const startChat = () => pushModal(ChatStart)
@@ -23,23 +23,9 @@
relays: ctx.app.router.UserInbox().getUrls(),
})
const onUpdate = ({added}: {added: TrustedEvent[]}) => {
for (const event of added) {
ensureUnwrapped(event)
}
}
let term = ""
$: chats = $chatSearch.searchOptions(term).filter(c => c.pubkeys.length > 1)
onMount(() => {
repository.on("update", onUpdate)
return () => {
repository.off("update", onUpdate)
}
})
</script>
<SecondaryNav>
@@ -67,5 +53,7 @@
</div>
</SecondaryNav>
<Page>
<slot />
{#key $page.url.pathname}
<slot />
{/key}
</Page>
+2 -2
View File
@@ -15,10 +15,10 @@
import SpaceCheck from "@app/components/SpaceCheck.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {
userMembership,
memberships,
membershipByPubkey,
getMembershipUrls,
userRoomsByUrl,
getDefaultPubkeys,
} from "@app/state"
import {discoverRelays} from "@app/commands"
@@ -98,7 +98,7 @@
{/if}
</div>
</div>
{#if getMembershipUrls($userMembership).includes(relay.url)}
{#if $userRoomsByUrl.has(relay.url)}
<div
class="tooltip absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary"
data-tip="You are already a member of this space.">
+7 -2
View File
@@ -1,9 +1,14 @@
<script lang="ts">
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.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 openProfile = () => pushModal(ProfileDetail, {pubkey})
</script>
<div class="mt-8 min-h-screen bg-base-200 sm:hero">
@@ -34,7 +39,7 @@
<p class="text-sm">
Built with 💜 by
<span class="text-primary">
@<Link external href={pubkeyLink(pubkey)} class="link">hodlbod</Link>
@<Button on:click={openProfile} class="link">hodlbod</Button>
</span>
</p>
<p class="text-xs">
+21 -2
View File
@@ -9,6 +9,8 @@
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ProfileEdit from "@app/components/ProfileEdit.svelte"
import InfoKeys from "@app/components/InfoKeys.svelte"
import {PLATFORM_NAME} from "@app/state"
import {pushModal} from "@app/modal"
import {clip} from "@app/toast"
@@ -21,6 +23,8 @@
const copyNsec = () => clip(nip19.nsecEncode(hexToBytes($session!.secret!)))
const startEdit = () => pushModal(ProfileEdit)
const startEject = () => pushModal(InfoKeys)
</script>
<div class="content column gap-4">
@@ -49,6 +53,21 @@
<Content event={{content: $profile?.about || "", tags: []}} hideMedia />
{/key}
</div>
{#if $session?.email}
<div class="card2 bg-alt col-4 shadow-xl">
<FieldInline>
<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={$session.email} class="grow" />
</label>
<p slot="info">
Your email and password can only be used to log into {PLATFORM_NAME}.
<Button class="link" on:click={startEject}>Start holding your own keys</Button>
</p>
</FieldInline>
</div>
{/if}
<div class="card2 bg-alt col-4 shadow-xl">
<FieldInline>
<p slot="label">Public Key</p>
@@ -57,7 +76,7 @@
slot="input">
<div class="row-2 flex-grow items-center">
<Icon icon="link-round" />
<input class="ellipsize flex-grow" value={$session?.pubkey} />
<input readonly class="ellipsize flex-grow" value={$session?.pubkey} />
</div>
<Button class="flex items-center" on:click={copyNpub}>
<Icon icon="copy" />
@@ -73,7 +92,7 @@
<p slot="label">Private Key</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="link-round" />
<input value={$session.secret} class="grow" type="password" />
<input readonly value={$session.secret} class="grow" type="password" />
<Button class="flex items-center" on:click={copyNsec}>
<Icon icon="copy" />
</Button>
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import {page} from "$app/stores"
</script>
{#key $page.params.relay}
<slot />
{/key}
+25 -17
View File
@@ -1,35 +1,41 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {ifLet, now} from "@welshman/lib"
import {now} from "@welshman/lib"
import {subscribe} from "@welshman/app"
import {DELETE, REACTION} from "@welshman/util"
import {DELETE, REACTION, GROUPS} from "@welshman/util"
import Page from "@lib/components/Page.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte"
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {setChecked} from "@app/notifications"
import {checkRelayConnection, checkRelayAuth} from "@app/commands"
import {decodeRelay, MEMBERSHIPS} from "@app/state"
import {deriveNotification, SPACE_FILTERS} from "@app/notifications"
import {checkRelayConnection, checkRelayAuth, checkRelayAccess} from "@app/commands"
import {decodeRelay} from "@app/state"
import {notifications} from "@app/notifications"
const url = decodeRelay($page.params.relay)
const notification = deriveNotification($page.url.pathname, SPACE_FILTERS, url)
const checkConnection = async () => {
ifLet(await checkRelayConnection(url), error => {
pushToast({theme: "error", message: error})
})
const connectionError = await checkRelayConnection(url)
ifLet(await checkRelayAuth(url, 30_000), error => {
pushToast({theme: "error", message: error})
})
if (connectionError) {
return pushToast({theme: "error", message: connectionError})
}
const [authError, accessError] = await Promise.all([checkRelayAuth(url), checkRelayAccess(url)])
const error = authError || accessError
if (error) {
pushModal(SpaceAuthError, {url, error})
}
}
// We have to watch this one, since on mobile the badge wil be visible when active
// We have to watch this one, since on mobile the badge will be visible when active
$: {
if ($notification) {
if ($notifications.has($page.url.pathname)) {
setChecked($page.url.pathname)
}
}
@@ -39,7 +45,7 @@
const sub = subscribe({
relays: [url],
filters: [{kinds: [MEMBERSHIPS]}, {kinds: [DELETE, REACTION], since: now()}],
filters: [{kinds: [GROUPS]}, {kinds: [DELETE, REACTION], since: now()}],
})
return () => {
@@ -52,5 +58,7 @@
<MenuSpace {url} />
</SecondaryNav>
<Page>
<slot />
{#key $page.url.pathname}
<slot />
{/key}
</Page>
+82 -5
View File
@@ -1,19 +1,44 @@
<script lang="ts">
import {page} from "$app/stores"
import type {TrustedEvent} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import {fade} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ProfileFeed from "@app/components/ProfileFeed.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import {decodeRelay} from "@app/state"
import {makeChatPath} from "@app/routes"
import {
decodeRelay,
channelIsLocked,
makeChannelId,
channelsById,
deriveUserRooms,
deriveOtherRooms,
userRoomsByUrl,
} from "@app/state"
import {makeChatPath, makeRoomPath, makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay)
const relay = deriveRelay(url)
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const threadsPath = makeSpacePath(url, "threads")
const joinSpace = () => pushModal(SpaceJoin, {url})
const addRoom = () => pushModal(RoomCreate, {url})
let relayAdminEvents: TrustedEvent[] = []
$: pubkey = $relay?.profile?.pubkey
</script>
@@ -25,7 +50,12 @@
</div>
<strong slot="title">Home</strong>
<div slot="action" class="row-2">
{#if pubkey}
{#if !$userRoomsByUrl.has(url)}
<Button class="btn btn-primary btn-sm" on:click={joinSpace}>
<Icon icon="login-2" />
Join Space
</Button>
{:else if pubkey}
<Link class="btn btn-primary btn-sm" href={makeChatPath([pubkey])}>
<Icon icon="letter" />
Contact Owner
@@ -87,9 +117,56 @@
</div>
{/if}
</div>
<div class="grid grid-cols-3 gap-2">
<Link href={threadsPath} class="btn btn-primary">
<div class="relative flex items-center gap-2">
<Icon icon="notes-minimalistic" />
Threads
{#if $notifications.has(threadsPath)}
<div
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary-content"
transition:fade />
{/if}
</div>
</Link>
{#each $userRooms as room (room)}
{@const roomPath = makeRoomPath(url, room)}
<Link href={roomPath} class="btn btn-neutral relative">
<div class="flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
{#if channelIsLocked($channelsById.get(makeChannelId(url, room)))}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
{/if}
<ChannelName {url} {room} />
</div>
{#if $notifications.has(roomPath)}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary" transition:fade />
{/if}
</Link>
{/each}
{#each $otherRooms as room (room)}
<Link href={makeRoomPath(url, room)} class="btn btn-neutral">
<div class="relative flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
{#if channelIsLocked($channelsById.get(makeChannelId(url, room)))}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
{/if}
<ChannelName {url} {room} />
</div>
</Link>
{/each}
<Button on:click={addRoom} class="btn btn-neutral">
<Icon icon="add-circle" />
Create Room
</Button>
</div>
{#if pubkey}
<Divider>Recent posts from the relay admin</Divider>
<ProfileFeed {url} {pubkey} />
<div class="hidden flex-col gap-2" class:!flex={relayAdminEvents.length > 0}>
<Divider>Recent posts from the relay admin</Divider>
<ProfileFeed hideLoading {url} {pubkey} bind:events={relayAdminEvents} />
</div>
{/if}
</div>
</div>
+104 -67
View File
@@ -1,81 +1,117 @@
<script lang="ts" context="module">
type Element = {
id: string
type: "date" | "note"
value: string | TrustedEvent
showPubkey: boolean
}
</script>
<script lang="ts">
import {nip19} from "nostr-tools"
import {onMount, onDestroy} from "svelte"
import type {Readable} from "svelte/store"
import {derived} from "svelte/store"
import type {Editor} from "svelte-tiptap"
import {page} from "$app/stores"
import {sortBy, append, now} from "@welshman/lib"
import {sleep, ctx} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, DELETE} from "@welshman/util"
import {formatTimestampAsDate, publishThunk} from "@welshman/app"
import {throttled} from "@welshman/store"
import {createEvent, MESSAGE} from "@welshman/util"
import type {Subscription} from "@welshman/net"
import {formatTimestampAsDate, publishThunk, deriveRelay} from "@welshman/app"
import {slide} from "@lib/transition"
import {createScroller, type Scroller} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import Divider from "@lib/components/Divider.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import ChannelMessage from "@app/components/ChannelMessage.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import {
pullConservatively,
userSettingValues,
userMembership,
decodeRelay,
makeChannelId,
deriveChannel,
deriveEventsForUrl,
GENERAL,
tagRoom,
MESSAGE,
COMMENT,
getMembershipRoomsByUrl,
LEGACY_MESSAGE,
userRoomsByUrl,
displayChannel,
} from "@app/state"
import {setChecked} from "@app/notifications"
import {addRoomMembership, removeRoomMembership, subscribePersistent} from "@app/commands"
import {nip29, addRoomMembership, removeRoomMembership, getThunkError} from "@app/commands"
import {listenForChannelMessages} from "@app/requests"
import {PROTECTED, hasNip29} from "@app/state"
import {popKey} from "@app/implicit"
import {pushToast} from "@app/toast"
const {room = GENERAL} = $page.params
const content = popKey<string>("content") || ""
const url = decodeRelay($page.params.relay)
const channel = deriveChannel(makeChannelId(url, room))
const relay = deriveRelay(url)
const legacyRoom = room === GENERAL ? "general" : room
const events = throttled(
300,
deriveEventsForUrl(url, [
{kinds: [MESSAGE], "#h": [room]},
{kinds: [LEGACY_MESSAGE], "#~": [legacyRoom]},
]),
)
const assertEvent = (e: any) => e as TrustedEvent
const joinRoom = async () => {
if (hasNip29($relay)) {
const message = await getThunkError(nip29.joinRoom(url, room))
if (message && !message.includes("already")) {
return pushToast({theme: "error", message})
}
}
addRoomMembership(url, room, displayChannel(url, room))
}
const leaveRoom = () => {
if (hasNip29($relay)) {
nip29.leaveRoom(url, room)
}
removeRoomMembership(url, room)
}
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 = ({content, tags}: EventContent) =>
publishThunk({
relays: [url],
event: createEvent(MESSAGE, {content, tags: append(tagRoom(room, url), tags)}),
event: createEvent(MESSAGE, {content, tags: [...tags, tagRoom(room, url), PROTECTED]}),
delay: $userSettingValues.send_delay,
})
let loading = true
let elements: Element[] = []
let limit = 30
let loading = sleep(5000)
let sub: Subscription
let element: HTMLElement
let scroller: Scroller
let editor: Readable<Editor>
$: {
elements = []
const elements = derived(events, $events => {
const $elements = []
let previousDate
let previousPubkey
for (const {event} of sortBy(m => m.event.created_at, $channel?.messages || [])) {
if (event.kind === COMMENT) {
continue
}
for (const event of $events.toReversed()) {
const {id, pubkey, created_at} = event
const date = formatTimestampAsDate(created_at)
if (date !== previousDate) {
elements.push({type: "date", value: date, id: date, showPubkey: false})
$elements.push({type: "date", value: date, id: date, showPubkey: false})
}
elements.push({
$elements.push({
id,
type: "note",
value: event,
@@ -86,32 +122,31 @@
previousPubkey = pubkey
}
elements.reverse()
}
return $elements.reverse().slice(0, limit)
})
onMount(() => {
pullConservatively({
relays: [url],
filters: [{kinds: [MESSAGE, DELETE], "#~": [room]}],
onMount(async () => {
// Sveltekiiit
await sleep(100)
scroller = createScroller({
element,
delay: 300,
threshold: 3000,
onScroll: () => {
limit += 30
loading = sleep(5000)
},
})
const unsub = subscribePersistent({
relays: [url],
filters: [{kinds: [MESSAGE, COMMENT], "#~": [room], since: now()}],
})
return () => {
unsub()
}
sub = listenForChannelMessages(url, room)
})
onDestroy(() => {
setChecked($page.url.pathname)
scroller?.stop()
sub?.close()
})
setTimeout(() => {
loading = false
}, 5000)
</script>
<div class="relative flex h-full flex-col">
@@ -119,16 +154,18 @@
<div slot="icon" class="center">
<Icon icon="hashtag" />
</div>
<strong slot="title">{room}</strong>
<strong slot="title">
<ChannelName {url} {room} />
</strong>
<div slot="action" class="row-2">
{#if room !== GENERAL}
{#if getMembershipRoomsByUrl(url, $userMembership).includes(room)}
<Button class="btn btn-neutral btn-sm" on:click={() => removeRoomMembership(url, room)}>
{#if $userRoomsByUrl.get(url)?.has(room)}
<Button class="btn btn-neutral btn-sm" on:click={leaveRoom}>
<Icon icon="arrows-a-logout-2" />
Leave Room
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" on:click={() => addRoomMembership(url, room)}>
<Button class="btn btn-neutral btn-sm" on:click={joinRoom}>
<Icon icon="login-2" />
Join Room
</Button>
@@ -137,25 +174,25 @@
<MenuSpaceButton {url} />
</div>
</PageBar>
<div class="-mt-2 flex flex-grow flex-col-reverse overflow-auto py-2">
{#each elements as { type, id, value, showPubkey } (id)}
<div
class="scroll-container -mt-2 flex flex-grow flex-col-reverse overflow-auto py-2"
bind:this={element}>
{#each $elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
{:else}
<div in:slide>
<ChannelMessage {url} {room} event={assertEvent(value)} {showPubkey} />
<div in:slide class:-mt-4={!showPubkey}>
<ChannelMessage {url} {room} {replyTo} event={assertEvent(value)} {showPubkey} />
</div>
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for messages...
{:else}
End of message history
{/if}
</Spinner>
{#await loading}
<Spinner loading>Looking for messages...</Spinner>
{:then}
<Spinner>End of message history</Spinner>
{/await}
</p>
</div>
<ChannelCompose {content} {onSubmit} />
<ChannelCompose bind:editor {content} {onSubmit} />
</div>
@@ -14,7 +14,8 @@
import EventItem from "@app/components/EventItem.svelte"
import EventCreate from "@app/components/EventCreate.svelte"
import {pushModal} from "@app/modal"
import {deriveEventsForUrl, pullConservatively, decodeRelay} from "@app/state"
import {deriveEventsForUrl, decodeRelay} from "@app/state"
import {pullConservatively} from "@app/requests"
import {setChecked} from "@app/notifications"
const url = decodeRelay($page.params.relay)
+35 -31
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {sortBy, sleep, uniqBy, now} from "@welshman/lib"
import {sortBy, min, nthEq, sleep} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {throttled} from "@welshman/store"
import {feedsFromFilters, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {nthEq} from "@welshman/lib"
import {createFeedController, userMutes} from "@welshman/app"
import {createScroller, type Scroller} from "@lib/html"
import {fly} from "@lib/transition"
@@ -16,40 +16,51 @@
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {THREAD, COMMENT, decodeRelay, getEventsForUrl} from "@app/state"
import {subscribePersistent} from "@app/commands"
import {THREAD_FILTERS, setChecked} from "@app/notifications"
import {THREAD_FILTER, COMMENT_FILTER, decodeRelay, deriveEventsForUrl} from "@app/state"
import {setChecked} from "@app/notifications"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay)
const feeds = feedsFromFilters([THREAD_FILTER, COMMENT_FILTER])
const threads = deriveEventsForUrl(url, [THREAD_FILTER])
const comments = deriveEventsForUrl(url, [COMMENT_FILTER])
const mutedPubkeys = getPubkeyTagValues(getListTags($userMutes))
const events = throttled(
800,
derived([threads, comments], ([$threads, $comments]) => {
const scores = new Map<string, number>()
for (const comment of $comments) {
const id = comment.tags.find(nthEq(0, "E"))?.[1]
if (id) {
scores.set(id, min([scores.get(id), -comment.created_at]))
}
}
return sortBy(
e => min([scores.get(e.id), -e.created_at]),
$threads.filter(e => !mutedPubkeys.includes(e.pubkey)),
)
}),
)
const createThread = () => pushModal(ThreadCreate, {url})
const ctrl = createFeedController({
useWindowing: true,
feed: makeIntersectionFeed(makeRelayFeed(url), feedsFromFilters(THREAD_FILTERS)),
onEvent: (event: TrustedEvent) => {
if (
event.kind === THREAD &&
!event.tags.some(nthEq(0, "e")) &&
!mutedPubkeys.includes(event.pubkey)
) {
buffer.push(event)
}
},
feed: makeIntersectionFeed(makeRelayFeed(url), feeds),
onExhausted: () => {
loading = false
},
})
let limit = 10
let loading = true
let unmounted = false
let element: Element
let scroller: Scroller
let buffer: TrustedEvent[] = []
let events: TrustedEvent[] = sortBy(e => -e.created_at, getEventsForUrl(url, [{kinds: [THREAD]}]))
onMount(() => {
// Element is frequently not defined. I don't know why
@@ -60,10 +71,9 @@
delay: 300,
threshold: 3000,
onScroll: () => {
buffer = sortBy(e => -e.created_at, buffer)
events = uniqBy(e => e.id, [...events, ...buffer.splice(0, 5)])
limit += 10
if (buffer.length < 50) {
if ($events.length - limit < 10) {
ctrl.load(50)
}
},
@@ -71,13 +81,7 @@
}
})
const unsub = subscribePersistent({
relays: [url],
filters: [{kinds: [COMMENT], "#K": [String(THREAD)], since: now()}],
})
return () => {
unsub()
unmounted = true
scroller?.stop()
setChecked($page.url.pathname)
@@ -100,17 +104,17 @@
</div>
</PageBar>
<div class="flex flex-grow flex-col gap-2 overflow-auto p-2">
{#each events as event (event.id)}
{#each $events as event (event.id)}
<div in:fly>
<ThreadItem {url} {event} />
</div>
{/each}
{#if loading || events.length === 0}
{#if loading || $events.length === 0}
<p class="flex h-10 items-center justify-center py-20" out:fly>
<Spinner {loading}>
{#if loading}
Looking for threads...
{:else if events.length === 0}
{:else if $events.length === 0}
No threads found.
{/if}
</Spinner>
@@ -1,8 +1,9 @@
<script lang="ts">
import {onMount} from "svelte"
import {sortBy, nthEq, sleep} from "@welshman/lib"
import {page} from "$app/stores"
import {repository} from "@welshman/app"
import {sortBy, nthEq, sleep} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {repository, subscribe} from "@welshman/app"
import {deriveEvents} from "@welshman/store"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
@@ -13,8 +14,7 @@
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte"
import ThreadReply from "@app/components/ThreadReply.svelte"
import {COMMENT, deriveEvent, decodeRelay} from "@app/state"
import {subscribePersistent} from "@app/commands"
import {deriveEvent, decodeRelay} from "@app/state"
import {setChecked} from "@app/notifications"
const {relay, id} = $page.params
@@ -33,15 +33,20 @@
showReply = false
}
const expand = () => {
showAll = true
}
let showAll = false
let showReply = false
$: title = $event?.tags.find(nthEq(0, "title"))?.[1] || ""
onMount(() => {
const unsub = subscribePersistent({relays: [url], filters})
const sub = subscribe({relays: [url], filters})
return () => {
unsub()
sub.close()
setChecked($page.url.pathname)
}
})
@@ -51,14 +56,14 @@
<div class="absolute left-[51px] top-32 h-[calc(100%-248px)] w-[2px] bg-neutral" />
{#if $event}
{#if !showReply}
<div class="flex justify-end p-2">
<div class="flex justify-end px-2 pb-2">
<Button class="btn btn-primary" on:click={openReply}>
<Icon icon="reply" />
Reply to thread
</Button>
</div>
{/if}
{#each sortBy(e => -e.created_at, $replies) as reply (reply.id)}
{#each sortBy(e => -e.created_at, $replies).slice(0, showAll ? undefined : 4) as reply (reply.id)}
<NoteCard event={reply} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12">
<Content showEntire event={reply} />
@@ -66,9 +71,17 @@
</div>
</NoteCard>
{/each}
{#if !showAll && $replies.length > 4}
<div class="flex justify-center">
<Button class="btn btn-link" on:click={expand}>
<Icon icon="sort-vertical" />
Show all {$replies.length} replies
</Button>
</div>
{/if}
<NoteCard event={$event} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12">
<Content showEntire event={$event} />
<Content showEntire event={$event} quoteProps={{relays: [url]}} />
<ThreadActions event={$event} {url} />
</div>
</NoteCard>
+3 -3
View File
@@ -1,9 +1,9 @@
import dotenv from "dotenv"
import {config} from "dotenv"
import daisyui from "daisyui"
import themes from "daisyui/src/theming/themes"
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env"})
config({path: ".env.local"})
config({path: ".env"})
/** @type {import('tailwindcss').Config} */
export default {
+3 -3
View File
@@ -1,11 +1,11 @@
import dotenv from "dotenv"
import {config} from "dotenv"
import {defineConfig} from "vite"
import {SvelteKitPWA} from "@vite-pwa/sveltekit"
import {sveltekit} from "@sveltejs/kit/vite"
import svg from "@poppanator/sveltekit-svg"
dotenv.config({path: ".env.local"})
dotenv.config({path: ".env"})
config({path: ".env.local"})
config({path: ".env"})
export default defineConfig({
server: {